diff --git a/CredentialProvider/MyVault/app/build.gradle.kts b/CredentialProvider/MyVault/app/build.gradle.kts index adc3d238..8f8456d4 100644 --- a/CredentialProvider/MyVault/app/build.gradle.kts +++ b/CredentialProvider/MyVault/app/build.gradle.kts @@ -21,9 +21,7 @@ plugins { android { namespace = "com.example.android.authentication.myvault" - compileSdk = 35 - defaultConfig { applicationId = "com.example.android.authentication.myvault" minSdk = 34 diff --git a/Shrine/README.md b/Shrine/README.md new file mode 100644 index 00000000..d5bc9b46 --- /dev/null +++ b/Shrine/README.md @@ -0,0 +1,255 @@ +# Credential Manager Sample App + +This is the repository for the Credential Manager API code integration app, +also known as the **"Shrine"** app. + +The Shrine app is a fully functional Android app built with Kotlin and Jetpack Compose. +This sample app is built to share a working sample of Credential Manager APIs in Android +and help visualize the workflow. This sample code is designed to help you understand the +workflow better and allow you to estimate the level of effort needed to incorporate +Credential Manager with your own apps. + +## Features + +This sample app implements the following use cases: + +* Create an account using username and set the session using password. +* Generate a new passkey for an existing account +* Store the credentials for created accounts in the user's Google Password Manager account. +* Sign in flow with passkeys support +* Sign in flow with restore credentials support +* Logout from the account. + +## Requirements + +* Latest release of [Android Studio](https://developer.android.com/studio) +* Java 11 or higher +* A web browser with the ability to access [Glitch](https://glitch.com/). + +## Typical account creation and login flow + +* Launch the app +* Create an account by sending any username to the server +* Set a session by sending a password in step 2 +* Register user credentials using your fingerprint sensor +* From the list of passkeys options shown in the bottom sheet, select the correct passkey option to login +* Logout of the application and close the app +* [Optional] Create multiple accounts and switch accounts to test the implementation + + +## How to setup your own Glitch.me server + +The Shrine app sends requests to a Glitch.me server, and out of the box this code example has been +configured to use a Glitch instance that we've created. To use your own Glitch-hosted backend, +follow these steps. The backend code uses your Android package and SHA fingerprint, and you will +update these on the server. + +1. Go to the edit page of the website at [https://glitch.com/edit/#!/credential-manager-app-test](https://glitch.com/edit/#!/credential-manager-app-test) + +2. Find the ***"Remix to Edit"*** button at the top right corner. By pressing the button, you can fork the code and continue this tutorial with your own version of the project and services. + +3. To use the API on an Android app, you need to associate it with a website and share credentials between them. To set this up, you'll use [Digital Asset Links](https://developer.android.com/training/sign-in/passkeys#add-support-dal). Digital Asset Links files are used to declare associations by hosting a JSON file on your website, and adding a link to this file to your app's manifest. Normally, you'll define an association between your app and the website by creating a JSON file and put it at `.well-known/assetlinks.json` on your HTTPS server. **For this demo, we have a server code that creates an `assetlinks.json` file automatically, just by adding the following environment params to the `.env` file in Glitch:** + + 1. In the Glitch left nav Files section, click on the `.env` file. This opens up your project's Environment Config. Fill in the following values: + + 2. `HOSTNAME`: The name of your newly created Glitch service. The project name is found on top left of your Glitch project screen. It'll be something like `peaceful-banana-fern`. Paste or type in the name of your Glitch project into the `HOSTNAME` section. + + 3. `ANDROID_PACKAGENAME`: The package name of your app, such as `com.google.credentialmanager.sample`. You can find the package name in your project's app-level `build.gradle` file as the value of the `applicationId` property within the `android` block. + + 4. `ANDROID_SHA256HASH`: SHA-256 hash of your signing certificate. To get the SHA-256 hash of your developer signing certificate, use the following command: `keytool -list -v -alias androiddebugkey -keystore ~/.android/debug.keystore`. The default password of the debug keystore is "android". The SHA256 value appears under Certificate fingerprints. (`75:89:78:74:...`) + + +4. In your `build.gradle`'s `android` block, find the fields for `buildConfigField` and `resValue`, and update the following values. + + 1. `buildConfigField / API_BASE_URL`: The URL of your new Glitch server's API. It'll be the full URL + /path appended to the end. For example: `https://peaceful-banana-fern.glitch.me/auth` + + 2. `resValue / host`: The root URL for your server. For example: [https://peaceful-banana-fern.glitch.me](https://peaceful-banana-fern.glitch.me) + + +5. Sync your `build.gradle` changes by running **File > Sync Project with Gradle Files**. + +6. Test building your app. Run a physical or emulated device that has a valid and passkey-enabled Google account set up, then run your app on it. You should see the Shrine app home screen appear, with Sign In and Sign Up buttons. Don't click anything just yet, you'll do that in the next step. + + +## Integration + +Follow these steps to test Credential Manager integration. In the app, look for a toast to appear to indicate a response success or failure on each step. + +### Create an account for username on the server + +1. When your app runs the first time, you should see a screen with buttons for Sign In and Sign Up. Click the **Sign Up** button. The **Create Account** screen appears. + +2. Enter an email address and unique password and click the **Submit** button. + +3. You should now see a **Create a passkey** screen. Click **Create a passkey**. + +4. You should see a Google Password Manager bottom sheet appear, offering to save your credentials. Click **Continue**. + +5. The Shrine app should then show the **Create a passkey** screen. Click the **Create a passkey** button and a Google Password Manager bottom sheet should appear that offers to create a passkey for your app. Click **Continue**. You should now see the Shrine main menu. + +6. Click **Step 1: Send Username** after adding a username and email in the 1st field. For demo purposes, the app and the server will accept any username. + +7. Check the username, and create a new account if it doesn't exist. + +8. Set a `username` in the session. + +9. Wait for the toast to appear saying "Username verified successfully". If you don't see toast, check the logs for errors. + + +### **Set a session on server** + +This step demonstrates if developers want to do additional authentication (2FA). This step shows how 2FA can be done while using passkeys for authentication. + +1. Check **Step 2: Send Password**. Above that field, add any password. Type and send the request. + +2. Verify the user credential and let the user sign-in. No preceding registration is required. + +3. Wait for the toast to appear that says "Session-id stored successfully, Do register!" + +4. If you don't see toast, check the logs for errors. + + +### **Pass required information to a passkey creation prompt** + +This section describes how to send a registration request to the server and pass the required information to a passkey creation prompt. + +1. Register the passkey credential. Inside `AuthRepository.kt`, find `registerRequest`. + +2. Once the request is sent from the client, this method calls the server API `/auth/registerRequest`. The API returns an `ApiResult` with all the `PublicKeyCredentialCreationOptions` that the client needs to generate a new credential. + + +### **Create a passkey** + +In this section, you'll create a passkey with the response received from `/registerRequest`. + +1. Parse the params as per needed for the create credentials call. Call `createPasskey()` from `Auth.kt` + +2. Give users the choice to enroll a passkey and use it for re-authentication by registering a user credential using a `CreatePublicKeyCredentialRequest()` object. + +3. This method calls `createCredential()` from *Credential Manager API*, which registers a user credential that can be used to authenticate the user to the app in the future. This method launches framework UI flows for a user to view their registration options, grant consent, etc. + +4. Use your fingerprint or other auth. Methods from your device to register. + + +### **Send the registration response and register a user credential** + +This section describes how to send a registration response back to the server and register a user credential on a server. + +- Call `/registerResponse`. This `registerResponse` method is called after the user interface successfully generates a new credential, and you want to send it back to the server. + +- Use the response received from the `createPasskey()` call and pass it back to your server. + +- Remember the ID of your local key so you can distinguish it from other keys registered on the server. In the `PublicKeyCredential` object, use the `rawId` property. + +- The returned value contains a list of all the credentials registered on the server, including the new one. + + +### **How to retrieve previously stored credentials for user’s account** + +You now have a credential registered on the app and the server. You can now use it to let the user sign in. + +1. Initiate a server check: + +- Open the file `AuthRepository.kt`. + +- Examine the `signinRequest` object. + +- Send a request to your server to confirm if Credential Manager APIs can be used for user sign-in. + +- Provide the `sessionId` and `credentialId` (which were stored locally) as data for this request. + + +2. Prompt the user for stored credentials: + +- If the server request is successful, call the `getPasskey()` function from the `Auth.kt` file to display the user's stored credentials. + +3. Configure the retrieval request: + +- Create a `GetCredentialRequest()`. + +- Provide the previously created registration options to this request. + +- For the `isAutoSelectAllowed()` flag: + + +- Set to `true` if you want a single stored credential to be automatically selected. + +- Set to `false` to require manual selection. + + +4. Retrieve credentials: + +- Use `PublicKeyCredentialOptions` with the `GetCredentialRequest` to retrieve all the user's eligible credentials. + +- Ensure that the `requestJson` argument is in a valid WebAuthn JSON format. + + +5. Display the credential selection interface: + + +- Call `CredentialManager.getCredential()` to display a bottom sheet interface. + +- This UI displays a list of previously saved credentials. The user can then select a credential and provide consent to proceed with authentication. + + +### **Send a sign-in response and authenticate the user** + +This section describes how to send a sign-in response to the server and authenticate your user. + +1. Call `signinResponse` from `AuthRespository.kt`. Pass the response and credential to the method as parameters. + +2. If successful, the user has been signed in and you can redirect them to the home screen. + + +### **Restore credentials of a returning user on a new device** + +This section describes how to implement restore credentials + +1. On a successful user authentication, create a Restore Key + + 1. Call `AuthRepository`'s `registerPasskeyCreationRequest` method + + 2. With the PasskeyCreationRequest recieved from the above method, call `CredentialManagerUtils`'s `createRestoreKey` method + + 3. Then call `AuthRepository`'s `registerPasskeyCreationResponse` method + + +2. Once on a new device, check if there is any restore key present on the device or not (brought to the new device in the process of Backup and Restore) + + 1. Call `AuthRepository`'s `signInWithPasskeyOrPasswordRequest` method + + 2. With the PasskeyCreationRequest recieved from the above method, call `CredentialManagerUtils`'s `getRestoreKey` method + + 3. If there is a RestoreKey present this will return a `GenericCredentialManagerResponse.GetCredentialSuccess` else this will return a `GenericCredentialManagerResponse.Error` + + +3. Sign in using the found Restore Key + + 1. If a restore key is found in the above step, simply use it to sign-in using `AuthRepository`'s `signInWithPasskeysResponse` method + + +4. Delete a Restore Key + + 1. If a user logs out of the app, make sure to clear the stored restore key by calling `CredentialManagerUtils`'s `deleteRestoreKey` + + +## **Specific use case handling** + +- When there are no passkeys associated with accounts registered, the developer should catch the exception code and let the user know that first he needs to create a passkey before they try to fetch it. + +- For Begin Sign In Failure: 16: Caller has been temporarily blocked due to too many canceled sign-in prompt errors: This is a FIDO-specific error. + +- For Begin Sign In Failure: 8: Unknown internal error. If the phone isn't set up properly with a Google account, the passkey JSON is being created incorrectly. + +- For `publickeycredential.CreatePublicKeyCredentialDomException`: The incoming request cannot be validated: This means your application package ID is not registered with your server. Validate this with your server code. + + +### **How to build a debug signed version** + +To build a debug signed version of this sample app, you need to update the `API_BASE_URL` to `https://credential-manager-app-test.glitch.me/auth` and `host` field to `https://credman-glitch-sample.glitch.me` under `buildConfigField` in your `build.gradle` file. + +## **License** + +Shrine is distributed under the terms of the Apache License (Version 2.0). See the license for more information. + +** diff --git a/Shrine/app/.gitignore b/Shrine/app/.gitignore new file mode 100644 index 00000000..3a2358d3 --- /dev/null +++ b/Shrine/app/.gitignore @@ -0,0 +1,36 @@ +# built application files +*.apk +*.ap_ + +# Mac files +.DS_Store + +# files for the dex VM +*.dex + +# Java class files +*.class + +# generated files +bin/ +gen/ + +# Ignore gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ +proguard-project.txt + +# Eclipse files +.project +.classpath +.settings/ + +# Android Studio/IDEA +*.iml +.idea \ No newline at end of file diff --git a/Shrine/app/build.gradle.kts b/Shrine/app/build.gradle.kts new file mode 100644 index 00000000..88819969 --- /dev/null +++ b/Shrine/app/build.gradle.kts @@ -0,0 +1,183 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.io.FileInputStream +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + id("dagger.hilt.android.plugin") + alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.compose) +} + +repositories { + google() + mavenCentral() +} + +// Load keystore properties for our signing key +// (see developer.android.com/studio/publish/app-signing) +val keystorePropertiesFile: File = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + +android { + namespace = "com.authentication.shrine" + compileSdk = 35 + + defaultConfig { + applicationId = "com.authentication.shrine" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + buildConfigField("String", "API_BASE_URL", "\"https://project-sesame-426206.appspot.com/\"") + buildConfigField("String", "API_BASE_DOMAIN", "\"project-sesame-426206.appspot.com\"") + } + + signingConfigs { + create("config") { + keyAlias = keystoreProperties["keyAlias"].toString() + keyPassword = keystoreProperties["keyPassword"].toString() + storeFile = file(keystoreProperties["storeFile"].toString()) + storePassword = keystoreProperties["storePassword"].toString() + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("config") + } + + debug { + signingConfig = signingConfigs.getByName("config") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs += "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api" + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Credential Manager + implementation(libs.credentials) + implementation(libs.google.id) + implementation(libs.play.services.auth) + + // optional - needed for credentials support from play services, for devices running + // Android 13 and below. + implementation(libs.credentials.play.services.auth) + + // FIDO + implementation(libs.play.services.fido) + + //Firebase Specific + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + implementation(libs.firebase.database) + implementation(libs.firebase.functions) + + // ViewModel + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.viewmodel.ktx) + + // for collectAsStateWithLifecycle + implementation(libs.lifecycle.runtime.compose) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.graphics) + implementation(libs.compose.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.activity.compose) + implementation(libs.compose.foundation.layout) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + implementation(libs.compose.material.iconsExtended) + + implementation(libs.coil.kt.compose) + implementation(libs.coil.svg) + + // Navigation + implementation(libs.navigation.compose) + implementation(libs.system.ui.controller) + + // Runtime + implementation(libs.compose.runtime) + implementation(libs.compose.runtime.livedata) + + // Font + implementation(libs.google.fonts) + + // Defaults + implementation(libs.core.ktx) + implementation(libs.lifecycle.runtime.ktx) + implementation(platform(libs.compose.bom)) + implementation(libs.browser) + + // Retrofit + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.androidx.foundation) + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + + // Dagger Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + ksp(libs.androidx.hilt.compiler) + + // Other Dependencies + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.kotlin.coroutines) + implementation(libs.datastore.pref) + implementation(libs.hilt.navigation.compose) + implementation(libs.lifecycle.runtime.compose) + implementation(libs.gms.location) + implementation(libs.androidx.core.splashscreen) +} diff --git a/Shrine/app/proguard-rules.pro b/Shrine/app/proguard-rules.pro new file mode 100644 index 00000000..5519fb0b --- /dev/null +++ b/Shrine/app/proguard-rules.pro @@ -0,0 +1,26 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} diff --git a/Shrine/app/src/main/AndroidManifest.xml b/Shrine/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0d8f9d67 --- /dev/null +++ b/Shrine/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + diff --git a/Shrine/app/src/main/assets/aaguids.json b/Shrine/app/src/main/assets/aaguids.json new file mode 100644 index 00000000..84d62855 --- /dev/null +++ b/Shrine/app/src/main/assets/aaguids.json @@ -0,0 +1,130 @@ +{ + "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": { + "icon_light": "", + "icon_dark": "" + }, + "adce0002-35bc-c60a-648b-0b25f1f05503": { + "icon_light": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n", + "icon_dark": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n" + }, + "08987058-cadc-4b81-b6e1-30de50dcbe96": { + "icon_light": "", + "icon_dark": "" + }, + "9ddd1817-af5a-4672-a2b9-3e3dd95000a9": { + "icon_light": "", + "icon_dark": "" + }, + "6028b017-b1d4-4c02-b4b3-afcdafc96bb2": { + "icon_light": "", + "icon_dark": "" + }, + "dd4ec289-e01d-41c9-bb89-70fa845d4bf2": { + "icon_light": "", + "icon_dark": "" + }, + "531126d6-e717-415c-9320-3d9aa6981239": { + "icon_light": "\n\n\n\n\n\n\n\n", + "icon_dark": "\n\n\n\n\n\n\n\n" + }, + "bada5566-a7aa-401f-bd96-45619a55120d": { + "icon_light": "\n\n\n", + "icon_dark": "\n\n\n" + }, + "b84e4048-15dc-4dd0-8640-f4f60813c8af": { + "icon_light": "\n \n\n", + "icon_dark": "\n \n\n" + }, + "0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": { + "icon_light": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "icon_dark": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + }, + "891494da-2c90-4d31-a9cd-4eab0aed1309": { + "icon_light": "\n\n\n\n\n\n\n\n\n\n\n", + "icon_dark": "\n\n\n\n\n\n\n\n\n\n\n" + }, + "f3809540-7f14-49c1-a8b3-8f813b225541": { + "icon_light": "\n\n\n", + "icon_dark": "\n\n\n" + }, + "b5397666-4885-aa6b-cebf-e52262a439a2": { + "icon_light": "", + "icon_dark": "" + }, + "771b48fd-d3d4-4f74-9232-fc157ab0507a": { + "icon_light": "Edge_Logo_265x265", + "icon_dark": "Edge_Logo_265x265" + }, + "39a5647e-1853-446c-a1f6-a79bae9f5bc7": { + "icon_light": "", + "icon_dark": "" + }, + "d548826e-79b4-db40-a3d8-11116f7e8349": { + "icon_light": "\n\n\n\n\n\n\n\n\n\n\n", + "icon_dark": "\n\n\n\n\n\n\n\n\n\n\n" + }, + "fbfc3007-154e-4ecc-8c0b-6e020557d7bd": { + "icon_light": "", + "icon_dark": "" + }, + "53414d53-554e-4700-0000-000000000000": { + "icon_light": "", + "icon_dark": "" + }, + "66a0ccb3-bd6a-191f-ee06-e375c50b9846": { + "icon_light": "", + "icon_dark": "" + }, + "8836336a-f590-0921-301d-46427531eee6": { + "icon_light": "", + "icon_dark": "" + }, + "cd69adb5-3c7a-deb9-3177-6800ea6cb72a": { + "icon_light": "", + "icon_dark": "" + }, + "17290f1e-c212-34d0-1423-365d729f09d9": { + "icon_light": "", + "icon_dark": "" + }, + "50726f74-6f6e-5061-7373-50726f746f6e": { + "icon_light": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "icon_dark": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + }, + "fdb141b2-5d84-443e-8a35-4698c205a502": { + "icon_light": "", + "icon_dark": "" + }, + "cc45f64e-52a2-451b-831a-4edd8022a202": { + "icon_light": "\nimage/svg+xml", + "icon_dark": "\nimage/svg+xml\n" + }, + "bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0": { + "icon_light": "\n\n\n \n \n \n\n", + "icon_dark": "\n\n\n \n \n \n\n" + }, + "b35a26b2-8f6e-4697-ab1d-d44db4da28c6": { + "icon_light": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n", + "icon_dark": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n" + }, + "b78a0a55-6ef8-d246-a042-ba0f6d55050c": { + "icon_light": "\n\n\n\n\n\n\n\n\n\n\n\n\n", + "icon_dark": "\n\n\n\n\n\n\n\n\n\n\n\n\n" + }, + "de503f9c-21a4-4f76-b4b7-558eb55c6f89": { + "icon_light": "\n\t\n \n \n \n \n \n \n \n \n \n \n\n\n\n", + "icon_dark": "\n\t\n \n \n \n \n \n \n \n \n \n \n\n\n\n" + }, + "22248c4c-7a12-46e2-9a41-44291b373a4d": { + "icon_light": "\n\n\n\n\n", + "icon_dark": "\n\n\n\n" + }, + "a10c6dd9-465e-4226-8198-c7c44b91c555": { + "icon_light": "", + "icon_dark": "" + }, + "d350af52-0351-4ba2-acd3-dfeeadc3f764": { + "icon_light": "", + "icon_dark": "" + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/CredentialManagerUtils.kt b/Shrine/app/src/main/java/com/authentication/shrine/CredentialManagerUtils.kt new file mode 100644 index 00000000..fd6e4624 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/CredentialManagerUtils.kt @@ -0,0 +1,268 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine + +import android.annotation.SuppressLint +import android.content.Context +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.ClearCredentialStateRequest.Companion.TYPE_CLEAR_RESTORE_CREDENTIAL +import androidx.credentials.CreateCredentialRequest +import androidx.credentials.CreateCredentialResponse +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePasswordResponse +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CreateRestoreCredentialRequest +import androidx.credentials.CreateRestoreCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.GetRestoreCredentialOption +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException +import com.authentication.shrine.repository.SERVER_CLIENT_ID +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import org.json.JSONObject +import javax.inject.Inject + +/** + * A utility class that provides methods for interacting with the credential manager. + * + * @param credentialManager Instance of [CredentialManager] + */ +class CredentialManagerUtils @Inject constructor( + private val credentialManager: CredentialManager, +) { + + /** + * Retrieves a passkey or password credential from the credential manager. + * + * @param publicKeyCredentialRequestOptions The public key credential request options. + * @param context The activity context from the Composable, to be used in Credential Manager APIs + * @return The [GenericCredentialManagerResponse] object containing the passkey or password, or + * null if an error occurred. + */ + suspend fun getPasskeyOrPasswordCredential( + publicKeyCredentialRequestOptions: JSONObject, + context: Context, + ): GenericCredentialManagerResponse { + val passkeysEligibility = PasskeysEligibility.isPasskeySupported(context) + if (!passkeysEligibility.isEligible) { + return GenericCredentialManagerResponse.Error(errorMessage = passkeysEligibility.reason) + } + + val result: GetCredentialResponse? + try { + val credentialRequest = GetCredentialRequest( + listOf( + GetPublicKeyCredentialOption( + publicKeyCredentialRequestOptions.toString(), + null, + ), + GetPasswordOption(), + ), + ) + result = credentialManager.getCredential(context, credentialRequest) + } catch (e: GetCredentialCancellationException) { + // When credential selector bottom-sheet is cancelled + return GenericCredentialManagerResponse.CancellationError + } catch (e: GetPublicKeyCredentialDomException) { + // When the user verification / biometric bottom sheet is cancelled + return GenericCredentialManagerResponse.CancellationError + } catch (e: Exception) { + return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "") + } + return GenericCredentialManagerResponse.GetCredentialSuccess(getCredentialResponse = result) + } + + /** + * Retrieves a Sign in with Google credential from the credential manager. + * + * @param context The activity context from the Composable, to be used in Credential Manager + * APIs + * @return The [GenericCredentialManagerResponse] object containing the passkey or password, or + * null if an error occurred. + */ + suspend fun getSignInWithGoogleCredential(context: Context): GenericCredentialManagerResponse { + val result: GetCredentialResponse? + try { + val credentialRequest = GetCredentialRequest( + listOf( + GetGoogleIdOption.Builder() + .setServerClientId(SERVER_CLIENT_ID) + .setFilterByAuthorizedAccounts(false) + .build(), + ) + ) + result = credentialManager.getCredential(context, credentialRequest) + } catch (e: GetCredentialCancellationException) { + // When credential selector bottom-sheet is cancelled + return GenericCredentialManagerResponse.CancellationError + } catch (e: GetPublicKeyCredentialDomException) { + // When the user verification / biometric bottom sheet is cancelled + return GenericCredentialManagerResponse.CancellationError + } catch (e: Exception) { + return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "") + } + return GenericCredentialManagerResponse.GetCredentialSuccess(getCredentialResponse = result) + } + + /** + * Creates a new password credential. + * + * @param username The username for the new credential. + * @param password The password for the new credential. + * @param context The activity context from the Composable, to be used in Credential Manager APIs + */ + suspend fun createPassword( + username: String, + password: String, + context: Context, + ) { + val createPasswordRequest = CreatePasswordRequest( + username, + password, + ) + try { + credentialManager.createCredential(context, createPasswordRequest) as CreatePasswordResponse + } catch (_: Exception) { } + } + + /** + * Creates a new passkey credential. + * + * @param requestResult The result of the passkey creation request. + * @param context The activity context from the Composable, to be used in Credential Manager APIs + * @return The [CreatePublicKeyCredentialResponse] object containing the passkey, or null if an error occurred. + */ + @SuppressLint("PublicKeyCredential") + suspend fun createPasskey( + requestResult: JSONObject, + context: Context, + ): GenericCredentialManagerResponse { + val passkeysEligibility = PasskeysEligibility.isPasskeySupported(context) + if (!passkeysEligibility.isEligible) { + return GenericCredentialManagerResponse.Error(errorMessage = passkeysEligibility.reason) + } + + val credentialRequest = CreatePublicKeyCredentialRequest(requestResult.toString()) + val credentialResponse: CreatePublicKeyCredentialResponse + try { + credentialResponse = credentialManager.createCredential( + context, + credentialRequest as CreateCredentialRequest, + ) as CreatePublicKeyCredentialResponse + } catch (e: CreateCredentialException) { + return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "") + } catch (e: Exception) { + return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "") + } + return GenericCredentialManagerResponse.CreatePasskeySuccess(createPasskeyResponse = credentialResponse) + } + + /** + * Creates a restore key using the Credential Manager API. + * + * @param requestResult A [JSONObject] containing the data required for creating the restore + * credential. This data is used to create a [CreateRestoreCredentialRequest] + * + * @param context The Android Context used for checking passkey eligibility and interacting + * with the Credential Manager API. + * + * @return A [CreateRestoreCredentialResponse] indicating the result of the operation + * + * @throws Exception If any error occurs during the credential creation process. + */ + suspend fun createRestoreKey( + requestResult: JSONObject, + context: Context, + ): GenericCredentialManagerResponse { + val passkeysEligibility = PasskeysEligibility.isPasskeySupported(context) + val credentialResponse: CreateRestoreCredentialResponse + + if (!passkeysEligibility.isEligible) { + return GenericCredentialManagerResponse.Error(errorMessage = passkeysEligibility.reason) + } + + val restoreCredentialRequest = CreateRestoreCredentialRequest(requestResult.toString()) + try { + credentialResponse = credentialManager.createCredential( + context, + restoreCredentialRequest, + ) as CreateRestoreCredentialResponse + } catch (e: Exception) { + return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "") + } + return GenericCredentialManagerResponse.CreatePasskeySuccess(createPasskeyResponse = credentialResponse) + } + + /** + * Retrieves the restore key using the Credential Manager API. + * + * @param authenticationJson The JSON object containing authentication information. + * @param context The application context. + * @return A [GenericCredentialManagerResponse] object indicating success or failure. + */ + suspend fun getRestoreKey( + authenticationJson: JSONObject, + context: Context, + ): GenericCredentialManagerResponse { + val passkeysEligibility = PasskeysEligibility.isPasskeySupported(context) + if (!passkeysEligibility.isEligible) { + return GenericCredentialManagerResponse.Error(errorMessage = passkeysEligibility.reason) + } + + val options = GetRestoreCredentialOption(authenticationJson.toString()) + val getRestoreKeyRequest = GetCredentialRequest(listOf(options)) + val result: GetCredentialResponse? + try { + result = credentialManager.getCredential( + context, + getRestoreKeyRequest, + ) + } catch (e: Exception) { + return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "") + } + return GenericCredentialManagerResponse.GetCredentialSuccess(result) + } + + /** + * Deletes the restore key using the Credential Manager API. + */ + suspend fun deleteRestoreKey() { + val clearRequest = ClearCredentialStateRequest(requestType = TYPE_CLEAR_RESTORE_CREDENTIAL) + credentialManager.clearCredentialState(clearRequest) + } +} + +sealed class GenericCredentialManagerResponse { + data class GetCredentialSuccess( + val getCredentialResponse: GetCredentialResponse, + ) : GenericCredentialManagerResponse() + + data class CreatePasskeySuccess( + val createPasskeyResponse: CreateCredentialResponse, + ) : GenericCredentialManagerResponse() + + data class Error( + val errorMessage: String, + ) : GenericCredentialManagerResponse() + + data object CancellationError : GenericCredentialManagerResponse() +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/MainActivity.kt b/Shrine/app/src/main/java/com/authentication/shrine/MainActivity.kt new file mode 100644 index 00000000..c3fb1fb2 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/MainActivity.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.authentication.shrine.ui.ShrineNavigation +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.SplashViewModel +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * The main activity and the entry point of the Shrine app. + */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject lateinit var credentialManagerUtils: CredentialManagerUtils + + private val splashViewModel: SplashViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + installSplashScreen().setKeepOnScreenCondition { + splashViewModel.uiState.value.isLoading + } + + setContent { + // Setting theme for the App + ShrineTheme { + ShrineNavigation( + startDestination = splashViewModel.uiState.collectAsState().value.nextScreen, + credentialManagerUtils = credentialManagerUtils, + ) + } + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/PasskeysEligibility.kt b/Shrine/app/src/main/java/com/authentication/shrine/PasskeysEligibility.kt new file mode 100644 index 00000000..e881bc59 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/PasskeysEligibility.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine + +import android.app.KeyguardManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability + +/** + * A class that provides information about whether passkeys are supported on the device. + */ +class PasskeysEligibility { + + companion object { + + private const val MIN_PLAY_VERSION = 230815045 + + /** + * Check if passkeys are supported on the device. In order, we verify that: + * 1. The API Version >= P + * 2. Ensure GMS is enabled, to avoid any disabled related errors. + * 3. Google Play Services >= 230815045, which is a version matching one of the first + * stable passkey releases. This check is added to the library here: + * https://developer.android.com/jetpack/androidx/releases/credentials#1.3.0-alpha01 + * 4. The device is secured with some lock. + * + * @param context The application context. + * @return A PasskeysEligibilityData object containing the eligibility status and reason. + * */ + fun isPasskeySupported(context: Context): PasskeysEligibilityData { + // Check if device is running on Android P or higher + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return PasskeysEligibilityData( + false, + context.getString(R.string.lower_than_android_o), + ) + } + + // Check if Google Play Services disabled + if (isGooglePlayServicesDisabled(context)) { + return PasskeysEligibilityData( + false, + context.getString(R.string.play_services_disabled), + ) + } + + // Check if Google Play Services version meets minimum requirement + val playServicesVersion = determineGooglePLayServicesVersion(context) + if (playServicesVersion < MIN_PLAY_VERSION) { + return PasskeysEligibilityData( + false, + context.getString(R.string.google_play_version_low), + ) + } + + // Check if device is secured with a lock screen + val isDeviceSecured = + (context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isDeviceSecure + + if (!isDeviceSecured) { + return PasskeysEligibilityData( + false, + context.getString(R.string.device_not_secure), + ) + } + + // All checks passed, device should support passkeys + return PasskeysEligibilityData(true, context.getString(R.string.empty_string)) + } + + /** + * Recovers the current GMS version code running on the device. This is needed because + * even if a dependency knows the methods and functions of a newer code, the device may + * only contain the older module, which can cause exceptions due to the discrepancy. + * + * @param context The application context. + * @return The GMS version code. + */ + @RequiresApi(Build.VERSION_CODES.P) + private fun determineGooglePLayServicesVersion(context: Context): Long { + val packageManager: PackageManager = context.packageManager + val packageName = GoogleApiAvailability.GOOGLE_PLAY_SERVICES_PACKAGE + return packageManager.getPackageInfo(packageName, 0).longVersionCode + } + + /** + * Determines if Google Play Services is disabled on the device. + * + * @param context The application context. + * @return True if Google Play Services is disabled, false otherwise. + */ + private fun isGooglePlayServicesDisabled(context: Context): Boolean { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val connectionResult = googleApiAvailability.isGooglePlayServicesAvailable(context) + return connectionResult != ConnectionResult.SUCCESS + } + } +} + +/** + * A data class that contains the eligibility status and reason. + */ +data class PasskeysEligibilityData( + val isEligible: Boolean, + val reason: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ShrineApplication.kt b/Shrine/app/src/main/java/com/authentication/shrine/ShrineApplication.kt new file mode 100644 index 00000000..dac86d1e --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ShrineApplication.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.credentials.CredentialManager +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.authentication.shrine.api.AddHeaderInterceptor +import com.authentication.shrine.api.AuthApiService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +/** + * The main application class for the Shrine app. + */ +@HiltAndroidApp +class ShrineApplication : Application() + +/** + * A Dagger Hilt module that provides dependencies for the application. + */ +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + /** + * Creates and provides an OkHttpClient instance with interceptors and timeouts. + * + * @return The OkHttpClient instance. + */ + @Singleton + @Provides + fun provideOkHttpClient(): OkHttpClient { + val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} " + + "(Android ${Build.VERSION.RELEASE}; ${Build.MODEL}; ${Build.BRAND})" + return OkHttpClient.Builder() + .addInterceptor(AddHeaderInterceptor(userAgent)) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }, + ) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(40, TimeUnit.SECONDS) + .connectTimeout(40, TimeUnit.SECONDS) + .build() + } + + /** + * Provides a singleton instance of the CoroutineScope. + * + * @return The CoroutineScope instance. + */ + @Singleton + @Provides + fun provideAppCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob()) + + /** + * Provides a DataStore instance with the file name "auth". + * + * @param application The application context. + * @return The DataStore instance. + */ + @Singleton + @Provides + fun provideDataStore(application: Application): DataStore { + return PreferenceDataStoreFactory.create { + application.preferencesDataStoreFile("auth") + } + } + + @Singleton + @Provides + fun providesCredentialManager(@ApplicationContext context: Context): CredentialManager { + return CredentialManager.create(context) + } + + @Singleton + @Provides + fun providesCredentialManagerUtils( + credentialManager: CredentialManager, + ): CredentialManagerUtils { + return CredentialManagerUtils(credentialManager) + } + + @Singleton + @Provides + fun provideAuthApiService(okHttpClient: OkHttpClient): AuthApiService { + return Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(AuthApiService::class.java) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/api/AddHeaderInterceptor.kt b/Shrine/app/src/main/java/com/authentication/shrine/api/AddHeaderInterceptor.kt new file mode 100644 index 00000000..dc22eb42 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/api/AddHeaderInterceptor.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.api + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * An [Interceptor] that adds the `User-Agent` and `X-Requested-With` headers to every request. + * + * @param userAgent The user agent string to use. + */ +internal class AddHeaderInterceptor(private val userAgent: String) : Interceptor { + + /** + * Intercepts the request and adds the headers. + * + * @param chain The interceptor chain. + * @return The response with the added headers. + */ + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .header("User-Agent", userAgent) + .header("X-Requested-With", "XMLHttpRequest") + .build(), + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/api/ApiException.kt b/Shrine/app/src/main/java/com/authentication/shrine/api/ApiException.kt new file mode 100644 index 00000000..3607aa3c --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/api/ApiException.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.api + +/** + * An exception class for API errors. + * + * @param message The error message. + */ +class ApiException(message: String) : RuntimeException(message) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/api/ApiResult.kt b/Shrine/app/src/main/java/com/authentication/shrine/api/ApiResult.kt new file mode 100644 index 00000000..b3fbb3cc --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/api/ApiResult.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.api + +/** + * Represents the result of an API call. + * + * This sealed class has two subclasses: + * - [Success]: The API call returned successfully with data. + * - [SignedOutFromServer]: The API call returned unsuccessfully with code 401, and the user should be signed out. + */ +sealed class ApiResult { + + /** + * API returned successfully with data. + * + * @param sessionId The session ID to be used for the subsequent API calls. + * Might be null if the API call does not return a new cookie. + * @param data The result data. + */ + class Success( + val sessionId: String?, + val data: T, + ) : ApiResult() + + /** + * API returned unsuccessfully with code 401, and the user should be signed out. + */ + data object SignedOutFromServer : ApiResult() +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/api/AuthApiService.kt b/Shrine/app/src/main/java/com/authentication/shrine/api/AuthApiService.kt new file mode 100644 index 00000000..e2d02dd2 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/api/AuthApiService.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.api + +import com.authentication.shrine.model.EditUsernameRequest +import com.authentication.shrine.model.FederationOptionsRequest +import com.authentication.shrine.model.GenericAuthResponse +import com.authentication.shrine.model.PasskeysList +import com.authentication.shrine.model.PasswordRequest +import com.authentication.shrine.model.RegisterRequestRequestBody +import com.authentication.shrine.model.RegisterRequestResponse +import com.authentication.shrine.model.RegisterResponseRequestBody +import com.authentication.shrine.model.RegisterUsernameRequest +import com.authentication.shrine.model.SignInRequestResponse +import com.authentication.shrine.model.SignInResponseRequest +import com.authentication.shrine.model.SignInWithGoogleRequest +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * Interface defining the API endpoints for authentication and WebAuthn operations. + * This interface is intended to be used with Retrofit for making network requests. + */ +interface AuthApiService { + + /** + * Sets or updates the username for the current session. + * + * @param username The request body containing the new username. + * @return A Retrofit {@link Response} wrapping a {@link GenericAuthResponse}, + * indicating the success or failure of the operation. + */ + @POST("auth/username") + suspend fun setUsername( + @Body username: EditUsernameRequest, + ): Response + + /** + * Sets or updates the password for the authenticated user. + * + * @param cookie The session cookie for authentication. + * @param password The request body containing the new password information. + * @return A Retrofit {@link Response} wrapping a {@link GenericAuthResponse}, + * indicating the success or failure of the operation. + */ + @POST("auth/password") + suspend fun setPassword( + @Header("Cookie") cookie: String, + @Body password: PasswordRequest, + ): Response + + /** + * Initiates a WebAuthn registration ceremony by requesting registration options + * from the server. + * + * @param cookie The session cookie for authentication. + * @param requestBody The request body, potentially containing user information + * or relying party details for the registration request. + * @return A Retrofit {@link Response} wrapping a {@link RegisterRequestResponse}, + * which contains the challenge and options for the WebAuthn registration. + */ + @POST("webauthn/registerRequest") + suspend fun registerRequest( + @Header("Cookie") cookie: String, + @Body requestBody: RegisterRequestRequestBody, + ): Response + + /** + * Sends the client's response to a WebAuthn registration challenge back to the server + * to complete the passkey registration. + * + * @param cookie The session cookie for authentication. + * @param type The type of credential. Only used to specify Restore Credential passkeys to the + * server. + * @param requestBody The request body containing the client's attestation response + * to the registration challenge. + * @return A Retrofit {@link Response} wrapping a {@link GenericAuthResponse}, + * indicating the success or failure of the passkey registration. + */ + @POST("webauthn/registerResponse") + suspend fun registerResponse( + @Header("Cookie") cookie: String, + @Query("type") type: String?, + @Body requestBody: RegisterResponseRequestBody, + ): Response + + /** + * Initiates a WebAuthn sign-in ceremony by requesting assertion options + * (a challenge) from the server. + * + * @return A Retrofit {@link Response} wrapping a {@link SignInRequestResponse}, + * which contains the challenge and options for the WebAuthn sign-in. + */ + @POST("webauthn/signinRequest") + suspend fun signInRequest(): Response + + /** + * Sends the client's response to a WebAuthn sign-in challenge (assertion) back + * to the server to complete the authentication. + * + * @param cookie The session cookie that might have been established or is being verified. + * @param requestBody The request body containing the client's assertion response + * to the sign-in challenge. + * @return A Retrofit {@link Response} wrapping a {@link GenericAuthResponse}, + * indicating the success or failure of the WebAuthn sign-in. + */ + @POST("webauthn/signinResponse") + suspend fun signInResponse( + @Header("Cookie") cookie: String, + @Body requestBody: SignInResponseRequest, + ): Response + + /** + * Retrieves a list of registered passkeys (WebAuthn credentials) for the + * authenticated user. + * + * @param cookie The session cookie for authentication. + * @return A Retrofit {@link Response} wrapping a {@link PasskeysList}, + * containing the list of the user's passkeys. + */ + @POST("webauthn/getKeys") + suspend fun getKeys( + @Header("Cookie") cookie: String, + ): Response + + /** + * Registers a username with the authentication server. + * + * @param username The request body containing the username and display name. + * @return A [Response] indicating the success or failure of the operation. + */ + @POST("auth/new-user") + suspend fun registerUsername( + @Body username: RegisterUsernameRequest, + ): Response + + /** + * Deletes a passkey from the authentication server. + */ + @POST("webauthn/removeKey") + suspend fun deletePasskey( + @Header("Cookie") cookie: String, + @Query("credId") credentialId: String, + ): Response + + /** + * Send a request to the server with urls parameter that contains a list of IdPs in an array. + * e.g. urls=["https://accounts.google.com"]. The response will contain a nonce and IdP details. + * @param urls a list of urls to send for federated requests. + */ + @POST("federation/options") + suspend fun getFederationOptions( + @Body urls: FederationOptionsRequest + ): Response + + /** + * Verifies a session ID token and Credential Manager credentials with the backend server. + * @param cookie The session cookie for authentication containing the session ID. + * @param requestParams The parameters to send to the server, including Credential Manager + * credentials. + */ + @POST("federation/verifyIdToken") + suspend fun verifyIdToken( + @Header("Cookie") cookie: String, + @Body requestParams: SignInWithGoogleRequest, + ): Response +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/data/FakeDatasource.kt b/Shrine/app/src/main/java/com/authentication/shrine/data/FakeDatasource.kt new file mode 100644 index 00000000..020e8730 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/data/FakeDatasource.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.data + +import com.authentication.shrine.R +import com.authentication.shrine.model.Product + +/** + * A fake data source that provides a list of products. + */ +class FakeDatasource { + + companion object { + + /** + * Loads a list of products. + * + * @return A list of [Product] objects. + */ + fun loadProducts(): List { + return listOf( + Product(R.string.lamp, R.drawable.lamp), + Product(R.string.dishes, R.drawable.dishes), + Product(R.string.bag, R.drawable.bag), + Product(R.string.jacket, R.drawable.jacket), + ) + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/AuthResult.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/AuthResult.kt new file mode 100644 index 00000000..faed81e1 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/AuthResult.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +sealed class AuthResult { + data class Success(val data: T) : AuthResult() + data class Failure(val error: AuthError) : AuthResult() +} + +sealed class AuthError { + object NetworkError : AuthError() + object UserAlreadyExists : AuthError() + object InvalidCredentials : AuthError() + data class ServerError(val message: String?) : AuthError() + data class Unknown(val message: String?) : AuthError() +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/FederationOptionsRequest.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/FederationOptionsRequest.kt new file mode 100644 index 00000000..16f2d6a3 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/FederationOptionsRequest.kt @@ -0,0 +1,9 @@ +package com.authentication.shrine.model + +/** + * Represents the request body for getting federation options from the server. + * @param urls a list of urls to send for federated requests. + */ +data class FederationOptionsRequest( + val urls: List = listOf("https://accounts.google.com") +) \ No newline at end of file diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/GenericAuthResponse.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/GenericAuthResponse.kt new file mode 100644 index 00000000..ebffeaed --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/GenericAuthResponse.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Represents a generic authentication response from the server. + * This data class is typically used as a common response structure for various + * authentication-related operations, such as setting a username, password, + * or completing certain WebAuthn steps. + * + * It usually contains basic information about the authenticated user or the + * outcome of an authentication operation. + * + * @property id The unique identifier for the user or session, often a UUID or a server-generated ID. + * @property username The username associated with the authenticated account. + * @property displayName The display name for the user, which might be the same as the username + * or a more user-friendly name. + */ +data class GenericAuthResponse( + val id: String, + val username: String, + val displayName: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/LoginRequest.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/LoginRequest.kt new file mode 100644 index 00000000..e62da84f --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/LoginRequest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Represents the request body for setting or updating an existing username. + * This data class is typically used for serialization (e.g., with Gson or Kotlinx Serialization) + * when making API calls related to username management. + * + * @property username The username to be set or updated. + */ +data class EditUsernameRequest( + val username: String, +) + +/** + * Represents the request body for registering a new username. + * This data class is typically used for serialization (e.g., with Gson or Kotlinx Serialization) + * when making API calls related to new user registration. + * + * @property username The username to be registered. + * @property displayName The display name for the new user. + */ +data class RegisterUsernameRequest( + val username: String, + val displayName: String, +) + +/** + * Represents the request body for setting or updating a password. + * This data class is typically used for serialization (e.g., with Gson or Kotlinx Serialization) + * when making API calls related to password management. + * + * @property password The new password. + */ +data class PasswordRequest( + val password: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/PasskeysList.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/PasskeysList.kt new file mode 100644 index 00000000..a2656cf0 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/PasskeysList.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Data class for fetching list of passkeys from the /getKeys endpoint + * + * @param rpId Relying Party ID + * @param userId User ID + * @param credentials List of credentials data + * */ +data class PasskeysList( + val rpId: String, + val userId: String, + val credentials: List, +) + +/** + * Data class for holding credential data + * + * @param id Credential ID + * @param passkeyUserId UserId corresponding to the passkey + * @param name Name of the credential Provider + * @param credentialType Type of the credential + * @param aaguid AAGUID corresponding to the passkey + * @param registeredAt Time of creation of the passkey + * @param providerIcon Icon for the credential provider + * */ +data class PasskeyCredential( + val id: String, + val passkeyUserId: String, + val name: String, + val credentialType: String, + val aaguid: String, + val registeredAt: Long, + val providerIcon: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/Product.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/Product.kt new file mode 100644 index 00000000..1250bc7d --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/Product.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +/** + * A data class representing a product. + * + * @param stringResourceId The resource ID of the product name string. + * @param imageResourceId The resource ID of the product image drawable. + */ +data class Product( + @StringRes val stringResourceId: Int, + @DrawableRes val imageResourceId: Int, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/RegisterRequest.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/RegisterRequest.kt new file mode 100644 index 00000000..bd3ba79a --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/RegisterRequest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Represents the request body for initiating a WebAuthn registration ceremony. + * This data is sent to the server to request options for creating a new passkey. + * + * @property attestation The desired attestation conveyance preference. Defaults to "none". + * Common values include "none", "indirect", "direct". + * @property authenticatorSelection Specifies requirements for the authenticator to be used. + * Defaults to a standard {@link AuthenticatorSelection} configuration. + * @see AuthenticatorSelection + */ +data class RegisterRequestRequestBody( + val attestation: String = "none", + val authenticatorSelection: AuthenticatorSelection = AuthenticatorSelection(), +) + +/** + * Represents the server's response when requesting WebAuthn registration options. + * This data contains the parameters needed by the client (e.g., a browser or app) + * to call `navigator.credentials.create()`. + * + * @property authenticatorSelection Specifies authenticator selection criteria required by the server. + * @property challenge A server-generated cryptographic challenge that must be signed by the authenticator. + * @property excludeCredentials A list of credentials that should not be created again for this user, + * used to prevent duplicate registrations. + * @property pubKeyCredParams A list of public key credential types and algorithms supported by the Relying Party (server). + * @property rp Information about the Relying Party (the server or service). + * @property timeout The time, in milliseconds, that the client has to complete the registration ceremony. + * @property user Information about the user for whom the credential is being registered. + * @see AuthenticatorSelection + * @see CredentialDetail + * @see PubKeyCredParam + * @see Rp + * @see User + */ +data class RegisterRequestResponse( + val authenticatorSelection: AuthenticatorSelection, + val challenge: String, + val excludeCredentials: List, + val pubKeyCredParams: List, + val rp: Rp, + val timeout: Int, + val user: User, +) + +/** + * Specifies requirements for the authenticator during a WebAuthn ceremony (registration or authentication). + * + * @property authenticatorAttachment Specifies the desired authenticator attachment modality. + * Defaults to "platform" (e.g., built-in sensor like Touch ID or Windows Hello). + * Other common value is "cross-platform" (e.g., a USB security key). + * @property userVerification Specifies the Relying Party's user verification requirement. + * Defaults to "required" (e.g., user must verify with PIN, biometric). + * Other values: "preferred", "discouraged". + * @property requireResidentKey Indicates if the authenticator should create a client-side discoverable credential (resident key). + * Defaults to true. + * @property residentKey An alternative way to specify the resident key requirement, often aligned with `requireResidentKey`. + * Defaults to "required". + */ +data class AuthenticatorSelection( + val authenticatorAttachment: String = "platform", + val userVerification: String = "required", + val requireResidentKey: Boolean = true, + val residentKey: String = "required", +) + +/** + * Describes a public key credential type and the cryptographic algorithm it uses. + * Part of the `pubKeyCredParams` in {@link RegisterRequestResponse}. + * + * @property alg The COSE algorithm identifier for the public key. (e.g., -7 for ES256, -257 for RS256). + * @property type The type of the public key credential. Typically "public-key". + */ +data class PubKeyCredParam( + val alg: Int, + val type: String, +) + +/** + * Represents the Relying Party (RP) in a WebAuthn ceremony. + * The RP is the server or service that the user is trying to register with or authenticate to. + * + * @property id The effective domain of the Relying Party. This MUST be a valid domain string. + * @property name A human-readable name for the Relying Party. + */ +data class Rp( + val id: String, + val name: String, +) + +/** + * Represents the user for whom a WebAuthn credential is being registered or asserted. + * + * @property displayName A human-readable name for the user account, chosen by the user. + * @property id A unique, server-chosen identifier for the user account (e.g., a UUID or username). + * This should not be personally identifiable information if possible. + * @property name A human-readable name for the user account, which may be the same as `displayName` or a username. + * The distinction can vary based on RP implementation. + */ +data class User( + val displayName: String, + val id: String, + val name: String, +) + +/** + * Describes an existing WebAuthn credential, often used in `excludeCredentials` + * to prevent re-registration of an already existing passkey. + * + * @property id The base64url-encoded credential ID of an existing public key credential. + * @property type The type of the credential. Typically "public-key". + */ +data class CredentialDetail( + val id: String, + val type: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/RegisterResponse.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/RegisterResponse.kt new file mode 100644 index 00000000..1dd746f3 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/RegisterResponse.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Represents the request body for completing a WebAuthn registration ceremony. + * This data is sent to the server after the client (e.g., browser or app) + * has successfully created a new passkey using `navigator.credentials.create()`. + * + * It contains the public key credential and related attestation data. + * + * @property id The base64url-encoded credential ID of the newly created passkey. + * This is the identifier for the credential. + * @property type The type of the credential. Typically "public-key". + * @property rawId The raw ID of the newly created passkey, in base64url encoding. + * This is the byte array version of the `id`. + * @property response The client data and attestation object, which are part of the + * `PublicKeyCredential` returned by the WebAuthn API. + * @see CredmanResponse + */ +data class RegisterResponseRequestBody( + val id: String, + val type: String, + val rawId: String, + val response: CredmanResponse, +) + +/** + * Represents the `response` part of a `PublicKeyCredential` obtained during a + * WebAuthn registration ceremony. It contains the client data and the attestation object. + * + * This structure is typically nested within {@link RegisterResponseRequestBody}. + * + * @property clientDataJSON A JSON string containing client data about the registration ceremony, + * such as the challenge, origin, and type of operation. + * This data is signed by the authenticator. + * @property attestationObject A base64url-encoded CBOR object containing the attestation statement. + * This object provides information about the authenticator and the + * newly created public key credential, allowing the Relying Party + * to verify the authenticity and properties of the credential. + */ +data class CredmanResponse( + val clientDataJSON: String, + val attestationObject: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/SignInRequestResponse.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/SignInRequestResponse.kt new file mode 100644 index 00000000..8553b5b6 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/SignInRequestResponse.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Represents the server's response to a WebAuthn sign-in request. + * This data contains the parameters needed by the client (e.g., a browser or app) + * to call `navigator.credentials.get()`. It specifies the challenge, allowed credentials, + * timeout, and user verification requirements for the sign-in ceremony. + * + * @property challenge A server-generated cryptographic challenge that must be signed by the authenticator. + * This prevents replay attacks. + * @property allowCredentials A list of credentials that are allowed to be used for sign-in. + * Each {@link CredentialDetail} typically contains the `id` and `type` + * of a credential previously registered by the user. If empty, the client + * may allow the user to choose from any available passkey for the `rpId`. + * @property timeout The time, in milliseconds, that the client has to complete the sign-in ceremony. + * @property userVerification Specifies the Relying Party's user verification requirement for this + * authentication ceremony (e.g., "required", "preferred", "discouraged"). + * @property rpId The effective domain of the Relying Party (the server or service) for which + * the assertion is being requested. This should match the `rpId` used during registration. + * @see CredentialDetail + */ +data class SignInRequestResponse( + val challenge: String, + val allowCredentials: List, + val timeout: Long, + val userVerification: String, + val rpId: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/SignInResponse.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/SignInResponse.kt new file mode 100644 index 00000000..6048174a --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/SignInResponse.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.model + +/** + * Represents the request body for completing a WebAuthn sign-in ceremony. + * This data is sent to the server after the client (e.g., browser or app) + * has successfully retrieved a passkey using `navigator.credentials.get()`. It contains the + * assertion and related data. + * + * @property id The base64url-encoded credential ID of the passkey used for sign-in. + * @property type The type of the credential. Typically "public-key". + * @property rawId The raw ID of the passkey used for sign-in, in base64url encoding. + * This is the byte array version of the `id`. + * @property response The client data, authenticator data, signature, and user handle, which are + * part of the `PublicKeyCredential` returned by the WebAuthn API. + * @see ResponseObject + */ +data class SignInResponseRequest( + val id: String, + val type: String, + val rawId: String, + val response: ResponseObject, +) + +/** + * Represents the `response` part of a `PublicKeyCredential` obtained during a + * WebAuthn sign-in ceremony. It contains the client data, authenticator data, signature, and user handle. + * + * This structure is typically nested within {@link SignInResponseRequest}. + * + * @property clientDataJSON A JSON string containing client data about the sign-in ceremony, such as + * the challenge, origin, and type of operation. + * This data is signed by the authenticator. + * @property authenticatorData A base64url-encoded CBOR object containing the authenticator data. + * This object provides information about the authenticator and the + * assertion, allowing the Relying Party to verify the authenticity + * and properties of the assertion. + * @property signature A base64url-encoded signature over the `clientDataJSON` and `authenticatorData` + * using the private key of the passkey. + * @property userHandle An optional base64url-encoded user handle, which can be used to identify + * the user within the Relying Party's system. This may or may not be present + * depending on whether a resident key was used and how the server requested it. + */ +data class ResponseObject( + val clientDataJSON: String, + val authenticatorData: String, + val signature: String, + val userHandle: String, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/model/SignInWithGoogleRequest.kt b/Shrine/app/src/main/java/com/authentication/shrine/model/SignInWithGoogleRequest.kt new file mode 100644 index 00000000..9ae7d1f6 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/model/SignInWithGoogleRequest.kt @@ -0,0 +1,11 @@ +package com.authentication.shrine.model + +/** + * Represents the request body for signing in with Google. + * @param token the auth token retrieved from Credential Manager. + * @param url a fixed url to send for Sign in with Google requests. + */ +data class SignInWithGoogleRequest( + val token: String, + val url: String = "https://accounts.google.com" +) \ No newline at end of file diff --git a/Shrine/app/src/main/java/com/authentication/shrine/repository/AuthRepository.kt b/Shrine/app/src/main/java/com/authentication/shrine/repository/AuthRepository.kt new file mode 100644 index 00000000..4bc33076 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/repository/AuthRepository.kt @@ -0,0 +1,704 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.repository + +import android.util.Log +import androidx.credentials.CreateCredentialResponse +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CreateRestoreCredentialResponse +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import androidx.credentials.RestoreCredential +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.authentication.shrine.api.ApiException +import com.authentication.shrine.api.AuthApiService +import com.authentication.shrine.model.AuthError +import com.authentication.shrine.model.AuthResult +import com.authentication.shrine.model.CredmanResponse +import com.authentication.shrine.model.EditUsernameRequest +import com.authentication.shrine.model.FederationOptionsRequest +import com.authentication.shrine.model.PasskeysList +import com.authentication.shrine.model.PasswordRequest +import com.authentication.shrine.model.RegisterRequestRequestBody +import com.authentication.shrine.model.RegisterResponseRequestBody +import com.authentication.shrine.model.RegisterUsernameRequest +import com.authentication.shrine.model.ResponseObject +import com.authentication.shrine.model.SignInResponseRequest +import com.authentication.shrine.model.SignInWithGoogleRequest +import com.authentication.shrine.utility.createCookieHeader +import com.authentication.shrine.utility.getJsonObject +import com.authentication.shrine.utility.getSessionId +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import okhttp3.ResponseBody +import org.json.JSONObject +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +const val SERVER_CLIENT_ID = + "493201854729-bposa1duevdn4nspp28cmn6anucu60pf.apps.googleusercontent.com" + +/** + * Repository class that handles authentication-related operations. + * + * @param authApiService The API service for interacting with the server. + * @param dataStore The data store for storing user data. + */ +@Singleton +class AuthRepository @Inject constructor( + private val dataStore: DataStore, + private val authApiService: AuthApiService, +) { + + // Companion object for constants and helper methods + companion object { + const val TAG = "AuthRepository" + + // Keys for SharedPreferences + val USERNAME = stringPreferencesKey("username") + val DISPLAYNAME = stringPreferencesKey("displayname") + val IS_SIGNED_IN_THROUGH_PASSKEYS = booleanPreferencesKey("is_signed_passkeys") + val SESSION_ID = stringPreferencesKey("session_id") + val RESTORE_KEY_CREDENTIAL_ID = stringPreferencesKey("restore_key_credential_id") + + // Value for restore credential AuthApiService parameter + const val RESTORE_KEY_TYPE_PARAMETER = "rc" + const val RESTORE_CREDENTIAL_AAGUID = "restore-credential" + + suspend fun DataStore.read(key: Preferences.Key): T? { + return data.map { it[key] }.first() + } + } + + /** + * Registers the username with the server. + * + * @param username The username to send. + * @param displayName The display name to send. + * @return True if the login was successful, false otherwise. + */ + suspend fun registerUsername(username: String, displayName: String): AuthResult { + return try { + val response = + authApiService.registerUsername(RegisterUsernameRequest(username, displayName)) + if (response.isSuccessful) { + dataStore.edit { prefs -> + // Use local values since server doesn't return response with these fields. + prefs[USERNAME] = username + prefs[DISPLAYNAME] = displayName + response.getSessionId()?.also { + prefs[SESSION_ID] = it + } + } + AuthResult.Success(Unit) + } else { + if (response.code() == 401) { + signOut() + } + AuthResult.Failure( + AuthError.ServerError( + response.errorBody()?.let { parseResponseError(it) } ?: response.message())) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + /** + * Sends the username to the server. + * + * @param username The username to send. + * @param password The password to send. + * @return True if the login was successful, false otherwise. + */ + suspend fun login(username: String, password: String): AuthResult { + return try { + val response = authApiService.setUsername(EditUsernameRequest(username = username)) + if (response.isSuccessful) { + dataStore.edit { prefs -> + prefs[USERNAME] = response.body()?.username.orEmpty() + prefs[DISPLAYNAME] = response.body()?.displayName.orEmpty() + response.getSessionId()?.also { + prefs[SESSION_ID] = it + } + } + setSessionWithPassword(password) + AuthResult.Success(Unit) + } else { + if (response.code() == 401) { + signOut() + } + AuthResult.Failure( + AuthError.ServerError( + response.errorBody()?.let { parseResponseError(it) } ?: response.message())) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + + /** + * Signs in with a password. + * + * @param password The password to use. + * @return True if the sign-in was successful, false otherwise. + */ + private suspend fun setSessionWithPassword(password: String): Boolean { + val username = dataStore.read(USERNAME) + val sessionId = dataStore.read(SESSION_ID) + if (!username.isNullOrEmpty() && !sessionId.isNullOrEmpty()) { + try { + val response = authApiService.setPassword( + cookie = sessionId.createCookieHeader(), + password = PasswordRequest(password = password), + ) + if (response.isSuccessful) { + dataStore.edit { prefs -> + prefs[USERNAME] = response.body()?.username.orEmpty() + prefs[DISPLAYNAME] = response.body()?.displayName.orEmpty() + response.getSessionId()?.also { + prefs[SESSION_ID] = it + } + } + return true + } else if (response.code() == 401) { + signOut() + } + } catch (e: ApiException) { + Log.e(TAG, "Invalid login credentials", e) + + // Remove previously stored credentials and start login over again + signOut() + } + } else { + Log.e(TAG, "Please check if username and session id is present in your datastore") + } + return false + } + + /** + * Clears all the sign-in information. + */ + suspend fun signOut() { + dataStore.edit { prefs -> + prefs.remove(USERNAME) + prefs.remove(DISPLAYNAME) + prefs.remove(SESSION_ID) + prefs.remove(IS_SIGNED_IN_THROUGH_PASSKEYS) + prefs.remove(RESTORE_KEY_CREDENTIAL_ID) + } + } + + /** + * Starts to register a passkey creation request to the server. + * + * @return The public key credential request options, or null if there was an error. + */ + suspend fun registerPasskeyCreationRequest(): AuthResult { + return try { + val sessionId = dataStore.read(SESSION_ID) + if (!sessionId.isNullOrEmpty()) { + val response = authApiService.registerRequest( + cookie = sessionId.createCookieHeader(), + requestBody = RegisterRequestRequestBody(), + ) + if (response.isSuccessful) { + dataStore.edit { prefs -> + response.getSessionId()?.also { + prefs[SESSION_ID] = it + } + } + val responseObject = response.getJsonObject() + AuthResult.Success(responseObject) + } else { + if (response.code() == 401) { + signOut() + } + AuthResult.Failure( + AuthError.ServerError( + response.errorBody()?.let { parseResponseError(it) } + ?: response.message())) + } + } else { + AuthResult.Failure(AuthError.Unknown(null)) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + /** + * Finishes registering a new credential to the server. This should only be called after + * a call to [registerPasskeyCreationRequest] and a local API for public key generation. + * + * @param credentialResponse The credential response. + * @return True if the registration was successful, false otherwise. + */ + suspend fun registerPasskeyCreationResponse( + credentialResponse: CreateCredentialResponse, + ): AuthResult { + return try { + // Field to pass as query parameter to authApiService. + val typeParam: String? + val registrationResponseJson = when (credentialResponse) { + is CreatePublicKeyCredentialResponse -> { + typeParam = null + JSONObject(credentialResponse.registrationResponseJson) + } + + is CreateRestoreCredentialResponse -> { + typeParam = RESTORE_KEY_TYPE_PARAMETER + JSONObject(credentialResponse.responseJson) + } + + else -> { + return AuthResult.Failure(AuthError.Unknown("Unknown credential type")) + } + } + + val rawId = registrationResponseJson.getString("rawId") + val response = registrationResponseJson.getJSONObject("response") + val sessionId = dataStore.read(SESSION_ID) + if (!sessionId.isNullOrBlank()) { + val apiResult = authApiService.registerResponse( + cookie = sessionId.createCookieHeader(), + type = typeParam, + requestBody = RegisterResponseRequestBody( + id = rawId, + type = PublicKeyCredentialType.PUBLIC_KEY.toString(), + rawId = rawId, + response = CredmanResponse( + clientDataJSON = response.getString("clientDataJSON"), + attestationObject = response.getString("attestationObject"), + ), + ), + ) + if (apiResult.isSuccessful) { + dataStore.edit { prefs -> + if (credentialResponse is CreateRestoreCredentialResponse) { + prefs[RESTORE_KEY_CREDENTIAL_ID] = rawId + } + apiResult.getSessionId()?.also { + prefs[SESSION_ID] = it + } + } + AuthResult.Success(Unit) + } else { + if (apiResult.code() == 401) { + signOut() + } + AuthResult.Failure(AuthError.ServerError(parseResponseError(apiResult.errorBody()!!))) + } + } else { + AuthResult.Failure(AuthError.Unknown(null)) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + /** + * Starts to sign in with a credential. + * + * @return The public key credential request options, or null if there was an error. + */ + suspend fun signInWithPasskeyOrPasswordRequest(): AuthResult { + return try { + val response = authApiService.signInRequest() + if (response.isSuccessful) { + dataStore.edit { prefs -> + response.getSessionId()?.also { + prefs[SESSION_ID] = it + } + } + val responseObject = response.getJsonObject() + AuthResult.Success(responseObject) + } else { + if (response.code() == 401) { + signOut() + } + AuthResult.Failure( + AuthError.ServerError( + response.errorBody()?.let { parseResponseError(it) } ?: response.message())) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + /** + * Finishes to signing in with a credential. This should only be called after a call to + * [signInWithPasskeyRequest] and a local API for key assertion. + * + * @param credentialResponse The credential response. + * @return True if the sign-in was successful, false otherwise. + */ + suspend fun signInWithPasskeyOrPasswordResponse(credentialResponse: GetCredentialResponse): AuthResult { + return try { + val credential = credentialResponse.credential + if (credential is PublicKeyCredential) { + val signInResponse = + credential.data.getString( + if (credential.type == RestoreCredential.TYPE_RESTORE_CREDENTIAL) { + "androidx.credentials.BUNDLE_KEY_GET_RESTORE_CREDENTIAL_RESPONSE" + } else { + "androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON" + }, + ) + if (signInResponse != null) { + val signInResponseJSON = JSONObject(signInResponse) + val response = signInResponseJSON.getJSONObject("response") + val sessionId = dataStore.read(SESSION_ID) + val credentialId = signInResponseJSON.getString("rawId") + + if (!sessionId.isNullOrBlank()) { + val apiResult = authApiService.signInResponse( + cookie = sessionId.createCookieHeader(), + requestBody = SignInResponseRequest( + id = credentialId, + type = PublicKeyCredentialType.PUBLIC_KEY.toString(), + rawId = credentialId, + response = ResponseObject( + clientDataJSON = response.getString("clientDataJSON"), + authenticatorData = response.getString("authenticatorData"), + signature = response.getString("signature"), + userHandle = response.getString("userHandle"), + ), + ), + ) + return if (apiResult.isSuccessful) { + dataStore.edit { prefs -> + apiResult.getSessionId()?.also { + prefs[SESSION_ID] = it + } + prefs[USERNAME] = apiResult.body()?.username ?: "" + prefs[DISPLAYNAME] = apiResult.body()?.displayName.orEmpty() + } + AuthResult.Success(Unit) + } else { + if (apiResult.code() == 401) { + signOut() + } + AuthResult.Failure( + AuthError.ServerError( + parseResponseError(apiResult.errorBody()!!) + ) + ) + } + } + } + } else if (credential is PasswordCredential) { + val email = + credential.data.getString("androidx.credentials.BUNDLE_KEY_ID") + val password = + credential.data.getString("androidx.credentials.BUNDLE_KEY_PASSWORD") + if (email != null && password != null) { + return login(email, password) + } + } + AuthResult.Failure(AuthError.Unknown(null)) + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + + /** + * Sends the session ID to the server to sign in the user. + * @param sessionId The session ID retrieved from the server via federation options request. + * @param credentialResponse The credential retrieved from Credential Manager. + */ + suspend fun signInWithFederatedTokenResponse( + sessionId: String, + credentialResponse: GetCredentialResponse + ): AuthResult { + return try { + val credential = credentialResponse.credential + if (credential is CustomCredential) { + val isSuccess = verifyIdToken( + sessionId, + GoogleIdTokenCredential + .createFrom(credential.data).idToken + ) + if (isSuccess) { + AuthResult.Success(Unit) + } else { + AuthResult.Failure(AuthError.InvalidCredentials) + } + } else { + Log.e(TAG, "Invalid federated token credential") + AuthResult.Failure(AuthError.Unknown("Invalid federated token credential")) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + /** + * Checks if the user is signed in. + * + * @return True if the user is signed in, false otherwise. + */ + suspend fun isSignedInThroughPassword(): Boolean { + val sessionId = dataStore.read(SESSION_ID) + return when { + sessionId.isNullOrBlank() -> false + else -> true + } + } + + /** + * Checks if the user is signed in through passkeys. + * + * @return True if the user is signed in through passkeys, false otherwise. + */ + suspend fun isSignedInThroughPasskeys(): Boolean { + val isSignedInThroughPasskeys = dataStore.read(IS_SIGNED_IN_THROUGH_PASSKEYS) + isSignedInThroughPasskeys?.let { + return it + } + return false + } + + /** + * Checks if session id is valid with server. Currently makes a getKeys() request + * + * @return True if the session id is valid, false otherwise. + */ + suspend fun isSessionIdValid(): AuthResult { + return try { + val sessionId = dataStore.read(SESSION_ID) + if (!sessionId.isNullOrBlank()) { + val apiResult = authApiService.getKeys( + cookie = sessionId.createCookieHeader(), + ) + if (apiResult.isSuccessful) { + AuthResult.Success(Unit) + } else { + AuthResult.Failure(AuthError.InvalidCredentials) + } + } else { + AuthResult.Failure(AuthError.Unknown(null)) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + /** + * Sets the sign-in state. + * + * @param flag True if the user is signed in through passkeys, false otherwise. + */ + suspend fun setSignedInState(flag: Boolean) { + dataStore.edit { prefs -> + prefs[IS_SIGNED_IN_THROUGH_PASSKEYS] = flag + } + } + + /** + * Clears the stored session ID from the data store asynchronously. + * + * This is a suspend function that edits the data store to remove the session ID. + */ + suspend fun clearSessionIdFromDataStore() { + dataStore.edit { prefs -> + prefs.remove(SESSION_ID) + } + } + + /** + * Retrieves the stored username asynchronously. + * + * This is a suspend function that reads the username from the data store. + * + * @return The stored username as a [String]. Returns an empty string if no username is found. + */ + suspend fun getUsername(): String { + return dataStore.read(USERNAME).orEmpty() + } + + /** + * Retrieves the stored displayname asynchronously. + * + * This is a suspend function that reads the displayname from the data store. + * + * @return The stored displayname as a [String]. Returns an empty string if no displayname is + * found. + */ + suspend fun getDisplayname(): String { + return dataStore.read(DISPLAYNAME).orEmpty() + } + + /** + * Retrieves a list of Passkeys from the Backend + * + * @return [PasskeysList] Object holding a list of Passkey details + * */ + suspend fun getListOfPasskeys(): PasskeysList? { + val sessionId = dataStore.read(SESSION_ID) + if (!sessionId.isNullOrBlank()) { + val apiResult = authApiService.getKeys( + cookie = sessionId.createCookieHeader(), + ) + if (apiResult.isSuccessful) { + return apiResult.body() + } else if (apiResult.code() == 401) { + signOut() + return null + } + } + signOut() + return null + } + + /** + * Deletes a passkey from the Backend + * @param credentialId The ID of the credential to be deleted + * @return True if the deletion was successful, false otherwise + */ + suspend fun deletePasskey(credentialId: String): AuthResult { + val sessionId = dataStore.read(SESSION_ID) + // Construct endpoint for deleting passkeys. + return try { + if (!sessionId.isNullOrEmpty()) { + val response = authApiService.deletePasskey( + cookie = sessionId.createCookieHeader(), + credentialId = credentialId, + ) + if (response.isSuccessful) { + AuthResult.Success(Unit) + } else { + if (response.code() == 401) { + signOut() + } + AuthResult.Failure( + AuthError.ServerError( + response.errorBody()?.let { parseResponseError(it) } + ?: response.message())) + } + } else { + AuthResult.Failure(AuthError.Unknown(null)) + } + } catch (e: IOException) { + AuthResult.Failure(AuthError.NetworkError) + } catch (e: Exception) { + AuthResult.Failure(AuthError.Unknown(e.message)) + } + } + + suspend fun deleteRestoreKeyFromServer(): Boolean { + val sessionId = dataStore.read(SESSION_ID) + val credentialId = dataStore.read(RESTORE_KEY_CREDENTIAL_ID) + // Construct endpoint for deleting passkeys. + try { + if (!sessionId.isNullOrEmpty() && !credentialId.isNullOrEmpty()) { + val response = authApiService.deletePasskey( + cookie = sessionId.createCookieHeader(), + credentialId = credentialId, + ) + if (response.isSuccessful) { + return true + } else if (response.code() == 401) { + signOut() + } + } + } catch (e: Exception) { + Log.e(TAG, "Cannot call deleteRestoreKey", e) + } + return false + } + + /** + * Send a request to the server with urls parameter that contains a list of IdPs in an array. + * e.g. url=["https://accounts.google.com"]. The response will contain a client ID to be used + * for subsequent server verification to complete login. Note this sequence may vary depending + * on your server implementation. + * @return The client ID stored as the session ID as a [String]. + */ + suspend fun getFederationOptions(): String? { + val apiResult = authApiService.getFederationOptions(FederationOptionsRequest()) + if (apiResult.isSuccessful) { + return apiResult.getSessionId() + } + + return null + } + + /** + * Verifies the ID token with the server to complete sign in. + * @param sessionId The ID token retrieved from the server via federation options request. This + * is treated as a session ID for this server implementation. + * @param token The ID token to be authorized. + */ + suspend fun verifyIdToken(sessionId: String, token: String): Boolean { + val apiResult = authApiService.verifyIdToken( + cookie = sessionId.createCookieHeader(), + requestParams = SignInWithGoogleRequest(token = token) + ) + + if (apiResult.isSuccessful) { + apiResult.getSessionId()?.let { newSessionId -> + dataStore.edit { prefs -> + prefs[SESSION_ID] = newSessionId + } + return true + } + } + + return false + } + + /** + * Parses an okhttp Response error message into a string. + */ + fun parseResponseError(errorBody: ResponseBody): String { + return try { + val errorString = errorBody.string() + val jsonObject = JSONObject(errorString) + jsonObject.getString("error") + } catch (e: Exception) { + Log.e(TAG, "Error parsing response error body", e) + "A server error occurred." + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/AuthenticationScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/AuthenticationScreen.kt new file mode 100644 index 00000000..e8bc6412 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/AuthenticationScreen.kt @@ -0,0 +1,233 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.LogoHeading +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineLoader +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.AuthenticationUiState +import com.authentication.shrine.ui.viewmodel.AuthenticationViewModel + +/** + * Stateful composable function for Authentication screen. + * + * This screen allows the user to authenticate using a username and password, or through passkeys. + * + * @param navigateToHome Callback to navigate to the home screen. + * @param viewModel The [AuthenticationViewModel] for this screen. + * @param navigateToRegister Callback to navigate to the registration screen. + * @param credentialManagerUtils The instance of [CredentialManagerUtils] + */ +@Composable +fun AuthenticationScreen( + navigateToHome: (isSignInThroughPasskeys: Boolean) -> Unit, + viewModel: AuthenticationViewModel, + navigateToRegister: () -> Unit, + modifier: Modifier = Modifier, + credentialManagerUtils: CredentialManagerUtils, +) { + val uiState = viewModel.uiState.collectAsState().value + + // Passing in the lambda / context to the VM + val context = LocalContext.current + val createRestoreKey = { + viewModel.createRestoreKey( + createRestoreKeyOnCredMan = { createRestoreCredObject -> + credentialManagerUtils.createRestoreKey( + context = context, + requestResult = createRestoreCredObject, + ) + }, + ) + } + + val onSignInWithPasskeyOrPasswordRequest = { + viewModel.signInWithPasskeyOrPasswordRequest( + onSuccess = { flag -> + createRestoreKey() + navigateToHome(flag) + }, + getCredential = { jsonObject -> + credentialManagerUtils.getPasskeyOrPasswordCredential( + context = context, + publicKeyCredentialRequestOptions = jsonObject, + ) + }, + ) + } + + val onSignInWithSignInWithGoogleRequest = { + viewModel.signInWithGoogleRequest( + onSuccess = { + createRestoreKey() + // Don't suggest passkeys if user signs in with Google. + navigateToHome(false) + }, + getCredential = { + credentialManagerUtils.getSignInWithGoogleCredential( + context = context, + ) + }, + ) + } + + AuthenticationScreen( + onSignInWithPasskeyOrPasswordRequest = onSignInWithPasskeyOrPasswordRequest, + onSignInWithSignInWithGoogleRequest = onSignInWithSignInWithGoogleRequest, + navigateToRegister = navigateToRegister, + uiState = uiState, + modifier = modifier, + ) +} + +/** + * Stateless composable function for the authentication screen. + * + * This screen allows the user to authenticate using a username and password, or through passkeys. + * + * @param modifier Modifier to be applied to the composable. + * @param onSignInWithPasskeyOrPasswordRequest Callback to initiate the sign-in with passkeys or password request. + * @param navigateToRegister Callback to navigate to the registration screen. + * @param uiState The current UI state of the authentication screen. +*/ +@Composable +fun AuthenticationScreen( + onSignInWithPasskeyOrPasswordRequest: () -> Unit, + onSignInWithSignInWithGoogleRequest: () -> Unit, + navigateToRegister: () -> Unit, + uiState: AuthenticationUiState, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + modifier = modifier, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .padding(dimensionResource(R.dimen.padding_extra_large)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LogoHeading() + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_extra_large))) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Sign In with passkey or password Button + ShrineButton( + onClick = onSignInWithPasskeyOrPasswordRequest, + buttonText = stringResource(id = R.string.sign_in), + isButtonEnabled = !uiState.isLoading, + ) + + // Sign Up Button + ShrineButton( + onClick = navigateToRegister, + buttonText = stringResource(id = R.string.sign_up), + usePrimaryColor = false, + isButtonEnabled = !uiState.isLoading, + ) + + // Sign in with Google image + Image( + painter = painterResource(id = R.drawable.siwg_button_light), + contentDescription = stringResource(R.string.sign_in_with_google_button), + modifier = Modifier + .height(dimensionResource(R.dimen.siwg_button_height)) + .clickable( + enabled = !uiState.isLoading, + onClick = onSignInWithSignInWithGoogleRequest) + ) + } + } + + if (uiState.isLoading) { + ShrineLoader() + } + + val snackbarMessage: String? = when { + !uiState.passkeyRequestErrorMessage.isNullOrBlank() -> uiState.passkeyRequestErrorMessage + !uiState.signInWithGoogleRequestErrorMessage.isNullOrBlank() -> uiState.signInWithGoogleRequestErrorMessage + uiState.logInWithFederatedTokenFailure -> stringResource(R.string.sign_in_with_google_response_error_message) + !uiState.isSignInWithPasskeysSuccess && stringResource(uiState.passkeyResponseMessageResourceId).isNotBlank() -> { + stringResource(uiState.passkeyResponseMessageResourceId) + } + else -> null + } + LaunchedEffect(uiState) { + snackbarMessage?.let { + snackbarHostState.showSnackbar( + message = it, + ) + } + } + } +} + +/** + * Preview function for the authentication screen. + * + * This function displays a preview of the authentication screen in the Android Studio preview pane. + */ +@Preview(showSystemUi = true) +@Composable +fun AuthenticationScreenPreview() { + ShrineTheme { + AuthenticationScreen( + onSignInWithPasskeyOrPasswordRequest = { }, + onSignInWithSignInWithGoogleRequest = { }, + navigateToRegister = { }, + uiState = AuthenticationUiState(), + modifier = Modifier, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/CreatePasskeyScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/CreatePasskeyScreen.kt new file mode 100644 index 00000000..ce3fe1e3 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/CreatePasskeyScreen.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.PasskeyInfo +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineLoader +import com.authentication.shrine.ui.common.ShrineTextHeader +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.CreatePasskeyUiState +import com.authentication.shrine.ui.viewmodel.CreatePasskeyViewModel + +/** + * Stateful composable function for the create passkey screen. + * + * This screen allows the user to create a passkey for authentication. + * + * @param navigateToMainMenu Callback to navigate to the main menu. + * @param viewModel The [CreatePasskeyViewModel] for this screen. + * @param onLearnMoreClicked Callback to navigate to the learn more screen. + * @param onNotNowClicked Callback to dismiss the create passkey screen. + * @param credentialManagerUtils The instance of [CredentialManagerUtils] + */ +@Composable +fun CreatePasskeyScreen( + navigateToMainMenu: (isSignedInThroughPasskeys: Boolean) -> Unit, + viewModel: CreatePasskeyViewModel, + onLearnMoreClicked: () -> Unit, + onNotNowClicked: () -> Unit, + modifier: Modifier = Modifier, + credentialManagerUtils: CredentialManagerUtils, +) { + val uiState = viewModel.uiState.collectAsState().value + + val context = LocalContext.current + val onRegisterRequest = { + viewModel.createPasskey( + onSuccess = { flag -> + navigateToMainMenu(flag) + }, + ) { data -> + credentialManagerUtils.createPasskey( + requestResult = data, + context = context, + ) + } + } + + CreatePasskeyScreen( + onLearnMoreClicked = onLearnMoreClicked, + onRegisterRequest = onRegisterRequest, + onNotNowClicked = onNotNowClicked, + uiState = uiState, + modifier = modifier, + ) +} + +/** + * Stateless composable function for the create passkey screen. + * + * This screen allows the user to create a passkey for authentication. + * + * @param onLearnMoreClicked Callback invoked when the user clicks on "Learn more". + * @param onRegisterRequest Callback to initiate the passkey creation request. + * @param onNotNowClicked Callback invoked when the user clicks on "Not now". + * @param uiState The current UI state of the create passkey screen. + * @param modifier Modifier to be applied to the composable. + */ +@Composable +fun CreatePasskeyScreen( + onLearnMoreClicked: () -> Unit, + onRegisterRequest: () -> Unit, + onNotNowClicked: () -> Unit, + uiState: CreatePasskeyUiState, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .padding(dimensionResource(R.dimen.padding_large)) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShrineTextHeader( + text = stringResource(R.string.create_passkey), + ) + + PasskeyInfo( + onLearnMoreClicked = onLearnMoreClicked, + ) + + CreatePasskeyActions( + onRegisterRequest = onRegisterRequest, + uiState = uiState, + onNotNowClicked = onNotNowClicked, + ) + } + + if (uiState.isLoading) { + ShrineLoader() + } + + val snackbarMessage = if (uiState.messageResourceId != R.string.empty_string) { + val baseMessage = stringResource(uiState.messageResourceId) + if (uiState.errorMessage != null) { + "$baseMessage ${uiState.errorMessage}" + } else { + baseMessage + } + } else { + null // No message to show + } + + LaunchedEffect(uiState) { + snackbarMessage?.let { + snackbarHostState.showSnackbar( + message = it, + ) + } + } + } +} + +@Composable +private fun CreatePasskeyActions( + onRegisterRequest: () -> Unit, + uiState: CreatePasskeyUiState, + onNotNowClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .height(250.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShrineButton( + onClick = onRegisterRequest, + buttonText = stringResource(R.string.create_passkey), + isButtonEnabled = !uiState.isLoading, + ) + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_large))) + + ShrineButton( + onClick = onNotNowClicked, + buttonText = stringResource(R.string.not_now), + usePrimaryColor = false, + isButtonEnabled = !uiState.isLoading, + ) + } +} + +/** + * Preview function for the create passkey screen. + * + * This function displays a preview of the create passkey screen in the Android Studio preview pane. + */ +@Preview(showSystemUi = true) +@Composable +fun CreatePasskeyScreenPreview() { + ShrineTheme { + CreatePasskeyScreen( + onLearnMoreClicked = { }, + onRegisterRequest = { }, + onNotNowClicked = { }, + uiState = CreatePasskeyUiState(), + modifier = Modifier, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/HelpScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/HelpScreen.kt new file mode 100644 index 00000000..b9161665 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/HelpScreen.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.LogoHeading +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * Composable function for the help screen. + * + * This screen displays contact information for the app. + * + * @param modifier Modifier to be applied to the composable. + */ +@Composable +fun HelpScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .padding(dimensionResource(R.dimen.padding_medium)) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LogoHeading() + Text( + text = stringResource(R.string.contact), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = stringResource(R.string.email), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +/** + * Preview function for the help screen. + * + * This function displays a preview of the help screen in the Android Studio preview pane. + */ +@Preview(showBackground = true) +@Composable +fun HelpScreenPreview() { + ShrineTheme { + HelpScreen() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/LearnMoreScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/LearnMoreScreen.kt new file mode 100644 index 00000000..ff73c5c2 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/LearnMoreScreen.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.ClickableLearnMore +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * Composable function for the learn more screen. + * + * This screen provides information about passkeys and their benefits. + * + * @param modifier Modifier to be applied to the composable. + */ +@Composable +fun LearnMoreScreen( + modifier: Modifier = Modifier, + onBackButtonClicked: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(dimensionResource(R.dimen.padding_medium)), + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.passkey_image), + contentDescription = stringResource(R.string.passkey_logo), + contentScale = ContentScale.Crop, + modifier = Modifier.width(dimensionResource(R.dimen.size_extra_large)) + .align(Alignment.CenterHorizontally), + ) + + Text( + text = stringResource(R.string.learn_more_heading), + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)), + ) + + Text( + text = stringResource(R.string.learn_more_line1), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)), + ) + + Text( + text = stringResource(R.string.learn_more_line2), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small), vertical = dimensionResource(R.dimen.padding_minimum)), + ) + + Text( + text = stringResource(R.string.learn_more_line3), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small), vertical = dimensionResource(R.dimen.padding_minimum)), + ) + + Text( + text = stringResource(R.string.learn_more_line4), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small), vertical = dimensionResource(R.dimen.padding_minimum)), + ) + + Text( + text = stringResource(R.string.learn_more_line5), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small), vertical = dimensionResource(R.dimen.padding_minimum)), + ) + + Text( + text = stringResource(R.string.learn_more_line6), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small), vertical = dimensionResource(R.dimen.padding_minimum)), + ) + + ClickableLearnMore() + + Spacer(Modifier.height(dimensionResource(R.dimen.dimen_standard))) + + ShrineButton( + onClick = onBackButtonClicked, + buttonText = stringResource(R.string.back_button), + ) + } +} + +/** + * Preview function for the learn more screen. + * + * This function displays a preview of the learn more screen in the Android Studio preview pane. + */ +@Preview(showBackground = true) +@Composable +fun LearnMoreScreenPreview() { + ShrineTheme { + LearnMoreScreen() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/MainMenuScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/MainMenuScreen.kt new file mode 100644 index 00000000..821cac4f --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/MainMenuScreen.kt @@ -0,0 +1,193 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.LogoHeading +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.HomeViewModel + +/** + * Stateful composable function that displays the main menu screen. + * + * @param onShrineButtonClicked Callback for when the Shrine button is clicked. + * @param onSettingsButtonClicked Callback for when the settings button is clicked. + * @param onHelpButtonClicked Callback for when the help button is clicked. + * @param navigateToLogin Callback for navigating to the login screen. + * @param viewModel The HomeViewModel that provides the UI state. + */ +@Composable +fun MainMenuScreen( + onShrineButtonClicked: () -> Unit, + onSettingsButtonClicked: () -> Unit, + onHelpButtonClicked: () -> Unit, + navigateToLogin: () -> Unit, + viewModel: HomeViewModel, + modifier: Modifier = Modifier, + credentialManagerUtils: CredentialManagerUtils, +) { + val onSignOut = { + viewModel.signOut( + deleteRestoreKey = credentialManagerUtils::deleteRestoreKey, + ) + } + + MainMenuScreen( + onShrineButtonClicked = onShrineButtonClicked, + onSettingsButtonClicked = onSettingsButtonClicked, + onHelpButtonClicked = onHelpButtonClicked, + navigateToLogin = navigateToLogin, + onSignOut = onSignOut, + modifier = modifier, + ) +} + +/** + * Stateless composable function for the main menu screen. + * + * This screen provides options for accessing the Shrine app, settings, help, and signing out. + * + * @param modifier Modifier to be applied to the composable. + * @param onShrineButtonClicked Callback to navigate to the Shrine app. + * @param onSettingsButtonClicked Callback to navigate to the settings screen. + * @param onHelpButtonClicked Callback to navigate to the help screen. + * @param navigateToLogin Callback to navigate to the login screen. + * @param onSignOut Callback to sign out the user. + */ +@Composable +fun MainMenuScreen( + onShrineButtonClicked: () -> Unit, + onSettingsButtonClicked: () -> Unit, + onHelpButtonClicked: () -> Unit, + navigateToLogin: () -> Unit, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .padding(contentPadding) + .fillMaxHeight() + .padding(dimensionResource(R.dimen.padding_medium)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LogoHeading() + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.size_large))) + + MainMenuButtonsList( + onShrineButtonClicked, + onSettingsButtonClicked, + onHelpButtonClicked, + onSignOut, + navigateToLogin, + ) + } + } +} + +/** + * Composable function that displays a list of buttons for the main menu. + * + * @param onShrineButtonClicked Callback invoked when the "Shop" button is clicked. + * @param onSettingsButtonClicked Callback invoked when the "Settings" button is clicked. + * @param onHelpButtonClicked Callback invoked when the "Help" button is clicked. + * @param onSignOut Callback invoked when the "Sign Out" button is clicked. + * @param navigateToLogin Callback invoked to navigate to the login screen after signing out. + */ +@Composable +private fun MainMenuButtonsList( + onShrineButtonClicked: () -> Unit, + onSettingsButtonClicked: () -> Unit, + onHelpButtonClicked: () -> Unit, + onSignOut: () -> Unit, + navigateToLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(dimensionResource(R.dimen.padding_medium)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.padding_large)), + ) { + ShrineButton( + onClick = onShrineButtonClicked, + buttonText = stringResource(R.string.shop), + ) + + ShrineButton( + onClick = onSettingsButtonClicked, + buttonText = stringResource(R.string.settings), + usePrimaryColor = false, + ) + + ShrineButton( + onClick = onHelpButtonClicked, + buttonText = stringResource(R.string.help), + ) + + ShrineButton( + onClick = { + onSignOut() + navigateToLogin() + }, + buttonText = stringResource(R.string.sign_out), + usePrimaryColor = false, + ) + } +} + +/** + * Generates a preview of the PasskeysSignedPreview composable function. + */ +@Preview(showBackground = true) +@Composable +fun PasskeysSignedPreview() { + ShrineTheme { + MainMenuScreen( + onShrineButtonClicked = { }, + onSettingsButtonClicked = { }, + onHelpButtonClicked = { }, + navigateToLogin = { }, + onSignOut = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/OtherOptionsSignInScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/OtherOptionsSignInScreen.kt new file mode 100644 index 00000000..a18f70f3 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/OtherOptionsSignInScreen.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineTextHeader +import com.authentication.shrine.ui.common.ShrineToolbar +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * Composable function that displays the screen for non-passkey registration options. + * + * @param onSignUpWithPasswordClicked Callback for signing up with password. + */ +@Composable +fun OtherOptionsSignInScreen( + onSignUpWithPasswordClicked: () -> Unit, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .padding(dimensionResource(R.dimen.padding_medium)), + ) { + ShrineToolbar( + showBack = true, + onBackClicked = onBackClicked, + ) + ShrineTextHeader( + text = stringResource(R.string.other_options), + ) + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_extra_large))) // Spacer like in RegisterScreen + ShrineButton( + onClick = onSignUpWithPasswordClicked, + buttonText = stringResource(R.string.sign_up_with_password), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun OtherOptionsSignInScreenPreview() { + ShrineTheme { + OtherOptionsSignInScreen( + onBackClicked = {}, + onSignUpWithPasswordClicked = {}, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/PasskeyManagementScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/PasskeyManagementScreen.kt new file mode 100644 index 00000000..924bc6ee --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/PasskeyManagementScreen.kt @@ -0,0 +1,338 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.decode.SvgDecoder +import coil.request.ImageRequest +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.R +import com.authentication.shrine.model.PasskeyCredential +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineClickableText +import com.authentication.shrine.ui.common.ShrineLoader +import com.authentication.shrine.ui.common.ShrineTextHeader +import com.authentication.shrine.ui.common.ShrineToolbar +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.PasskeyManagementUiState +import com.authentication.shrine.ui.viewmodel.PasskeyManagementViewModel +import com.authentication.shrine.utility.toReadableDate + +/** + * Stateful composable of the Passkeys Management Screen + * + * @param onLearnMoreClicked onclick lambda invoked when clicked on learn more about passkeys + * @param viewModel [PasskeyManagementViewModel] + * @param modifier Modifier to modify the UI of the screen + * */ +@Composable +fun PasskeyManagementScreen( + onLearnMoreClicked: () -> Unit, + onBackClicked: () -> Unit, + viewModel: PasskeyManagementViewModel, + modifier: Modifier = Modifier, + credentialManagerUtils: CredentialManagerUtils, +) { + val uiState = viewModel.uiState.collectAsState().value + // remember is added to callbacks so LazyColumn doesn't recompose when each one loads + val onDeleteClicked = + remember { { credentialId: String -> viewModel.deletePasskey(credentialId) } } + val context = LocalContext.current + val onCreatePasskeyClicked = remember { + { + viewModel.createPasskey( + { data -> + credentialManagerUtils.createPasskey( + requestResult = data, + context = context, + ) + }) + } + } + + PasskeyManagementScreen( + onLearnMoreClicked = onLearnMoreClicked, + onBackClicked = onBackClicked, + onCreatePasskeyClicked = onCreatePasskeyClicked, + onDeleteClicked = onDeleteClicked, + uiState = uiState, + passkeysList = uiState.passkeysList, + aaguidData = uiState.aaguidData, + modifier = modifier, + ) +} + +/** + * Stateless composable of the Passkey Management Screen + * + * @param onLearnMoreClicked onclick lambda invoked when clicked on learn more about passkeys + * @param passkeysList List of [PasskeyCredential] from the [PasskeyManagementViewModel] + * @param modifier Modifier to modify the UI of the screen + * */ +@Composable +fun PasskeyManagementScreen( + onLearnMoreClicked: () -> Unit, + onBackClicked: () -> Unit, + onCreatePasskeyClicked: () -> Unit, + onDeleteClicked: (credentialId: String) -> Unit, + uiState: PasskeyManagementUiState, + passkeysList: List, + aaguidData: Map>, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.dimen_standard)), + ) { + ShrineToolbar( + showBack = true, + onBackClicked = onBackClicked, + ) + + ShrineTextHeader(stringResource(R.string.passkeys)) + + ShrineClickableText( + text = stringResource(R.string.passkeys_info), + clickableText = stringResource(R.string.learn_more_about_passkeys), + onTextClick = onLearnMoreClicked, + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)), + ) + + if (passkeysList.isNotEmpty()) { + PasskeysListColumn( + onDeleteClicked = onDeleteClicked, + passkeysList = passkeysList, + aaguidData = aaguidData, + ) + } else { + ShrineButton( + onClick = onCreatePasskeyClicked, + buttonText = stringResource(R.string.create_passkey), + modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)), + usePrimaryColor = false, + isButtonEnabled = !uiState.isLoading, + ) + } + } + if (uiState.isLoading) { + ShrineLoader() + } + + val snackbarMessage = when { + !uiState.errorMessage.isNullOrBlank() -> uiState.errorMessage + uiState.messageResourceId != R.string.empty_string -> stringResource(uiState.messageResourceId) + else -> null + } + + LaunchedEffect(uiState) { + snackbarMessage?.let { + snackbarHostState.showSnackbar( + message = it + ) + } + } + } +} + +/** + * Composable displaying the list of passkeys + * + * @param passkeysList List of [PasskeyCredential] + * */ +@Composable +fun PasskeysListColumn( + onDeleteClicked: (credentialId: String) -> Unit, + passkeysList: List, + aaguidData: Map>, +) { + val shape = RoundedCornerShape(dimensionResource(R.dimen.padding_small)) + LazyColumn( + modifier = Modifier + .padding(dimensionResource(R.dimen.padding_small)) + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape = shape) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(dimensionResource(R.dimen.padding_small)) + .fillMaxWidth(), + ) { + itemsIndexed( + items = passkeysList, + itemContent = { index, item -> + PasskeysDetailsRow( + onDeleteClicked = onDeleteClicked, + credentialId = item.id, + iconSvgString = aaguidData[item.aaguid]?.get("icon_light"), + credentialProviderName = item.name, + passkeyCreationDate = item.registeredAt.toReadableDate(), + ) + + if (index < passkeysList.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding( + vertical = dimensionResource(R.dimen.padding_extra_small), + horizontal = dimensionResource(R.dimen.dimen_standard) + ), + thickness = 1.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } +} + +/** + * Composable to display one list item of Passkeys detail + * + * @param iconSvgString Icon SVG string fetched from the server for the credential provider + * @param credentialProviderName Name of the credential provider for the passkey + * @param passkeyCreationDate Date when the passkey was created + * */ +@Composable +fun PasskeysDetailsRow( + onDeleteClicked: (credentialId: String) -> Unit, + credentialId: String, + iconSvgString: String?, + credentialProviderName: String, + passkeyCreationDate: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensionResource(R.dimen.padding_small)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.dimen_standard)), + ) { + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current) + .data(iconSvgString?.toByteArray() ?: R.drawable.ic_passkey) + .decoderFactory(SvgDecoder.Factory()) + .build(), + ) + + Image( + modifier = Modifier.size(48.dp), + painter = painter, + contentDescription = stringResource(R.string.credential_provider_logo), + ) + + Column( + modifier = Modifier.weight(1F), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_extra_small)), + ) { + Text( + text = credentialProviderName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(R.string.created, passkeyCreationDate), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + TextButton( + onClick = { + onDeleteClicked(credentialId) + }, + ) { + Text( + text = stringResource(R.string.delete), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Preview of the stateless composable of the passkey management screen + * */ +@Preview(showBackground = true) +@Composable +fun PasskeyManagementScreenPreview() { + ShrineTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PasskeyManagementScreen( + onLearnMoreClicked = { }, + onBackClicked = { }, + onCreatePasskeyClicked = { }, + onDeleteClicked = { }, + uiState = PasskeyManagementUiState(), + passkeysList = listOf( + PasskeyCredential( + id = "preview-id-1", + name = "Preview Passkey", + passkeyUserId = "user@preview.com", + credentialType = "public-key", + aaguid = "00000000-0000-0000-0000-000000000000", + registeredAt = System.currentTimeMillis(), + providerIcon = "" + ) + ), + aaguidData = mapOf(), + ) + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/PlaceholderScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/PlaceholderScreen.kt new file mode 100644 index 00000000..b97dcb71 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/PlaceholderScreen.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.LogoHeading +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * Composable function that displays a placeholder screen with a logo and a message. + * + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun PlaceholderScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(dimensionResource(R.dimen.padding_medium)) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LogoHeading() + Text( + text = stringResource(R.string.todo), + textAlign = TextAlign.Center, + ) + } +} + +/** + * Generates a preview of the PlaceholderScreen composable function. + */ +@Preview(showBackground = true) +@Composable +fun PlaceholderScreenPreview() { + ShrineTheme { + PlaceholderScreen() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/RegisterPasswordScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/RegisterPasswordScreen.kt new file mode 100644 index 00000000..bff4d275 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/RegisterPasswordScreen.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineLoader +import com.authentication.shrine.ui.common.ShrineTextHeader +import com.authentication.shrine.ui.common.ShrineToolbar +import com.authentication.shrine.ui.viewmodel.RegisterUiState +import com.authentication.shrine.ui.viewmodel.RegistrationViewModel + +/** + * Stateful composable function that displays the registration screen. + * + * @param navigateToHome Callback for navigating to the home screen. + * @param viewModel The AuthenticationViewModel that provides the UI state. + */ +@Composable +fun RegisterPasswordScreen( + navigateToHome: (isSignInThroughPasskeys: Boolean) -> Unit, + viewModel: RegistrationViewModel, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, + credentialManagerUtils: CredentialManagerUtils, +) { + val uiState = viewModel.uiState.collectAsState().value + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + val context = LocalContext.current + val createRestoreKey = { + viewModel.createRestoreKey( + createRestoreKeyOnCredMan = { createRestoreCredObject -> + credentialManagerUtils.createRestoreKey( + context = context, + requestResult = createRestoreCredObject, + ) + }, + ) + } + + val onRegister = { emailAddress: String, registrationPassword: String -> + viewModel.onPasswordRegister( + username = emailAddress, + password = registrationPassword, + onSuccess = { flag -> + createRestoreKey() + navigateToHome(flag) + }, + createPassword = { username: String, password: String -> + credentialManagerUtils.createPassword( + username = username, + password = password, + context = context, + ) + }, + ) + } + + RegisterPasswordScreen( + onRegister = onRegister, + uiState = uiState, + email = email, + onEmailChanged = { email = it }, + password = password, + onPasswordChange = { password = it }, + passwordVisible = passwordVisible, + onPasswordVisibilityToggle = { passwordVisible = !passwordVisible }, + onBackClicked = onBackClicked, + modifier = modifier, + ) +} + +/** + * Stateless composable function that displays the registration screen. + * + * This screen allows users to create a new account by providing their email and password. + * It also supports signing in with passkeys. + * + * @param onRegister Callback for logging in with email and password. + * @param uiState The UI state of the authentication process. + * @param email The user's email address. + * @param onEmailChanged Callback for when the email address changes. + * @param password The user's password. + * @param onPasswordChange Callback for when the password changes. + * @param passwordVisible Whether the password is currently visible. + * @param onPasswordVisibilityToggle Callback for toggling the visibility of the password. + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun RegisterPasswordScreen( + onRegister: (String, String) -> Unit, + uiState: RegisterUiState, + email: String, + onEmailChanged: (String) -> Unit, + password: String, + onPasswordChange: (String) -> Unit, + passwordVisible: Boolean, + onPasswordVisibilityToggle: () -> Unit, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { + SnackbarHostState() + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .padding(dimensionResource(R.dimen.padding_medium)) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShrineToolbar( + showBack = true, + onBackClicked = onBackClicked, + ) + ShrineTextHeader( + text = stringResource(R.string.create_account), + ) + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_extra_large))) + + RegisterScreenInputSection( + email = email, + onEmailChanged = onEmailChanged, + password = password, + passwordVisible = passwordVisible, + onPasswordChange = onPasswordChange, + onPasswordVisibilityToggle = onPasswordVisibilityToggle, + onRegister = onRegister, + isPageLoading = uiState.isLoading, + ) + } + + if (uiState.isLoading) { + ShrineLoader() + } + + val snackbarMessage = if (uiState.messageResourceId != R.string.empty_string) { + val baseMessage = stringResource(uiState.messageResourceId) + if (uiState.errorMessage != null) { + "$baseMessage ${uiState.errorMessage}" + } else { + baseMessage + } + } else { + null + } + + LaunchedEffect(uiState) { + snackbarMessage?.let { + snackbarHostState.showSnackbar( + message = it, + ) + } + } + } +} + +@Composable +private fun RegisterScreenInputSection( + email: String, + onEmailChanged: (String) -> Unit, + password: String, + passwordVisible: Boolean, + onPasswordChange: (String) -> Unit, + onPasswordVisibilityToggle: () -> Unit, + onRegister: (String, String) -> Unit, + isPageLoading: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + ) + .fillMaxWidth() + .padding(dimensionResource(R.dimen.dimen_standard)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TextField( + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + value = email, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.email_icon), + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + onValueChange = onEmailChanged, + label = { Text(stringResource(R.string.email_address)) }, + placeholder = { Text(stringResource(R.string.email_address)) }, + ) + + TextField( + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + value = password, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Password, + contentDescription = stringResource(R.string.password_icon), + ) + }, + singleLine = true, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.password)) }, + trailingIcon = { + val image = if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + } + IconButton(onClick = onPasswordVisibilityToggle) { + Icon(imageVector = image, stringResource(R.string.password)) + } + }, + ) + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_extra_large))) + + ShrineButton( + onClick = { onRegister(email, password) }, + buttonText = stringResource(R.string.sign_up), + isButtonEnabled = !isPageLoading, + modifier = Modifier.widthIn(min = 280.dp), + ) + } +} + +/** + * Generates a preview of the RegisterPasswordScreen composable function. + */ +@Preview(showSystemUi = true) +@Composable +fun RegisterPasswordScreenPreview() { + RegisterPasswordScreen( + onRegister = { _, _ -> }, + uiState = RegisterUiState(), + email = "", + onEmailChanged = { }, + password = "", + onPasswordChange = { }, + passwordVisible = true, + onPasswordVisibilityToggle = { }, + onBackClicked = { }, + ) +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/RegisterScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/RegisterScreen.kt new file mode 100644 index 00000000..5068b58e --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/RegisterScreen.kt @@ -0,0 +1,368 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.R +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineClickableText +import com.authentication.shrine.ui.common.ShrineLoader +import com.authentication.shrine.ui.common.ShrineTextHeader +import com.authentication.shrine.ui.common.ShrineToolbar +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.RegisterUiState +import com.authentication.shrine.ui.viewmodel.RegistrationViewModel + +/** + * Stateful composable function that displays the registration screen. + * + * @param navigateToHome Callback for navigating to the home screen. + * @param viewModel The RegistrationViewModel that provides the UI state. + */ +@Composable +fun RegisterScreen( + navigateToHome: (isSignInThroughPasskeys: Boolean) -> Unit, + onLearnMoreClicked: () -> Unit, + onOtherWaysToSignUpClicked: () -> Unit, + onBackClicked: () -> Unit, + viewModel: RegistrationViewModel, + modifier: Modifier = Modifier, + credentialManagerUtils: CredentialManagerUtils, +) { + val uiState = viewModel.uiState.collectAsState().value + var fullName by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + + val context = LocalContext.current + val createRestoreKey = { + viewModel.createRestoreKey( + createRestoreKeyOnCredMan = { createRestoreCredObject -> + credentialManagerUtils.createRestoreKey( + context = context, + requestResult = createRestoreCredObject, + ) + }, + ) + } + + val onPasskeyRegister = { emailAddress: String -> + viewModel.onPasskeyRegister( + username = emailAddress, + displayName = fullName, + onSuccess = { flag -> + createRestoreKey() + navigateToHome(flag) + }, + createPasskeyCallback = { data -> + credentialManagerUtils.createPasskey( + requestResult = data, + context = context, + ) + }, + ) + } + + RegisterScreen( + onPasskeyRegister = onPasskeyRegister, + uiState = uiState, + fullName = fullName, + onFullNameChanged = { fullName = it }, + onLearnMoreClicked = onLearnMoreClicked, + onOtherWaysToSignUpClicked = onOtherWaysToSignUpClicked, + onBackClicked = onBackClicked, + email = email, + onEmailChanged = { email = it }, + modifier = modifier, + ) +} + +/** + * Stateless composable function that displays the registration screen. + * + * This screen allows users to create a new account by creating a passkey. + * + * @param onPasskeyRegister Callback for logging in with passkey. + * @param uiState The UI state of the authentication process. + * @param fullName The user's name. + * @param email The user's email address. + * @param onEmailChanged Callback for when the email address changes. + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun RegisterScreen( + onPasskeyRegister: (String) -> Unit, + uiState: RegisterUiState, + fullName: String, + onFullNameChanged: (String) -> Unit, + onLearnMoreClicked: () -> Unit, + onOtherWaysToSignUpClicked: () -> Unit, + onBackClicked: () -> Unit, + email: String, + onEmailChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { + SnackbarHostState() + } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .padding(dimensionResource(R.dimen.padding_small)) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShrineToolbar( + showBack = true, + onBackClicked = onBackClicked, + ) + ShrineTextHeader( + text = stringResource(R.string.sign_up), + ) + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_extra_large))) + + RegisterScreenInputSection( + fullName = fullName, + onFullNameChanged = onFullNameChanged, + onLearnMoreClicked = onLearnMoreClicked, + onOtherWaysToSignUpClicked = onOtherWaysToSignUpClicked, + email = email, + onEmailChanged = onEmailChanged, + onPasskeyRegister = onPasskeyRegister, + isPageLoading = uiState.isLoading, + ) + } + + if (uiState.isLoading) { + ShrineLoader() + } + + val snackbarMessage = if (uiState.messageResourceId != R.string.empty_string) { + val baseMessage = stringResource(uiState.messageResourceId) + if (uiState.errorMessage != null) { + "$baseMessage ${uiState.errorMessage}" + } else { + baseMessage + } + } else { + null + } + + LaunchedEffect(uiState) { // Keyed on uiState to re-trigger + snackbarMessage?.let { + snackbarHostState.showSnackbar( + message = it, + ) + } + } + } +} + +@Composable +private fun RegisterScreenInputSection( + fullName: String, + onFullNameChanged: (String) -> Unit, + onLearnMoreClicked: () -> Unit, + onOtherWaysToSignUpClicked: () -> Unit, + email: String, + onEmailChanged: (String) -> Unit, + onPasskeyRegister: (String) -> Unit, + isPageLoading: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + ) + .fillMaxWidth() + .padding(dimensionResource(R.dimen.dimen_standard)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_extra_small)), + ) { + Text( + text = stringResource(R.string.full_name), + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + color = MaterialTheme.colorScheme.onSurface + ) + TextField( + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + value = fullName, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = stringResource(R.string.email_icon), + ) + }, + singleLine = true, + onValueChange = onFullNameChanged, + label = { Text(stringResource(R.string.full_name)) }, + placeholder = { Text(stringResource(R.string.full_name)) }, + ) + Text( + text = stringResource(R.string.username), + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + color = MaterialTheme.colorScheme.onSurface + ) + TextField( + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + value = email, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.email_icon), + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + onValueChange = onEmailChanged, + label = { Text(stringResource(R.string.email_address)) }, + placeholder = { Text(stringResource(R.string.email_address)) }, + ) + Text( + text = stringResource(R.string.signing_in), + modifier = Modifier.padding(top = dimensionResource(R.dimen.dimen_standard)), + color = MaterialTheme.colorScheme.onSurface + ) + + PasskeyInformationTab(onLearnMoreClicked, onOtherWaysToSignUpClicked) + + ShrineButton( + onClick = { onPasskeyRegister(email) }, + buttonText = stringResource(R.string.sign_up), + isButtonEnabled = !isPageLoading, + modifier = Modifier.widthIn(min = 280.dp), + ) + } +} + +/** + * Composable for the Passkeys Information Tab UI Element + * + * @param onLearnMoreClicked lambda for more information, navigates to an informational screen + * @param onOtherWaysToSignUpClicked lambda for other sign in methods, navigates to + * @OtherOptionsSignInScreen + * */ +@Composable +private fun PasskeyInformationTab( + onLearnMoreClicked: () -> Unit, + onOtherWaysToSignUpClicked: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(dimensionResource(R.dimen.size_standard))) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) // Changed for theme-aware distinction + .padding(dimensionResource(R.dimen.padding_large)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + ) { + Column( + modifier = Modifier.weight(0.6F), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + ) { + ShrineClickableText( + text = stringResource(R.string.signing_in_description), + clickableText = stringResource(R.string.how_passkeys_work), + onTextClick = onLearnMoreClicked, + textStyle = MaterialTheme.typography.bodyMedium, + ) + ShrineClickableText( + text = "", + clickableText = stringResource(R.string.other_ways_to_sign_up), + onTextClick = onOtherWaysToSignUpClicked, + textStyle = MaterialTheme.typography.bodyMedium, + ) + } + + Image( + modifier = Modifier.weight(0.2F), + painter = painterResource(R.drawable.ic_passkeys_info), + contentDescription = stringResource(R.string.passkey_icon), + ) + } + } +} + +/** + * Generates a preview of the RegisterScreen composable function. + */ +@Preview(showSystemUi = true) +@Composable +fun RegisterScreenPreview() { + ShrineTheme { + RegisterScreen( + onPasskeyRegister = { _ -> }, + onLearnMoreClicked = { }, + onOtherWaysToSignUpClicked = { }, + onBackClicked = { }, + uiState = RegisterUiState(), + fullName = "", + onFullNameChanged = { }, + email = "", + onEmailChanged = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/SettingsScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/SettingsScreen.kt new file mode 100644 index 00000000..22c7b4b1 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/SettingsScreen.kt @@ -0,0 +1,448 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.authentication.shrine.R +import com.authentication.shrine.model.PasskeyCredential +import com.authentication.shrine.ui.common.ShrineButton +import com.authentication.shrine.ui.common.ShrineClickableText +import com.authentication.shrine.ui.common.ShrineTextField +import com.authentication.shrine.ui.common.ShrineLoader +import com.authentication.shrine.ui.common.ShrineTextHeader +import com.authentication.shrine.ui.common.ShrineToolbar +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.viewmodel.SettingsUiState +import com.authentication.shrine.ui.viewmodel.SettingsViewModel + +/** + * Stateful Composable for the Settings Screen + * + * @param viewModel [SettingsViewModel] + * @param onCreatePasskeyClicked Lambda to create a passkey + * @param onChangePasswordClicked Lambda invoked when change password is clicked + * @param onLearnMoreClicked Lambda invoked when Learn more about passkeys is clicked + * @param onManagePasskeysClicked Lambda to be invoked when manage passkeys is clicked + * */ +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onCreatePasskeyClicked: () -> Unit, + onChangePasswordClicked: () -> Unit, + onLearnMoreClicked: () -> Unit, + onManagePasskeysClicked: () -> Unit, + onBackClicked: () -> Unit, +) { + val uiState = viewModel.uiState.collectAsState().value + + // Refresh data whenever screen is shown. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.getPasskeysList() + } + } + + SettingsScreen( + onLearnMoreClicked = onLearnMoreClicked, + onCreatePasskeyClicked = onCreatePasskeyClicked, + onChangePasswordClicked = onChangePasswordClicked, + onManagePasskeysClicked = onManagePasskeysClicked, + onBackClicked = onBackClicked, + uiState = uiState, + ) +} + +/** + * Stateless composable of the Settings Screen + * + * @param onCreatePasskeyClicked Lambda to create a passkey + * @param onChangePasswordClicked Lambda invoked when change password is clicked + * @param onLearnMoreClicked Lambda invoked when Learn more about passkeys is clicked + * @param onManagePasskeysClicked Lambda to be invoked when manage passkeys is clicked + * @param uiState [SettingsUiState] Holding the data to update the Composable + * @param modifier [Modifier] responsible for formatting the Screen + * */ +@Composable +fun SettingsScreen( + onCreatePasskeyClicked: () -> Unit, + onChangePasswordClicked: () -> Unit, + onLearnMoreClicked: () -> Unit, + onManagePasskeysClicked: () -> Unit, + onBackClicked: () -> Unit, + uiState: SettingsUiState, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.dimen_standard)), + ) { + ShrineToolbar( + showBack = true, + onBackClicked = onBackClicked, + ) + + ShrineTextHeader(stringResource(R.string.account)) + + ShrineTextField( + title = stringResource(R.string.full_name), + text = uiState.displayname, + ) + + ShrineTextField( + title = stringResource(R.string.username), + text = uiState.username, + ) + + SecuritySection( + onLearnMoreClicked = onLearnMoreClicked, + onCreatePasskeyClicked = onCreatePasskeyClicked, + onChangePasswordClicked = onChangePasswordClicked, + onManagePasskeysClicked = onManagePasskeysClicked, + uiState = uiState, + ) + } + + if (uiState.isLoading) { + ShrineLoader() + } + + val snackbarMessage = when { + !uiState.errorMessage.isNullOrBlank() -> uiState.errorMessage + uiState.messageResourceId != R.string.empty_string -> stringResource(uiState.messageResourceId) + else -> null + } + + LaunchedEffect(uiState) { + snackbarMessage?.let { + snackbarHostState.showSnackbar( + message = it + ) + } + } + } +} + +/** + * Composable Element for the Security section of the screen + * + * @param onCreatePasskeyClicked Lambda to create a passkey + * @param onChangePasswordClicked Lambda invoked when change password is clicked + * @param onLearnMoreClicked Lambda invoked when Learn more about passkeys is clicked + * @param onManagePasskeysClicked Lambda to be invoked when manage passkeys is clicked + * @param uiState [SettingsUiState] Holding the data to update the Composable + * */ +@Composable +fun SecuritySection( + onLearnMoreClicked: () -> Unit, + onCreatePasskeyClicked: () -> Unit, + onChangePasswordClicked: () -> Unit, + onManagePasskeysClicked: () -> Unit, + uiState: SettingsUiState, +) { + Column( + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.dimen_standard)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_extra_small)), + ) { + Text( + stringResource(R.string.security), + color = MaterialTheme.colorScheme.onBackground + ) + + if (uiState.userHasPasskeys) { + PasskeysManagementTab( + onManageClicked = onManagePasskeysClicked, + uiState = uiState, + ) + } else { + CreatePasskeyTab( + onLearnMoreClicked = onLearnMoreClicked, + onCreatePasskeyClicked = onCreatePasskeyClicked, + isButtonEnabled = !uiState.isLoading, + ) + } + } +} + +/** + * Composable for the Passkeys Management Tab UI Element + * + * @param onManageClicked onClick lambda for manage tab, navigates to the list of passkeys screen + * @param uiState [SettingsUiState] Holding the data to update the Composable + * */ +@Composable +fun PasskeysManagementTab( + onManageClicked: () -> Unit, + uiState: SettingsUiState, +) { + val shape = RoundedCornerShape(dimensionResource(R.dimen.size_standard)) + Row( + modifier = Modifier + .fillMaxWidth() + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape = shape) // Changed to outlineVariant + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(dimensionResource(R.dimen.dimen_standard)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_passkey), + contentDescription = stringResource(R.string.icon_passkeys), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Column( + modifier = Modifier.weight(1F), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_extra_small)), + ) { + Text( + text = stringResource(R.string.passkeys), + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "${uiState.passkeysList.size} passkey" + if (uiState.passkeysList.size == 1) { + "" + } else { + "s" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + TextButton( + onClick = onManageClicked, + enabled = !uiState.isLoading, + ) { + Text( + stringResource(R.string.manage), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Composable for the Create Passkey UI element + * + * @param onLearnMoreClicked onclick lambda that redirect to learn more about passkeys on youtube + * @param onCreatePasskeyClicked onclick lambda to navigate to the [CreatePasskeyScreen] + * @param isButtonEnabled Boolean to disable buttons if the screen is loading + * */ +@Composable +fun CreatePasskeyTab( + onLearnMoreClicked: () -> Unit, + onCreatePasskeyClicked: () -> Unit, + isButtonEnabled: Boolean = true, +) { + val shape = RoundedCornerShape(dimensionResource(R.dimen.size_standard)) + Column( + modifier = Modifier + .fillMaxWidth() + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape = shape) // Changed to outlineVariant + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(dimensionResource(R.dimen.padding_large)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + ) { + Column( + modifier = Modifier.weight(0.6F), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + ) { + Text( + text = stringResource(R.string.sign_in_faster_next_time), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + ShrineClickableText( + text = stringResource(R.string.create_passkey_text), + clickableText = stringResource(R.string.how_passkeys_work), + onTextClick = onLearnMoreClicked, + textStyle = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + ) + } + + Image( + modifier = Modifier.weight(0.4F), + painter = painterResource(R.drawable.ic_passkeys_info), + contentDescription = "", + ) + } + + ShrineButton( + onClick = onCreatePasskeyClicked, + buttonText = stringResource(R.string.create_passkey), + isButtonEnabled = isButtonEnabled, + ) + } +} + +/** + * Composable for the Password Management Tab UI Element + * + * @param onChangePasswordClicked onclick lambda to trigger password change flow + * @param lastPasswordChange Date when the last time password was changed + * @param isButtonEnabled + * @param isButtonEnabled Boolean to disable buttons if the screen is loading + * */ +@Composable +fun PasswordManagementTab( + onChangePasswordClicked: () -> Unit, + lastPasswordChange: String, + isButtonEnabled: Boolean = true, +) { + val shape = RoundedCornerShape(dimensionResource(R.dimen.size_standard)) + // TODO: Add password management functionality. + Row( + modifier = Modifier + .fillMaxWidth() + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape = shape) // Changed to outlineVariant + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(dimensionResource(R.dimen.dimen_standard)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.clip_path_group), + contentDescription = "", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Column( + modifier = Modifier.weight(1F), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_extra_small)), + ) { + Text( + text = stringResource(R.string.password), + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(R.string.last_changed, lastPasswordChange), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + TextButton( + onClick = onChangePasswordClicked, + enabled = isButtonEnabled, + ) { + Text( + text = stringResource(R.string.change), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Preview of the stateless composable of the Settings Screen + * */ +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun SettingPreview() { + ShrineTheme { + SettingsScreen( + onCreatePasskeyClicked = { }, + onChangePasswordClicked = { }, + onLearnMoreClicked = { }, + onManagePasskeysClicked = { }, + onBackClicked = { }, + uiState = SettingsUiState(userHasPasskeys = false, username = "User 1"), + ) + } +} + +@Preview(name = "Passkeys Management Tab", showBackground = true) +@Composable +fun PasskeysManagementTabPreview() { + ShrineTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PasskeysManagementTab( + onManageClicked = { }, + uiState = SettingsUiState( + userHasPasskeys = true, + passkeysList = listOf( + PasskeyCredential( + id = "dummy-id-1", + name = "Dummy Passkey", + passkeyUserId = "user@example.com", + credentialType = "public-key", + aaguid = "00000000-0000-0000-0000-000000000000", + registeredAt = System.currentTimeMillis(), + providerIcon = "icon.jpg" + ) + ), + username = "User With Passkey" + ) + ) + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/ShrineAppScreen.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/ShrineAppScreen.kt new file mode 100644 index 00000000..4d459263 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/ShrineAppScreen.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.R +import com.authentication.shrine.data.FakeDatasource +import com.authentication.shrine.model.Product +import com.authentication.shrine.ui.common.ShrineTopMenu +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * The main screen of the Shrine app. + * + * This composable displays the top menu and a list of products. + * + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun ShrineAppScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(color = MaterialTheme.colorScheme.background) + .padding(dimensionResource(R.dimen.dimen_standard)) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShrineTopMenu() + + ProductList( + productList = FakeDatasource.loadProducts(), + ) + } +} + +/** + * Displays a list of products in a lazy column. + * + * @param modifier The modifier to be applied to the composable. + * @param productList The list of products to display. + */ +@Composable +fun ProductList( + productList: List, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .background(color = MaterialTheme.colorScheme.onSecondary) + .padding(dimensionResource(R.dimen.padding_small)) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(productList) { product -> + ProductCard( + product = product, + modifier = Modifier.padding(dimensionResource(R.dimen.padding_extra_small)), + ) + } + } +} + +/** + * Displays a product card with an image and a title. + * + * @param product The product to display. + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun ProductCard( + product: Product, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(product.imageResourceId), + contentDescription = stringResource(product.stringResourceId), + modifier.width(100.dp), + contentScale = ContentScale.Crop, + ) + Text( + text = LocalContext.current.getString(product.stringResourceId), + modifier.padding(dimensionResource(R.dimen.dimen_standard)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +/** + * A preview of the Shrine app screen. + */ +@Preview(showBackground = true) +@Composable +fun ShrineAppScreenPreview() { + ShrineTheme { + ShrineAppScreen() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/ShrineNavigation.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/ShrineNavigation.kt new file mode 100644 index 00000000..f7497a41 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/ShrineNavigation.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.navigation.compose.rememberNavController +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.ui.navigation.ShrineNavActions +import com.authentication.shrine.ui.navigation.ShrineNavGraph +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +/** + * Composable function responsible for setting App theme and navigation. + */ +@Composable +fun ShrineNavigation( + startDestination: String, + credentialManagerUtils: CredentialManagerUtils, +) { + val systemUiController = rememberSystemUiController() + val darkIcons = isSystemInDarkTheme() + + SideEffect { + systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = darkIcons) + } + + val navController = rememberNavController() + val navigationActions = remember(navController) { + ShrineNavActions(navController) + } + + ShrineNavGraph( + navController = navController, + startDestination = startDestination, + navigateToLogin = navigationActions.navigateToLogin, + navigateToHome = navigationActions.navigateToHome, + navigateToMainMenu = navigationActions.navigateToMainMenu, + navigateToRegister = navigationActions.navigateToRegister, + credentialManagerUtils = credentialManagerUtils, + ) +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ChangePasswordSection.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ChangePasswordSection.kt new file mode 100644 index 00000000..e19795e4 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ChangePasswordSection.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * The Change Password component of the Settings screen, consisting of an icon, + * password information, and a clickable "Change" text. + * + * @param onChangePasswordClicked Callback to be invoked when the "Change" text is clicked. + * @param modifier Modifier to be applied to the Change Password component. + */ +@Composable +fun ChangePasswordSection( + onChangePasswordClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.clip_path_group), + contentDescription = stringResource(R.string.password), + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(dimensionResource(id = R.dimen.padding_small)) + .width(dimensionResource(R.dimen.padding_medium)), + ) + + Row( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.padding_small)) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + Text( + text = stringResource(R.string.password), + style = MaterialTheme.typography.bodySmall, + ) + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_extra_small))) + + Text( + text = stringResource(R.string.last_changed_april_13_2023), + style = MaterialTheme.typography.bodySmall, + ) + } + + TextButton( + modifier = Modifier + .padding(top = dimensionResource(id = R.dimen.padding_small)), + onClick = onChangePasswordClicked, + ) { + Text( + text = stringResource(R.string.change), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} + +/** + * Preview of the Change Password component. + */ +@Preview(showBackground = true) +@Composable +fun ChangePasswordComponentPreview() { + ShrineTheme { + ChangePasswordSection( + onChangePasswordClicked = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ClickableLearnMore.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ClickableLearnMore.kt new file mode 100644 index 00000000..80564185 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ClickableLearnMore.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import android.content.Intent +import android.net.Uri +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * A clickable "Learn More" text composable. + * + * @param modifier The modifier to be applied to the text. + */ +@Composable +fun ClickableLearnMore( + modifier: Modifier = Modifier, +) { + val passkeysVideoUrl = stringResource(R.string.passkeys_youtube_url) + val context = LocalContext.current + val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse(passkeysVideoUrl)) } + TextButton( + modifier = modifier, + onClick = { context.startActivity(intent) }, + ) { + Text( + text = stringResource(R.string.learn_more), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * A preview of the ClickableLearnMore composable. + */ +@Preview(showBackground = true) +@Composable +fun ClickableLearnMorePreview() { + ShrineTheme { + ClickableLearnMore() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ContactSection.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ContactSection.kt new file mode 100644 index 00000000..db5a0035 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ContactSection.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * The Contact Us section of a screen, providing a button to access help resources. + * + * @param onHelpClicked Callback to be invoked when the "Help" button is clicked. + * @param modifier Modifier to be applied to the Contact Us section. + */ +@Composable +fun ContactUsSection( + onHelpClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.contact), + style = TextStyle(textAlign = TextAlign.Start), + modifier = Modifier + .padding(bottom = dimensionResource(R.dimen.padding_medium)) + .fillMaxWidth(), + ) + ShrineButton( + onClick = onHelpClicked, + buttonText = stringResource(R.string.help), + ) + } +} + +/** + * Preview of the Contact Us section. + */ +@Preview(showBackground = true) +@Composable +fun ContactUsScreenPreview() { + ShrineTheme { + ContactUsSection( + onHelpClicked = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/Dimensions.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/Dimensions.kt new file mode 100644 index 00000000..3f744edd --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/Dimensions.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.ui.unit.sp + +object Dimensions { + val TEXT_EXTRA_SMALL = 8.sp + val TEXT_SMALL = 10.sp + val SMALL_LINE_HEIGHT = 16.sp + val TEXT_MEDIUM_SMALL = 18.sp + val MEDIUM_LINE_HEIGHT = 20.sp + val TEXT_MEDIUM = 20.sp + val TEXT_MEDIUM_LARGE = 24.sp + val TEXT_LARGE = 30.sp + val TEXT_EXTRA_LARGE = 36.sp + val LARGE_LINE_HEIGHT = 40.sp +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/LogoHeading.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/LogoHeading.kt new file mode 100644 index 00000000..8f14fa58 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/LogoHeading.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * A composable that displays the Shrine logo as a heading. + * + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun LogoHeading( + modifier: Modifier = Modifier, +) { + Image( + painter = painterResource(R.drawable.shrine_home_logo), + contentDescription = stringResource(R.string.large_shrine_logo), + modifier = modifier.size(152.dp), + ) +} + +/** + * A preview of the LogoHeading composable. + */ +@Preview(showBackground = true) +@Composable +fun WelcomeLogoPreview() { + ShrineTheme { + LogoHeading() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/PasskeyInfo.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/PasskeyInfo.kt new file mode 100644 index 00000000..b7e6f9a6 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/PasskeyInfo.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * A composable that displays information about passkeys. + * + * @param modifier The modifier to be applied to the composable. + * @param onLearnMoreClicked The callback to be invoked when the "Learn More" text is clicked. + */ +@Composable +fun PasskeyInfo( + onLearnMoreClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.background, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.passkey_info_heading), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_large)), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.dimen_standard)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.passkey_info), + style = MaterialTheme.typography.displaySmall, + ) + + Text( + text = stringResource(R.string.learn_more), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier + .padding(top = dimensionResource(R.dimen.padding_small)) + .clickable { onLearnMoreClicked() }, + ) + } + + Image( + painter = painterResource(R.drawable.passkey_image), + contentDescription = stringResource(R.string.passkey_logo), + contentScale = ContentScale.Crop, + modifier = Modifier.width(dimensionResource(R.dimen.size_extra_large)), + ) + } + } +} + +/** + * A preview of the PasskeyInfo composable. + */ +@Preview(showBackground = true) +@Composable +fun PasskeyInfoPreview() { + ShrineTheme { + PasskeyInfo( + onLearnMoreClicked = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/SecuritySection.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/SecuritySection.kt new file mode 100644 index 00000000..4b100839 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/SecuritySection.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * The Security section of the Settings screen, providing options for managing security settings + * such as creating passkeys and changing passwords. + * + * @param onCreatePasskeyClicked Callback to be invoked when the "Create Passkey" button is clicked. + * @param onChangePasswordClicked Callback to be invoked when the "Change" text in the + * Change Password component is clicked. + * @param modifier Modifier to be applied to the Security section. + */ +@Composable +fun SecuritySection( + onCreatePasskeyClicked: () -> Unit, + onChangePasswordClicked: () -> Unit, + onLearnMoreClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(dimensionResource(R.dimen.padding_small)), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.security), + style = TextStyle(textAlign = TextAlign.Start), + modifier = Modifier + .padding(bottom = dimensionResource(R.dimen.padding_medium)) + .fillMaxWidth(), + ) + + PasskeyInfo( + onLearnMoreClicked = onLearnMoreClicked, + ) + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_minimum))) + + ShrineButton( + onClick = onCreatePasskeyClicked, + buttonText = stringResource(R.string.create_passkey), + ) + + Spacer(modifier = Modifier.height(dimensionResource(R.dimen.padding_minimum))) + + ChangePasswordSection( + onChangePasswordClicked = onChangePasswordClicked, + modifier = modifier + .height(72.dp) + .background(color = MaterialTheme.colorScheme.surfaceContainer), + ) + } +} + +/** + * Preview of the Security section. + */ +@Preview(showBackground = true) +@Composable +fun SecuritySectionPreview() { + ShrineTheme { + SecuritySection( + onCreatePasskeyClicked = { }, + onChangePasswordClicked = { }, + onLearnMoreClicked = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineButton.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineButton.kt new file mode 100644 index 00000000..6c15ad64 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineButton.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme +import com.authentication.shrine.ui.theme.dark_button +import com.authentication.shrine.ui.theme.light_button + +/** + * The default shape for Shrine buttons. + */ +private val ButtonShape = RoundedCornerShape(50) + +/** + * A custom button composable for the Shrine app. + * + * @param onClick The callback to be invoked when the button is clicked. + * @param buttonText Text to be displayed on the button + * @param modifier The modifier to be applied to the button. + * @param usePrimaryColor Determines button color styling. + * @param isButtonEnabled Whether the button is enabled. + * @param shape The shape of the button. + * @param border Defines the border logic. + * @param interactionSource The interaction source for the button. + */ +@Composable +fun ShrineButton( + onClick: () -> Unit, + buttonText: String, + modifier: Modifier = Modifier, + usePrimaryColor: Boolean = true, + isButtonEnabled: Boolean = true, + shape: Shape = ButtonShape, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val isDarkTheme = isSystemInDarkTheme() + + val enabledContainerColor: Color + val enabledContentColor: Color + + if (isDarkTheme) { + if (usePrimaryColor) { + enabledContainerColor = MaterialTheme.colorScheme.primary + enabledContentColor = MaterialTheme.colorScheme.onPrimary + } else { + enabledContainerColor = MaterialTheme.colorScheme.secondaryContainer + enabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer + } + } else { + if (usePrimaryColor) { + enabledContainerColor = dark_button + enabledContentColor = light_button + } else { + enabledContainerColor = light_button + enabledContentColor = dark_button + } + } + + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = shape, + colors = ButtonDefaults.buttonColors( + containerColor = enabledContainerColor, + contentColor = enabledContentColor, + ), + enabled = isButtonEnabled, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + interactionSource = interactionSource, + ) { + Text( + text = buttonText, + ) + } +} + +@Preview(name = "Shrine Button Light Theme - Primary", group = "Light") +@Composable +private fun ShrineButtonProminentLight() { + ShrineTheme(darkTheme = false) { + ShrineButton( + onClick = { }, + buttonText = stringResource(R.string.demo), + usePrimaryColor = true, + ) + } +} + +@Preview(name = "Shrine Button Light Theme - Secondary", group = "Light") +@Composable +private fun ShrineButtonSecondaryLight() { + ShrineTheme(darkTheme = false) { + ShrineButton( + onClick = { }, + buttonText = stringResource(R.string.demo), + usePrimaryColor = false, + ) + } +} + +@Preview(name = "Shrine Button Dark Theme - Primary", group = "Dark") +@Composable +private fun ShrineButtonProminentDark() { + ShrineTheme(darkTheme = true) { + ShrineButton( + onClick = { }, + buttonText = stringResource(R.string.demo), + usePrimaryColor = true, + ) + } +} + +@Preview(name = "Shrine Button Dark Theme - Secondary", group = "Dark") +@Composable +private fun ShrineButtonSecondaryDark() { + ShrineTheme(darkTheme = true) { + ShrineButton( + onClick = { }, + buttonText = stringResource(R.string.demo), + usePrimaryColor = false, + ) + } +} \ No newline at end of file diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineClickableText.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineClickableText.kt new file mode 100644 index 00000000..3c8b6754 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineClickableText.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink + +/** + * A custom Clickable Text that can be reused throughout the project + * + * @param text Displayed text + * @param clickableText Part of the text that needs to be clickable + * @param onTextClick onclick lambda for the clickable text + * @param textStyle Text style for the text + * @param modifier The [Modifier] to be applied to the ClickableText + * */ +@Composable +fun ShrineClickableText( + text: String, + clickableText: String, + onTextClick: () -> Unit, + textStyle: TextStyle, + modifier: Modifier = Modifier, +) { + val annotatedText = buildAnnotatedString { + if (text.isNotEmpty()) { + append("$text ") + } + if (clickableText.isNotEmpty()) { + withLink( + link = LinkAnnotation.Clickable( + tag = "url", + styles = TextLinkStyles(style = SpanStyle(fontWeight = FontWeight.Bold)), + linkInteractionListener = { onTextClick() }, + ), + ) { + append(clickableText) + } + } + } + + BasicText( + modifier = modifier, + text = annotatedText, + style = textStyle.copy(color = textStyle.color.takeOrElse { MaterialTheme.colorScheme.onSurface }), + ) +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineLoader.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineLoader.kt new file mode 100644 index 00000000..7b2266bf --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineLoader.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R + +/** + * A composable that displays a loading indicator. + * + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun ShrineLoader( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.75f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.width(dimensionResource(R.dimen.size_large)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } +} + +/** + * A preview of the ShrineLoader composable. + */ +@Preview(showBackground = true) +@Composable +fun LoaderPreview() { + ShrineLoader() +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTextField.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTextField.kt new file mode 100644 index 00000000..65c8a8ca --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTextField.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +@Composable +fun ShrineTextField( + title: String, + text: String = "", +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.padding_small)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_extra_small)), + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface + ) + + OutlinedTextField( + value = text, + onValueChange = { }, + modifier = Modifier + .fillMaxWidth(), + enabled = false, + shape = RoundedCornerShape(dimensionResource(R.dimen.size_standard)), + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface + ), + ) + } +} + +@Preview(showSystemUi = true, name = "ShrineTextField Light") +@Composable +fun ShrineTextFieldPreviewLight() { + ShrineTheme(darkTheme = false) { + Surface(color = MaterialTheme.colorScheme.background) { + ShrineTextField( + "Full Name", + "ABC XYZ", + ) + } + } +} + +@Preview(showSystemUi = true, name = "ShrineTextField Dark") +@Composable +fun ShrineTextFieldPreviewDark() { + ShrineTheme(darkTheme = true) { + Surface(color = MaterialTheme.colorScheme.background) { + ShrineTextField( + "Full Name", + "ABC XYZ", + ) + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTextHeader.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTextHeader.kt new file mode 100644 index 00000000..ec57874a --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTextHeader.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * A composable that displays a text header. + * + * @param modifier The modifier to be applied to the composable. + * @param text The text to be displayed. + */ +@Composable +fun ShrineTextHeader( + text: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.padding_small)), + ) { + Text( + text = text, + style = MaterialTheme.typography.headlineMedium, + ) + } +} + +/** + * A preview of the TextHeader composable. + */ +@Preview(showBackground = true) +@Composable +fun ShrineTextHeaderPreview() { + ShrineTheme { + ShrineTextHeader(text = stringResource(R.string.heading_text)) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineToolbar.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineToolbar.kt new file mode 100644 index 00000000..b69f0f07 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineToolbar.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +@Composable +fun ShrineToolbar( + showBack: Boolean = false, + onBackClicked: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.size_large)), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showBack) { + IconButton( + onClick = onBackClicked, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back_arrow_on_toolbar), + ) + } + } + } +} + +@Preview +@Composable +fun ShrineToolbarPreview() { + ShrineTheme { + ShrineToolbar( + showBack = true, + onBackClicked = { }, + ) + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTopMenu.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTopMenu.kt new file mode 100644 index 00000000..e3632495 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/ShrineTopMenu.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * A composable that displays the top menu for the Shrine app. + * + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun ShrineTopMenu( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painterResource(R.drawable.ic_menu_24px), + contentDescription = stringResource(R.string.shrine_app_menu_mockup), + contentScale = ContentScale.Fit, + ) + + Image( + painter = painterResource(R.drawable.logo), + contentDescription = stringResource(R.string.shrine_app_menu_mockup), + contentScale = ContentScale.Fit, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.ic_search_24px), + contentDescription = stringResource(R.string.shrine_app_menu_mockup), + contentScale = ContentScale.Fit, + modifier = Modifier.padding(dimensionResource(R.dimen.padding_minimum)), + ) + + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.padding_small))) + + Image( + painter = painterResource(R.drawable.image), + contentDescription = stringResource(R.string.shrine_app_menu_mockup), + contentScale = ContentScale.Fit, + modifier = Modifier.padding(dimensionResource(R.dimen.padding_minimum)), + ) + } +} + +/** + * A preview of the TopMenu composable. + */ +@Preview(showBackground = true) +@Composable +fun ShrineTopMenuPreview() { + ShrineTheme { + ShrineTopMenu() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/common/UsernameSection.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/UsernameSection.kt new file mode 100644 index 00000000..261fd9d6 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/common/UsernameSection.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.authentication.shrine.R +import com.authentication.shrine.ui.theme.ShrineTheme + +/** + * The Username section of the Settings screen, consisting of a label and an outlined text field. + * + * @param modifier Modifier to be applied to the Username section. + */ +@Composable +fun UsernameSection( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + Text( + text = stringResource(R.string.username), + style = TextStyle(textAlign = TextAlign.Start), + ) + OutlinedTextField( + value = stringResource(R.string.username), + onValueChange = {}, + modifier = Modifier + .padding(dimensionResource(R.dimen.padding_small)) + .fillMaxWidth() + .height(dimensionResource(R.dimen.size_medium)) + .background(color = MaterialTheme.colorScheme.surfaceContainer), + ) + } +} + +/** + * Preview of the Username section. + */ +@Preview(showBackground = true) +@Composable +fun UsernameSectionPreview() { + ShrineTheme { + UsernameSection() + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/navigation/ShrineNavActions.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/navigation/ShrineNavActions.kt new file mode 100644 index 00000000..66369fc6 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/navigation/ShrineNavActions.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.navigation + +import androidx.annotation.StringRes +import androidx.navigation.NavHostController +import com.authentication.shrine.R + +/** + * An enum class representing the different destinations in the Shrine app. + * + * @param title The resource ID of the destination title string. + */ +enum class ShrineAppDestinations(@StringRes val title: Int) { + CreatePasskeyRoute(title = R.string.home), + MainMenuRoute(title = R.string.main_menu), + AuthRoute(title = R.string.auth), + RegisterRoute(title = R.string.register), + RegisterPasswordRoute(title = R.string.register_password), + Help(title = R.string.help), + LearnMore(title = R.string.learn_more), + Placeholder(title = R.string.todo), + Settings(title = R.string.settings), + ShrineApp(title = R.string.app_name), + NavHostRoute(title = R.string.nav_host_route), + PasskeyManagementTab(title = R.string.passkey_management), + OtherOptionsSignInRoute(title = R.string.other_ways_to_sign_in), +} + +/** + * A class that provides navigation actions for the Shrine app. + * This controller will help decide where to navigate + * + * @param navController The [NavHostController] used for navigation. + */ +class ShrineNavActions(navController: NavHostController) { + // Takes user to Home flow. + val navigateToHome: (isSignInThroughPasskeys: Boolean) -> Unit = { + if (it) { + navController.navigate(ShrineAppDestinations.CreatePasskeyRoute.name) { + popUpTo(ShrineAppDestinations.NavHostRoute.name) + launchSingleTop = true + } + } else { + navController.navigate(ShrineAppDestinations.MainMenuRoute.name) { + popUpTo(ShrineAppDestinations.NavHostRoute.name) + launchSingleTop = true + } + } + } + + /** + * Navigates to the login flow. + */ + val navigateToLogin: () -> Unit = { + navController.navigate(ShrineAppDestinations.AuthRoute.name) { + popUpTo(ShrineAppDestinations.NavHostRoute.name) { + inclusive = true + } + launchSingleTop = true + } + } + + /** + * Navigates to the register flow. + */ + val navigateToRegister: () -> Unit = { + navController.navigate(ShrineAppDestinations.RegisterRoute.name) { + launchSingleTop = true + } + } + + /** + * Navigates to the Main Menu flow. + */ + val navigateToMainMenu: (isSignedInThroughPasskeys: Boolean) -> Unit = { + if (it) { + navController.navigate(ShrineAppDestinations.MainMenuRoute.name) { + popUpTo(ShrineAppDestinations.NavHostRoute.name) + launchSingleTop = true + } + } else { + navController.navigate(ShrineAppDestinations.CreatePasskeyRoute.name) { + popUpTo(ShrineAppDestinations.NavHostRoute.name) + launchSingleTop = true + } + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/navigation/ShrineNavGraph.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/navigation/ShrineNavGraph.kt new file mode 100644 index 00000000..88b65c9f --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/navigation/ShrineNavGraph.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.ui.AuthenticationScreen +import com.authentication.shrine.ui.CreatePasskeyScreen +import com.authentication.shrine.ui.HelpScreen +import com.authentication.shrine.ui.LearnMoreScreen +import com.authentication.shrine.ui.MainMenuScreen +import com.authentication.shrine.ui.OtherOptionsSignInScreen +import com.authentication.shrine.ui.PasskeyManagementScreen +import com.authentication.shrine.ui.PlaceholderScreen +import com.authentication.shrine.ui.RegisterPasswordScreen +import com.authentication.shrine.ui.RegisterScreen +import com.authentication.shrine.ui.SettingsScreen +import com.authentication.shrine.ui.ShrineAppScreen + +/** + * The navigation graph for the Shrine app. + * + * This graph handles navigation between the authentication and contacts screens. + * + * @param modifier The modifier to apply to the NavHost. + * @param navController The NavHostController for navigation. + * @param startDestination The route to navigate to when the graph is first created. + * @param navigateToLogin A lambda that navigates to the login screen. + * @param navigateToHome A lambda that navigates to the home screen. + * @param navigateToMainMenu A lambda that navigates to the main menu screen. + * @param navigateToRegister A lambda that navigates to the register screen. + */ +@Composable +fun ShrineNavGraph( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + startDestination: String = ShrineAppDestinations.AuthRoute.name, + navigateToLogin: () -> Unit, + navigateToHome: (Boolean) -> Unit, + navigateToMainMenu: (Boolean) -> Unit, + navigateToRegister: () -> Unit, + credentialManagerUtils: CredentialManagerUtils, +) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + route = ShrineAppDestinations.NavHostRoute.name, + ) { + composable(route = ShrineAppDestinations.AuthRoute.name) { + AuthenticationScreen( + navigateToHome = navigateToHome, + navigateToRegister = navigateToRegister, + viewModel = hiltViewModel(), + credentialManagerUtils = credentialManagerUtils, + ) + } + + composable(route = ShrineAppDestinations.CreatePasskeyRoute.name) { + CreatePasskeyScreen( + navigateToMainMenu = navigateToMainMenu, + viewModel = hiltViewModel(), + onLearnMoreClicked = { navController.navigate(ShrineAppDestinations.LearnMore.name) }, + onNotNowClicked = { navController.navigate(ShrineAppDestinations.MainMenuRoute.name) }, + credentialManagerUtils = credentialManagerUtils, + ) + } + + composable(route = ShrineAppDestinations.MainMenuRoute.name) { + MainMenuScreen( + onShrineButtonClicked = { navController.navigate(ShrineAppDestinations.ShrineApp.name) }, + onSettingsButtonClicked = { navController.navigate(ShrineAppDestinations.Settings.name) }, + onHelpButtonClicked = { navController.navigate(ShrineAppDestinations.Help.name) }, + navigateToLogin = navigateToLogin, + viewModel = hiltViewModel(), + credentialManagerUtils = credentialManagerUtils, + ) + } + + composable(route = ShrineAppDestinations.RegisterRoute.name) { + RegisterScreen( + navigateToHome = navigateToHome, + onLearnMoreClicked = { + navController.navigate(ShrineAppDestinations.LearnMore.name) + }, + onOtherWaysToSignUpClicked = { + navController.navigate(ShrineAppDestinations.OtherOptionsSignInRoute.name) + }, + onBackClicked = { navController.popBackStack() }, + viewModel = hiltViewModel(), + credentialManagerUtils = credentialManagerUtils, + ) + } + + composable(route = ShrineAppDestinations.Help.name) { + HelpScreen() + } + + composable(route = ShrineAppDestinations.LearnMore.name) { + LearnMoreScreen( + onBackButtonClicked = { navController.popBackStack() }, + ) + } + + composable(route = ShrineAppDestinations.OtherOptionsSignInRoute.name) { + OtherOptionsSignInScreen( + onSignUpWithPasswordClicked = { + navController.navigate(ShrineAppDestinations.RegisterPasswordRoute.name) + }, + onBackClicked = { navController.popBackStack() }, + ) + } + + composable(route = ShrineAppDestinations.RegisterPasswordRoute.name) { + RegisterPasswordScreen( + navigateToHome = navigateToHome, + viewModel = hiltViewModel(), + onBackClicked = { navController.popBackStack() }, + credentialManagerUtils = credentialManagerUtils, + ) + } + + composable(route = ShrineAppDestinations.Placeholder.name) { + PlaceholderScreen() + } + + composable(route = ShrineAppDestinations.Settings.name) { + SettingsScreen( + viewModel = hiltViewModel(), + onCreatePasskeyClicked = { + navController.navigate(ShrineAppDestinations.CreatePasskeyRoute.name) + }, + onChangePasswordClicked = { + navController.navigate(ShrineAppDestinations.Placeholder.name) + }, + onLearnMoreClicked = { + navController.navigate(ShrineAppDestinations.LearnMore.name) + }, + onManagePasskeysClicked = { + navController.navigate(ShrineAppDestinations.PasskeyManagementTab.name) + }, + onBackClicked = { navController.popBackStack() }, + ) + } + + composable(route = ShrineAppDestinations.PasskeyManagementTab.name) { backStackEntry -> + PasskeyManagementScreen( + onLearnMoreClicked = { + navController.navigate(ShrineAppDestinations.LearnMore.name) + }, + onBackClicked = { navController.popBackStack() }, + viewModel = hiltViewModel(), + credentialManagerUtils = credentialManagerUtils, + ) + } + + composable(route = ShrineAppDestinations.ShrineApp.name) { + ShrineAppScreen() + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Color.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Color.kt new file mode 100644 index 00000000..15cc27df --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Color.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFFDFBFC0) +val dark_button = Color(0xFF006B5F) +val light_button = Color(0xFFFFFFFF) +val grayBackground = Color(0xFFF4F4F4) +val greenBackground = Color(0xFFC5EAE2) +val md_theme_light_onPrimary = Color(0xFF291718) +val md_theme_light_primaryContainer = Color(0xFFFFD9E3) +val md_theme_light_onPrimaryContainer = Color(0xFF3E001F) +val md_theme_light_secondary = Color(0xFF9C404B) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFFFDADB) +val md_theme_light_onSecondaryContainer = Color(0xFF40000E) +val md_theme_light_tertiary = Color(0xFFF19FB2) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFEFF0) +val md_theme_light_onTertiaryContainer = Color(0xFF3F0015) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFFFFFF) +val md_theme_light_onBackground = Color(0xFF3F0018) +val md_theme_light_surface = Color.Transparent +val md_theme_light_onSurface = Color(0xFF3F0018) +val md_theme_light_surfaceVariant = Color.Transparent +val md_theme_light_onSurfaceVariant = Color(0xFF514347) +val md_theme_light_outline = Color(0xFF837377) +val md_theme_light_inverseOnSurface = Color(0xFFFFECEE) +val md_theme_light_inverseSurface = Color(0xFF5F112C) +val md_theme_light_inversePrimary = Color(0xFFFFB0CA) +val md_theme_light_surfaceTint = Color(0xFF984063) +val md_theme_light_outlineVariant = Color(0xFFD5C2C6) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFFFFB0CA) +val md_theme_dark_onPrimary = Color(0xFF5D1134) +val md_theme_dark_primaryContainer = Color(0xFF7A294B) +val md_theme_dark_onPrimaryContainer = Color(0xFFFFD9E3) +val md_theme_dark_secondary = Color(0xFFFFB2B7) +val md_theme_dark_onSecondary = Color(0xFF5F1220) +val md_theme_dark_secondaryContainer = Color(0xFF7D2935) +val md_theme_dark_onSecondaryContainer = Color(0xFFFFDADB) +val md_theme_dark_tertiary = Color(0xFFFFB2BE) +val md_theme_dark_onTertiary = Color(0xFF5F1128) +val md_theme_dark_tertiaryContainer = Color(0xFF7D293E) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9DE) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF3F0018) +val md_theme_dark_onBackground = Color(0xFFFFD9E0) +val md_theme_dark_surface = Color.Transparent +val md_theme_dark_onSurface = Color(0xFFFFD9E0) +val md_theme_dark_surfaceVariant = Color(0xFF514347) +val md_theme_dark_onSurfaceVariant = Color(0xFFD5C2C6) +val md_theme_dark_outline = Color(0xFF9E8C90) +val md_theme_dark_inverseOnSurface = Color(0xFF3F0018) +val md_theme_dark_inverseSurface = Color(0xFFFFD9E0) +val md_theme_dark_inversePrimary = Color(0xFF984063) +val md_theme_dark_surfaceTint = Color(0xFFFFB0CA) +val md_theme_dark_outlineVariant = Color(0xFF514347) +val md_theme_dark_scrim = Color(0xFF000000) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Theme.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Theme.kt new file mode 100644 index 00000000..6c3478b2 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Theme.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val lightScheme = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + +private val darkScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +/** + * A composable function that sets the theme for the Shrine app. + * + * @param darkTheme Whether to use a dark theme. Defaults to the system setting. + * @param dynamicColor Whether to use dynamic colors. Defaults to false. + * @param content The content to be displayed within the theme. + */ +@Composable +fun ShrineTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> darkScheme + else -> lightScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Type.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Type.kt new file mode 100644 index 00000000..38ef4553 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/theme/Type.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.authentication.shrine.R + +val Ubuntu = FontFamily( + Font(R.font.ubuntu_reg, FontWeight.Normal), + Font(R.font.ubuntu_bold, FontWeight.Bold), + Font(R.font.ubuntu_bolditalic, FontWeight.Bold, FontStyle.Italic), + Font(R.font.ubuntu_italic, FontWeight.Normal, FontStyle.Italic), + Font(R.font.ubuntu_meditalic, FontWeight.Normal, FontStyle.Italic), + Font(R.font.ubuntu_medium, FontWeight.Medium), +) + +// Set of Material typography styles to start with +val Typography = Typography( + bodySmall = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 12.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Normal, + fontSize = 15.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp, + ), + bodyLarge = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Normal, + fontSize = 15.sp, + lineHeight = 25.sp, + letterSpacing = 0.5.sp, + ), + + displaySmall = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 15.sp, + letterSpacing = 0.5.sp, + ), + displayMedium = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Normal, + fontSize = 15.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp, + ), + displayLarge = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, + lineHeight = 25.sp, + letterSpacing = 0.5.sp, + ), + + headlineSmall = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Bold, + fontSize = 30.sp, + lineHeight = 45.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 50.sp, + letterSpacing = 0.sp, + ), + + labelSmall = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Medium, + fontSize = 5.sp, + lineHeight = 10.sp, + letterSpacing = 0.5.sp, + ), + labelMedium = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 15.sp, + letterSpacing = 0.5.sp, + ), + // this is the button font + labelLarge = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp, + ), + + titleSmall = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = Ubuntu, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ), +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/AuthenticationViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/AuthenticationViewModel.kt new file mode 100644 index 00000000..c09b352a --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/AuthenticationViewModel.kt @@ -0,0 +1,318 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import android.util.Log +import androidx.annotation.StringRes +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.GenericCredentialManagerResponse +import com.authentication.shrine.R +import com.authentication.shrine.model.AuthError +import com.authentication.shrine.model.AuthResult +import com.authentication.shrine.repository.AuthRepository +import com.authentication.shrine.repository.AuthRepository.Companion.TAG +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import javax.inject.Inject + +/** + * A ViewModel that handles authentication-related operations. + * + * This ViewModel is responsible for: + * + * - Logging in the user with a username and password. + * - Requesting a sign-in challenge from the server. + * - Handling the response to a sign-in challenge. + * + * The ViewModel maintains an [AuthenticationUiState] object that represents the current UI state of the + * authentication screen. + */ +@HiltViewModel +class AuthenticationViewModel @Inject constructor( + private val repository: AuthRepository, + private val coroutineScope: CoroutineScope, +) : ViewModel() { + + /** + * The UI state of the authentication screen. + */ + private val _uiState = MutableStateFlow(AuthenticationUiState()) + val uiState = _uiState.asStateFlow() + + /** + * Requests a sign-in challenge from the server. + * + * @param onSuccess Lambda that handles actions on successful passkey sign-in + * @param getPasskey Lambda that calls CredManUtil's getPasskey method with Activity reference + */ + fun signInWithPasskeyOrPasswordRequest( + onSuccess: (Boolean) -> Unit, + getCredential: suspend (JSONObject) -> GenericCredentialManagerResponse, + ) { + _uiState.update { AuthenticationUiState(isLoading = true) } + viewModelScope.launch { + when (val result = repository.signInWithPasskeyOrPasswordRequest()) { + is AuthResult.Success -> { + val credentialResponse = getCredential(result.data) + if (credentialResponse is GenericCredentialManagerResponse.GetCredentialSuccess) { + signInWithPasskeyOrPasswordResponse( + credentialResponse.getCredentialResponse, + onSuccess, + ) + } else if (credentialResponse is GenericCredentialManagerResponse.Error) { + repository.clearSessionIdFromDataStore() + _uiState.update { + it.copy( + isLoading = false, + passkeyRequestErrorMessage = credentialResponse.errorMessage, + ) + } + } else if (credentialResponse is GenericCredentialManagerResponse.CancellationError) { + repository.clearSessionIdFromDataStore() + _uiState.update { it.copy(isLoading = false) } + } + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.ServerError -> R.string.error_server + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + else -> R.string.error_unknown + } + _uiState.update { + it.copy( + isLoading = false, + passkeyResponseMessageResourceId = messageResId, + passkeyRequestErrorMessage = errorMessage + ) + } + } + } + } + } + + /** + * Handles the response to a sign-in challenge. + * + * @param response The response from the server + * @param onSuccess Lambda that handles actions on successful passkey sign-in + */ + private fun signInWithPasskeyOrPasswordResponse( + response: GetCredentialResponse, + onSuccess: (navigateToHome: Boolean) -> Unit, + ) { + viewModelScope.launch { + when (repository.signInWithPasskeyOrPasswordResponse(response)) { + is AuthResult.Success -> { + val isPasswordCredential = response.credential is PasswordCredential + repository.setSignedInState(!isPasswordCredential) + _uiState.update { + it.copy( + isSignInWithPasskeysSuccess = true, + isLoading = false + ) + } + onSuccess(isPasswordCredential) + } + + is AuthResult.Failure -> { + repository.setSignedInState(false) + repository.clearSessionIdFromDataStore() + _uiState.update { + it.copy( + passkeyResponseMessageResourceId = R.string.error_invalid_credentials, + isSignInWithPasskeysSuccess = false, + isLoading = false, + ) + } + } + } + } + } + + /** + * Launches the Sign in with Google authentication flow. + * @param onSuccess Lambda that handles actions on successful Google sign in + * @param getCredential Lambda that retrieves the credential from the Credential Manager + */ + fun signInWithGoogleRequest( + onSuccess: (Boolean) -> Unit, + getCredential: suspend () -> GenericCredentialManagerResponse, + ) { + _uiState.update { AuthenticationUiState(isLoading = true) } + viewModelScope.launch { + val credentialResponse = getCredential() + if (credentialResponse is GenericCredentialManagerResponse.GetCredentialSuccess) { + logInWithFederatedToken( + credentialResponse.getCredentialResponse, + onSuccess, + ) + } else if (credentialResponse is GenericCredentialManagerResponse.Error) { + repository.clearSessionIdFromDataStore() + _uiState.update { + it.copy( + isLoading = false, + signInWithGoogleRequestErrorMessage = credentialResponse.errorMessage, + ) + } + } else if (credentialResponse is GenericCredentialManagerResponse.CancellationError) { + repository.clearSessionIdFromDataStore() + _uiState.update { it.copy(isLoading = false) } + } + } + } + + /** + * Logs in the user with federated credentials received from Credential Manager. + * @response Credentials received from Credential Manager + * @onSuccess Lambda that handles actions on successful Google sign in + */ + fun logInWithFederatedToken( + response: GetCredentialResponse, + onSuccess: (navigateToHome: Boolean) -> Unit, + ) { + viewModelScope.launch { + // Get sessionId from the server first. + val sessionId = repository.getFederationOptions() + if (sessionId == null) { + _uiState.update { + it.copy( + isLoading = false, + logInWithFederatedTokenFailure = true, + ) + } + } else { + // Log in to server with retrieved session ID and CredMan credentials. + when (repository.signInWithFederatedTokenResponse(sessionId, response)) { + is AuthResult.Success -> { + repository.setSignedInState(flag = false) + _uiState.update { + it.copy( + isLoading = false, + ) + } + onSuccess(true) + } + + is AuthResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + logInWithFederatedTokenFailure = true, + ) + } + } + } + } + } + } + + /** + * Checks for a stored restore key and attempts to sign in with it if found. + * + * @param getRestoreKey A suspend function that takes a [JSONObject] and returns a [GenericCredentialManagerResponse]. + * This function is responsible for retrieving the restore key from the CredentialManager. + * + * @param onSuccess A lambda that takes a [Boolean] indicating the success of the sign-in operation. + * + * @see GenericCredentialManagerResponse + * @see signInWithPasskeyResponse + */ + fun checkForStoredRestoreKey( + getRestoreKey: suspend (JSONObject) -> GenericCredentialManagerResponse, + onSuccess: (Boolean) -> Unit, + ) { + viewModelScope.launch { + if (!repository.isSignedInThroughPasskeys() && !repository.isSignedInThroughPassword()) { + when (val result = repository.signInWithPasskeyOrPasswordRequest()) { + is AuthResult.Success -> { + val restoreKeyResponse = getRestoreKey(result.data) + if (restoreKeyResponse is GenericCredentialManagerResponse.GetCredentialSuccess) { + _uiState.update { + AuthenticationUiState(isLoading = true) + } + signInWithPasskeyOrPasswordResponse( + response = restoreKeyResponse.getCredentialResponse, + onSuccess = onSuccess, + ) + } else { + repository.clearSessionIdFromDataStore() + } + } + + is AuthResult.Failure -> { + repository.clearSessionIdFromDataStore() + } + } + } + } + } + + /** + * Creates a restore key by registering a new passkey. + * + * @param createRestoreKeyOnCredMan A suspend function that takes a [JSONObject] and returns a + * [GenericCredentialManagerResponse]. This function is responsible for creating + * the restore key. + * + * @see GenericCredentialManagerResponse + */ + fun createRestoreKey( + createRestoreKeyOnCredMan: suspend (createRestoreCredRequestObj: JSONObject) -> GenericCredentialManagerResponse, + ) { + coroutineScope.launch { + when (val result = repository.registerPasskeyCreationRequest()) { + is AuthResult.Success -> { + val createRestoreKeyResponse = createRestoreKeyOnCredMan(result.data) + if (createRestoreKeyResponse is GenericCredentialManagerResponse.CreatePasskeySuccess) { + repository.registerPasskeyCreationResponse(createRestoreKeyResponse.createPasskeyResponse) + } + } + + is AuthResult.Failure -> { + Log.e(TAG, "Error creating restore key.") + // Don't block user sign in if this fails. + } + } + } + } +} + +/** + * Data class that stores tha data of Authentication Screen + */ +data class AuthenticationUiState( + val isLoading: Boolean = false, + @StringRes val passkeyResponseMessageResourceId: Int = R.string.empty_string, + val passkeyRequestErrorMessage: String? = null, + val isSignInWithPasskeysSuccess: Boolean = false, + val signInWithGoogleRequestErrorMessage: String? = null, + val logInWithFederatedTokenFailure: Boolean = false, + val isRestoreCredentialFound: Boolean = false, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/CreatePasskeyViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/CreatePasskeyViewModel.kt new file mode 100644 index 00000000..4ef17459 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/CreatePasskeyViewModel.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.GenericCredentialManagerResponse +import com.authentication.shrine.R +import com.authentication.shrine.model.AuthError +import com.authentication.shrine.model.AuthResult +import com.authentication.shrine.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import javax.inject.Inject + +/** + * ViewModel for creating passkeys. Uses [AuthRepository] to interact with the authentication backend + * and [CredentialManagerUtils] to interact with the Credential Manager API. + * + * @param repository The authentication repository. + * @param credentialManagerUtils The Credential Manager utility. + */ +@HiltViewModel +class CreatePasskeyViewModel @Inject constructor( + private val repository: AuthRepository, + private val credentialManagerUtils: CredentialManagerUtils, +) : ViewModel() { + + /** + * UI state for the create passkey screen. + */ + private val _uiState = MutableStateFlow(CreatePasskeyUiState()) + val uiState = _uiState.asStateFlow() + + /** + * Creates a passkey. + * + * @param onSuccess Callback to be invoked when the passkey creation is successful. + * @param createPasskey Reference to [CredentialManagerUtils.createPasskey] + * The boolean parameter indicates whether the user should be navigated to the home screen. + */ + fun createPasskey( + onSuccess: (navigateToHome: Boolean) -> Unit, + createPasskey: suspend (JSONObject) -> GenericCredentialManagerResponse, + ) { + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + when (val result = repository.registerPasskeyCreationRequest()) { + is AuthResult.Success -> { + val createPasskeyResponse = createPasskey(result.data) + if (createPasskeyResponse is GenericCredentialManagerResponse.CreatePasskeySuccess) { + when (repository.registerPasskeyCreationResponse(createPasskeyResponse.createPasskeyResponse)) { + is AuthResult.Success -> { + _uiState.update { + it.copy( + navigateToMainMenu = true + ) + } + onSuccess(true) + } + + is AuthResult.Failure -> { + _uiState.update { + it.copy( + navigateToMainMenu = true, + messageResourceId = R.string.some_error_occurred_please_check_logs + ) + } + onSuccess(true) + } + } + } else if (createPasskeyResponse is GenericCredentialManagerResponse.Error) { + _uiState.update { + it.copy( + errorMessage = createPasskeyResponse.errorMessage, + isLoading = false + ) + } + } + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.ServerError -> R.string.error_server + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + else -> R.string.error_unknown + } + _uiState.update { + it.copy( + messageResourceId = messageResId, + isLoading = false, + errorMessage = errorMessage + ) + } + onSuccess(false) + } + } + } + } +} + +/** + * Represents the UI state for the create passkey screen. + * + * @param isLoading Indicates whether a passkey creation operation is in progress. + * @param navigateToMainMenu Indicates whether to navigate to the main menu screen. + * @param messageResourceId The resource ID of a message to display to the user. + * @param errorMessage An error message to display to the user, if any. + */ +data class CreatePasskeyUiState( + val isLoading: Boolean = false, + val navigateToMainMenu: Boolean = false, + @StringRes val messageResourceId: Int= R.string.empty_string, + val errorMessage: String? = null, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/ErrorDialogViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/ErrorDialogViewModel.kt new file mode 100644 index 00000000..2f135066 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/ErrorDialogViewModel.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class ErrorDialogViewModel @Inject constructor() : ViewModel() { + private val _uiState = MutableStateFlow(ErrorDialogUiState()) + val uiState = _uiState.asStateFlow() + + fun showErrorDialog() { + _uiState.update { + ErrorDialogUiState(showAlert = true) + } + } + + fun hideErrorDialog() { + _uiState.update { + ErrorDialogUiState(showAlert = false) + } + } +} + +data class ErrorDialogUiState( + val showAlert: Boolean = true, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/HomeViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/HomeViewModel.kt new file mode 100644 index 00000000..51d6fa96 --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/HomeViewModel.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.repository.AuthRepository +import com.authentication.shrine.repository.AuthRepository.Companion.TAG +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A ViewModel that handles home screen-related operations. + * + * This ViewModel is responsible for signing out the user. + */ +@HiltViewModel +class HomeViewModel @Inject constructor( + private val repository: AuthRepository, +) : ViewModel() { + + /** + * Signs out the user. + * + * @param deleteRestoreKey Lambda function received from Composable that triggers + * Credential Manager's deleteRestoreKey + */ + fun signOut( + deleteRestoreKey: suspend () -> Unit, + ) { + viewModelScope.launch { + try { + deleteRestoreKey() + repository.deleteRestoreKeyFromServer() + } catch (e: Exception) { + Log.e(TAG, "Error deleting restore key: " + e.message) + // Don't block user sign out if this fails. + } finally { + repository.signOut() + } + } + } +} diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/PasskeyManagementViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/PasskeyManagementViewModel.kt new file mode 100644 index 00000000..11e81e8b --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/PasskeyManagementViewModel.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import android.app.Application +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.GenericCredentialManagerResponse +import com.authentication.shrine.R +import com.authentication.shrine.model.AuthError +import com.authentication.shrine.model.AuthResult +import com.authentication.shrine.model.PasskeyCredential +import com.authentication.shrine.repository.AuthRepository +import com.authentication.shrine.repository.AuthRepository.Companion.RESTORE_CREDENTIAL_AAGUID +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.io.InputStreamReader +import javax.inject.Inject + +/** + * ViewModel for user passkey and password registration. Uses [AuthRepository] to interact with the + * authentication backend + * + * @param authRepository The authentication repository. + * @param application The application. + */ +@HiltViewModel +class PasskeyManagementViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val application: Application +) : ViewModel() { + private val _uiState = MutableStateFlow(PasskeyManagementUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + loadAaguidData() + getPasskeysList() + } + } + + private fun loadAaguidData() { + // Use viewModelScope for coroutine + viewModelScope.launch(Dispatchers.IO) { // Use IO dispatcher for file reading + try { + val gson = Gson() + val aaguidInputStream = application.assets.open("aaguids.json") + val reader = InputStreamReader(aaguidInputStream) + val aaguidJsonData = gson.fromJson>>( + reader, + object : TypeToken>>() {}.type + ) + _uiState.update { it.copy(aaguidData = aaguidJsonData) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + messageResourceId = R.string.get_aaguid_error, + ) + } + } + } + } + + /** + * Makes a request to get a list of passkeys from the server. Very similar to + * [com.authentication.shrine.ui.viewmodel.SettingsViewModel.getPasskeysList]. + */ + fun getPasskeysList() { + _uiState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + val data = authRepository.getListOfPasskeys() + if (data != null) { + val filteredPasskeysList = + data.credentials.filter { passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID } + _uiState.update { + it.copy( + isLoading = false, + userHasPasskeys = filteredPasskeysList.isNotEmpty(), + passkeysList = filteredPasskeysList, + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + messageResourceId = R.string.get_keys_error, + ) + } + } + } + } + + /** + * Creates a passkey. This is similar to the function in [CreatePasskeyViewModel]. + * + * @param createPasskey Reference to [CredentialManagerUtils.createPasskey] + */ + fun createPasskey( + createPasskey: suspend (JSONObject) -> GenericCredentialManagerResponse, + ) { + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + when (val result = authRepository.registerPasskeyCreationRequest()) { + is AuthResult.Success -> { + val createPasskeyResponse = createPasskey(result.data) + if (createPasskeyResponse is GenericCredentialManagerResponse.CreatePasskeySuccess) { + when (authRepository.registerPasskeyCreationResponse(createPasskeyResponse.createPasskeyResponse)) { + is AuthResult.Success -> { + val passkeysList = authRepository.getListOfPasskeys() + if (passkeysList != null) { + val filteredPasskeysList = + passkeysList.credentials.filter { passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID } + _uiState.update { + it.copy( + isLoading = false, + passkeysList = filteredPasskeysList, + messageResourceId = R.string.passkey_created + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + messageResourceId = R.string.get_keys_error, + ) + } + } + } + + is AuthResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + messageResourceId = R.string.some_error_occurred_please_check_logs + ) + } + } + } + } else if (createPasskeyResponse is GenericCredentialManagerResponse.Error) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = createPasskeyResponse.errorMessage + ) + } + authRepository.setSignedInState(false) + } + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.ServerError -> { + errorMessage = error.message + R.string.error_server + } + + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + + else -> R.string.error_unknown + } + _uiState.update { + it.copy( + messageResourceId = messageResId, + isLoading = false, + errorMessage = errorMessage + ) + } + } + } + } + } + + /** + * Makes a request to delete a passkey from the server. Refreshes the passkey list upon + * successful deletion. + * + * @param credentialId The ID of the passkey to delete. + */ + fun deletePasskey(credentialId: String) { + _uiState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + when (val result = authRepository.deletePasskey(credentialId)) { + is AuthResult.Success -> { + // Refresh passkeys list after deleting a passkey + val data = authRepository.getListOfPasskeys() + if (data != null) { + val filteredPasskeysList = + data.credentials.filter { passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID } + _uiState.update { + it.copy( + isLoading = false, + userHasPasskeys = filteredPasskeysList.isNotEmpty(), + passkeysList = filteredPasskeysList, + messageResourceId = R.string.delete_passkey_successful + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + messageResourceId = R.string.get_keys_error, + ) + } + } + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.ServerError -> { + errorMessage = error.message + R.string.error_server + } + + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + + else -> R.string.error_unknown + } + _uiState.update { + it.copy( + messageResourceId = messageResId, + isLoading = false, + errorMessage = errorMessage + ) + } + } + } + } + } +} + +/** + * Represents the UI state for the passkey management screen. + * + * @param isLoading Indicates whether a modification operation is in progress. + * @param userHasPasskeys Indicates whether the user has passkeys. + * @param passkeysList A list of passkeys for the user returned from the server. + * @param messageResourceId The resource ID of a message to display to the user. + * @param errorMessage An error message returned from the server. + */ +data class PasskeyManagementUiState( + val aaguidData: Map> = emptyMap(), + val isLoading: Boolean = false, + val userHasPasskeys: Boolean = true, + val passkeysList: List = listOf(), + @StringRes val messageResourceId: Int = R.string.empty_string, + val errorMessage: String? = null, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/RegistrationViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/RegistrationViewModel.kt new file mode 100644 index 00000000..4c2dcdac --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/RegistrationViewModel.kt @@ -0,0 +1,296 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law lifeboatress or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.CredentialManagerUtils +import com.authentication.shrine.GenericCredentialManagerResponse +import com.authentication.shrine.R +import com.authentication.shrine.model.AuthError +import com.authentication.shrine.model.AuthResult +import com.authentication.shrine.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import javax.inject.Inject + +/** + * ViewModel for user passkey and password registration. Uses [AuthRepository] to interact with the + * authentication backend + * + * @param repository The authentication repository. + */ +@HiltViewModel +class RegistrationViewModel @Inject constructor( + private val repository: AuthRepository, +) : ViewModel() { + + /** + * UI state for the registration screen. + */ + private val _uiState = MutableStateFlow(RegisterUiState()) + val uiState = _uiState.asStateFlow() + + /** + * Registers a new user with a passkey. + * + * @param username The username of the new user. + * @param displayName The display name of the new user. + * @param onSuccess Lambda to be invoked when the registration is successful. + * @param createPasskeyCallback Lambda to be invoked after login is successful, usually to + * navigate to the next screen. + */ + fun onPasskeyRegister( + username: String, + displayName: String, + onSuccess: (navigateToHome: Boolean) -> Unit, + createPasskeyCallback: suspend (JSONObject) -> GenericCredentialManagerResponse, + ) { + _uiState.update { RegisterUiState(isLoading = true) } + + if (username.isNotEmpty() && displayName.isNotEmpty()) { + viewModelScope.launch { + when (val result = repository.registerUsername(username, displayName)) { + is AuthResult.Success -> { + // Now create the passkey and register with the server + createPasskey(onSuccess, createPasskeyCallback) + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.UserAlreadyExists -> R.string.error_user_exists + is AuthError.InvalidCredentials -> R.string.error_invalid_credentials + is AuthError.ServerError -> { + errorMessage = error.message + R.string.error_server + } + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + } + _uiState.update { + it.copy( + messageResourceId = messageResId, + isLoading = false, + errorMessage = errorMessage + ) + } + } + } + } + } else { + _uiState.update { + it.copy( + messageResourceId = R.string.enter_valid_username_and_display_name, + isLoading = false + ) + } + } + } + + /** + * Creates a passkey. This is similar to the function in [CreatePasskeyViewModel]. + * + * @param onSuccess Callback to be invoked when the passkey creation is successful. + * @param createPasskey Reference to [CredentialManagerUtils.createPasskey] + * The boolean parameter indicates whether the user should be navigated to the home screen. + */ + private fun createPasskey( + onSuccess: (navigateToHome: Boolean) -> Unit, + createPasskey: suspend (JSONObject) -> GenericCredentialManagerResponse, + ) { + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + when (val result = repository.registerPasskeyCreationRequest()) { + is AuthResult.Success -> { + val createPasskeyResponse = createPasskey(result.data) + if (createPasskeyResponse is GenericCredentialManagerResponse.CreatePasskeySuccess) { + when (repository.registerPasskeyCreationResponse(createPasskeyResponse.createPasskeyResponse)) { + is AuthResult.Success -> { + _uiState.update { + it.copy( + isSuccess = true, + isLoading = false, + ) + } + onSuccess(false) + } + + is AuthResult.Failure -> { + _uiState.update { + it.copy( + isSuccess = false, + isLoading = false, + messageResourceId = R.string.some_error_occurred_please_check_logs + ) + } + } + } + } else if (createPasskeyResponse is GenericCredentialManagerResponse.Error) { + _uiState.update { + it.copy( + messageResourceId = R.string.some_error_occurred_please_check_logs, + errorMessage = createPasskeyResponse.errorMessage, + isLoading = false + ) + } + repository.setSignedInState(false) + } + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.ServerError -> { + errorMessage = error.message + R.string.error_server + } + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + else -> R.string.error_unknown + } + _uiState.update { + it.copy( + messageResourceId = messageResId, + isLoading = false, + errorMessage = errorMessage + ) + } + onSuccess(false) + } + } + } + } + + /** + * Registers a new user with a password. + * + * @param username The username of the new user. + * @param password The password of the new user. + * @param onSuccess Lambda to be invoked when the registration is successful. + * The boolean parameter indicates whether the user should be navigated to the home screen. + * @param createPassword Lambda to be invoked when login is success and password needs to be saved + */ + fun onPasswordRegister( + username: String, + password: String, + onSuccess: (navigateToHome: Boolean) -> Unit, + createPassword: suspend (String, String) -> Unit, + ) { + _uiState.update { it.copy(isLoading = true) } + + if (username.isNotEmpty() && password.isNotEmpty()) { + viewModelScope.launch { + when (val result = repository.login(username, password)) { + is AuthResult.Success -> { + createPassword(username, password) + _uiState.update { + it.copy( + isSuccess = true, + isLoading = false + ) + } + onSuccess(true) + } + + is AuthResult.Failure -> { + var errorMessage: String? = null + val messageResId = when (val error = result.error) { + is AuthError.NetworkError -> R.string.error_network + is AuthError.UserAlreadyExists -> R.string.error_user_exists + is AuthError.InvalidCredentials -> R.string.error_invalid_credentials + is AuthError.ServerError -> { + errorMessage = error.message + R.string.error_server + } + is AuthError.Unknown -> { + errorMessage = error.message + R.string.error_unknown + } + } + _uiState.update { + it.copy( + messageResourceId = messageResId, + isLoading = false, + errorMessage = errorMessage + ) + } + } + } + repository.setSignedInState(false) + } + } else { + _uiState.update { + it.copy( + messageResourceId = R.string.enter_valid_username_and_password, + isLoading = false + ) + } + } + } + + /** + * Creates a restore key by registering a new passkey. + * + * @param createRestoreKeyOnCredMan A suspend function that takes a [JSONObject] and returns a + * [GenericCredentialManagerResponse]. This function is responsible for creating + * the restore key. + * + * @see GenericCredentialManagerResponse + */ + fun createRestoreKey( + createRestoreKeyOnCredMan: suspend (createRestoreCredRequestObj: JSONObject) -> GenericCredentialManagerResponse, + ) { + viewModelScope.launch { + when (val result = repository.registerPasskeyCreationRequest()) { + is AuthResult.Success -> { + val createRestoreKeyResponse = createRestoreKeyOnCredMan(result.data) + if (createRestoreKeyResponse is GenericCredentialManagerResponse.CreatePasskeySuccess) { + repository.registerPasskeyCreationResponse(createRestoreKeyResponse.createPasskeyResponse) + } + } + + is AuthResult.Failure -> { + // Don't block user registration if this fails. + } + } + } + } +} + +/** + * Represents the UI state for the registration screen. + * + * @param isLoading Indicates whether a registration operation is in progress. + * @param isSuccess Indicates whether the registration was successful. + * @param messageResourceId The resource ID of a message to display to the user. + */ +data class RegisterUiState( + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + @StringRes val messageResourceId: Int = R.string.empty_string, + val errorMessage: String? = null, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SettingsViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SettingsViewModel.kt new file mode 100644 index 00000000..1815498a --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SettingsViewModel.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.R +import com.authentication.shrine.model.PasskeyCredential +import com.authentication.shrine.repository.AuthRepository +import com.authentication.shrine.repository.AuthRepository.Companion.RESTORE_CREDENTIAL_AAGUID +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel for the Settings screen. + * + * This ViewModel is responsible for managing the UI state of the settings screen, + * including fetching user information and passkey details. It uses [AuthRepository] + * to interact with the data layer. + * + * Annotated with {@link HiltViewModel} for Hilt dependency injection. + * + * @property authRepository The repository for authentication-related operations. + * @see SettingsUiState + * @see AuthRepository + */ +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState = _uiState.asStateFlow() + + /** + * Fetches the list of passkeys for the authenticated user and updates the UI state. + * + * This function updates {@link #_uiState} to indicate loading status, + * and then asynchronously retrieves passkey data and username from {@link AuthRepository}. + * On successful retrieval, it updates the state with the fetched data. + * If retrieval fails, it updates the state with an error message. + */ + fun getPasskeysList() { + _uiState.update { + SettingsUiState(isLoading = true) + } + + viewModelScope.launch { + try { + val data = authRepository.getListOfPasskeys() + if (data != null) { + val filteredPasskeysList = + data.credentials.filter({ passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID }) + _uiState.update { + it.copy( + isLoading = false, + userHasPasskeys = filteredPasskeysList.isNotEmpty(), + username = authRepository.getUsername(), + displayname = authRepository.getDisplayname(), + passkeysList = filteredPasskeysList, + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + messageResourceId = R.string.get_keys_error, + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message, + ) + } + } + } + } +} + +/** + * Data class representing the UI state for the Settings screen. + * + * This class holds all the data necessary to render the settings UI, + * including loading indicators, user information, passkey details, and error messages. + * + * @property isLoading True if data is currently being loaded, false otherwise. + * @property userHasPasskeys True if the user has registered passkeys, false otherwise. + * Defaults to true, assuming a user might have passkeys until checked. + * @property username The display name of the authenticated user. + * @property passkeysList A list of {@link PasskeyCredential} objects representing the user's passkeys. + * @property passwordChanged A string indicating when the password was last changed (e.g., "2 days ago"). + * This property is not updated by the provided ViewModel snippet. + * @property messageResourceId A string resource ID for a message to be displayed. + * @property errorMessage A string resource ID for an error message to be displayed. + * Defaults to -1, indicating no error. + */ +data class SettingsUiState( + val isLoading: Boolean = false, + val userHasPasskeys: Boolean = true, + val username: String = "", + val displayname: String = "", + val passkeysList: List = listOf(), + val passwordChanged: String = "", + @StringRes val messageResourceId: Int = R.string.empty_string, + val errorMessage: String? = null, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SplashViewModel.kt b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SplashViewModel.kt new file mode 100644 index 00000000..cf414c3a --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SplashViewModel.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.authentication.shrine.model.AuthResult +import com.authentication.shrine.repository.AuthRepository +import com.authentication.shrine.ui.navigation.ShrineAppDestinations +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A ViewModel that handles splash screen-related operations. + * + * This ViewModel is responsible for checking if the user is signed in, either using a password or + * passkeys. + */ +@HiltViewModel +class SplashViewModel @Inject constructor( + private val repository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SplashScreenState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val startDestination = + if (isSessionIdValid()) { + ShrineAppDestinations.MainMenuRoute.name + } else { + signOut() + ShrineAppDestinations.AuthRoute.name + } + + _uiState.update { + SplashScreenState( + nextScreen = startDestination, + isLoading = false, + ) + } + } + } + + /** + * Checks if the session ID is valid with the server. + * + * @return True if the session ID is valid, false otherwise or if there was an error. + */ + private suspend fun isSessionIdValid(): Boolean { + return when (repository.isSessionIdValid()) { + is AuthResult.Success -> true + is AuthResult.Failure -> false + } + } + + /** + * Signs out the user. + */ + private fun signOut() { + viewModelScope.launch { + repository.signOut() + } + } +} + +data class SplashScreenState( + val isLoading: Boolean = true, + val nextScreen: String = ShrineAppDestinations.AuthRoute.name, +) diff --git a/Shrine/app/src/main/java/com/authentication/shrine/utility/Constants.kt b/Shrine/app/src/main/java/com/authentication/shrine/utility/Constants.kt new file mode 100644 index 00000000..0867954c --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/utility/Constants.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +<<<<<<<< HEAD:WebView/WebkitWebView/app/src/main/java/com/google/webkit/webviewsample/theme/Shape.kt +package com.google.webkit.webviewsample.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp), +) +======== +package com.authentication.shrine.utility + +object Constants { + const val SESSION_ID_KEY = "SESAME_SESSION_COOKIE=" +} +>>>>>>>> aa0a640 (Move Shrine app to main):Shrine/app/src/main/java/com/authentication/shrine/utility/Constants.kt diff --git a/Shrine/app/src/main/java/com/authentication/shrine/utility/ExtensionFunctions.kt b/Shrine/app/src/main/java/com/authentication/shrine/utility/ExtensionFunctions.kt new file mode 100644 index 00000000..016f03cc --- /dev/null +++ b/Shrine/app/src/main/java/com/authentication/shrine/utility/ExtensionFunctions.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authentication.shrine.utility + +import com.authentication.shrine.api.ApiException +import com.authentication.shrine.utility.Constants.SESSION_ID_KEY +import com.google.gson.Gson +import org.json.JSONObject +import retrofit2.Response +import java.text.SimpleDateFormat +import java.util.Date + +/** + * Extension function to convert a Long timestamp (in milliseconds since epoch) + * to a human-readable date string in "dd-MMM-yyyy" format. + * + * Example: `1678886400000.toReadableDate()` might return "15-Mar-2023". + * + * @receiver The Long timestamp in milliseconds. + * @return The formatted date string (e.g., "15-Mar-2023"). + * @see SimpleDateFormat + */ +fun Long.toReadableDate(): String { + val dateFormat = SimpleDateFormat("dd-MMM-yyyy") + val dateString = dateFormat.format(Date(this)) + return dateString +} + +/** + * Extension function to extract a session ID from the "set-cookie" header of a Retrofit {@link Response}. + * It specifically looks for a cookie matching the {@code SESSION_ID_KEY}. + * + * @receiver The Retrofit {@link Response} object. + * @param T The type of the response body. + * @return The session ID string if found, or null if the "set-cookie" header is not present + * or the session ID cookie is not found. + * @throws ApiException if the "set-cookie" header is present but the session ID key cannot be found within it. + */ +fun Response.getSessionId(): String? { + val cookie = headers()["set-cookie"] + if (cookie != null) { + val start = cookie.indexOf(SESSION_ID_KEY) + if (start < 0) { + throw ApiException("Cannot find Session ID") + } + val semicolon = cookie.indexOf(";", start + SESSION_ID_KEY.length) + val end = if (semicolon < 0) cookie.length else semicolon + return cookie.substring(start + SESSION_ID_KEY.length, end) + } else { + return null + } +} + +/** + * Extension function to create a cookie header string from a session ID string. + * This prepends the {@code SESSION_ID_KEY} to the given session ID. + * + * Example: `"myActualSessionId".createCookieHeader()` might return "session_id=myActualSessionId". + * + * @receiver The session ID string. + * @return The formatted cookie header string. + */ +fun String.createCookieHeader(): String { + return SESSION_ID_KEY + this +} + +/** + * Extension function to convert the body of a Retrofit {@link Response} into a {@link JSONObject}. + * This function uses Gson to first serialize the response body to a JSON string, + * and then parses that string into a JSONObject. + * + * Note: This approach involves an intermediate JSON string representation. If performance + * is critical for very large objects, or if the response body is already a JSON string, + * more direct methods might be considered. + * + * @receiver The Retrofit {@link Response} object. + * @param T The type of the response body, which must be serializable by Gson. + * @return A {@link JSONObject} representation of the response body. + * Returns an empty JSONObject if the body is null or cannot be parsed. + * @see Gson + * @see JSONObject + */ +fun Response.getJsonObject(): JSONObject { + val jsonString = Gson().toJson(body()) + return JSONObject(jsonString) +} diff --git a/Shrine/app/src/main/res/drawable-mdpi/bag.png b/Shrine/app/src/main/res/drawable-mdpi/bag.png new file mode 100644 index 00000000..2285b7b7 Binary files /dev/null and b/Shrine/app/src/main/res/drawable-mdpi/bag.png differ diff --git a/Shrine/app/src/main/res/drawable-mdpi/clip_path_group.xml b/Shrine/app/src/main/res/drawable-mdpi/clip_path_group.xml new file mode 100644 index 00000000..eb6e8135 --- /dev/null +++ b/Shrine/app/src/main/res/drawable-mdpi/clip_path_group.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/Shrine/app/src/main/res/drawable-mdpi/dishes.png b/Shrine/app/src/main/res/drawable-mdpi/dishes.png new file mode 100644 index 00000000..45ad7ec2 Binary files /dev/null and b/Shrine/app/src/main/res/drawable-mdpi/dishes.png differ diff --git a/Shrine/app/src/main/res/drawable-mdpi/ic_menu_24px.xml b/Shrine/app/src/main/res/drawable-mdpi/ic_menu_24px.xml new file mode 100644 index 00000000..1b6af8bd --- /dev/null +++ b/Shrine/app/src/main/res/drawable-mdpi/ic_menu_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/Shrine/app/src/main/res/drawable-mdpi/ic_search_24px.xml b/Shrine/app/src/main/res/drawable-mdpi/ic_search_24px.xml new file mode 100644 index 00000000..f113f505 --- /dev/null +++ b/Shrine/app/src/main/res/drawable-mdpi/ic_search_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/Shrine/app/src/main/res/drawable-mdpi/image.png b/Shrine/app/src/main/res/drawable-mdpi/image.png new file mode 100644 index 00000000..1e25e8db Binary files /dev/null and b/Shrine/app/src/main/res/drawable-mdpi/image.png differ diff --git a/Shrine/app/src/main/res/drawable-mdpi/jacket.png b/Shrine/app/src/main/res/drawable-mdpi/jacket.png new file mode 100644 index 00000000..6c4bd343 Binary files /dev/null and b/Shrine/app/src/main/res/drawable-mdpi/jacket.png differ diff --git a/Shrine/app/src/main/res/drawable-mdpi/lamp.png b/Shrine/app/src/main/res/drawable-mdpi/lamp.png new file mode 100644 index 00000000..64ece3d9 Binary files /dev/null and b/Shrine/app/src/main/res/drawable-mdpi/lamp.png differ diff --git a/Shrine/app/src/main/res/drawable-mdpi/logo.xml b/Shrine/app/src/main/res/drawable-mdpi/logo.xml new file mode 100644 index 00000000..3d1f380a --- /dev/null +++ b/Shrine/app/src/main/res/drawable-mdpi/logo.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/Shrine/app/src/main/res/drawable-mdpi/passkey_image.png b/Shrine/app/src/main/res/drawable-mdpi/passkey_image.png new file mode 100644 index 00000000..c1c12670 Binary files /dev/null and b/Shrine/app/src/main/res/drawable-mdpi/passkey_image.png differ diff --git a/Shrine/app/src/main/res/drawable-mdpi/person_24px.xml b/Shrine/app/src/main/res/drawable-mdpi/person_24px.xml new file mode 100644 index 00000000..b99d0927 --- /dev/null +++ b/Shrine/app/src/main/res/drawable-mdpi/person_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/Shrine/app/src/main/res/drawable/ic_passkey.xml b/Shrine/app/src/main/res/drawable/ic_passkey.xml new file mode 100644 index 00000000..cedee73f --- /dev/null +++ b/Shrine/app/src/main/res/drawable/ic_passkey.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/Shrine/app/src/main/res/drawable/ic_passkeys_info.xml b/Shrine/app/src/main/res/drawable/ic_passkeys_info.xml new file mode 100644 index 00000000..0e4e5105 --- /dev/null +++ b/Shrine/app/src/main/res/drawable/ic_passkeys_info.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Shrine/app/src/main/res/drawable/shrine.xml b/Shrine/app/src/main/res/drawable/shrine.xml new file mode 100644 index 00000000..aa914d29 --- /dev/null +++ b/Shrine/app/src/main/res/drawable/shrine.xml @@ -0,0 +1,13 @@ + + + + diff --git a/Shrine/app/src/main/res/drawable/shrine_home_logo.xml b/Shrine/app/src/main/res/drawable/shrine_home_logo.xml new file mode 100644 index 00000000..768a49dd --- /dev/null +++ b/Shrine/app/src/main/res/drawable/shrine_home_logo.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/Shrine/app/src/main/res/drawable/siwg_button_light.png b/Shrine/app/src/main/res/drawable/siwg_button_light.png new file mode 100644 index 00000000..ad33e2f2 Binary files /dev/null and b/Shrine/app/src/main/res/drawable/siwg_button_light.png differ diff --git a/Shrine/app/src/main/res/font/ubuntu_bold.ttf b/Shrine/app/src/main/res/font/ubuntu_bold.ttf new file mode 100644 index 00000000..c2293d5c Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_bold.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_bolditalic.ttf b/Shrine/app/src/main/res/font/ubuntu_bolditalic.ttf new file mode 100644 index 00000000..ce6e784d Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_bolditalic.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_italic.ttf b/Shrine/app/src/main/res/font/ubuntu_italic.ttf new file mode 100644 index 00000000..a599244e Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_italic.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_light.ttf b/Shrine/app/src/main/res/font/ubuntu_light.ttf new file mode 100644 index 00000000..b310d150 Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_light.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_lightitalic.ttf b/Shrine/app/src/main/res/font/ubuntu_lightitalic.ttf new file mode 100644 index 00000000..ad0741b4 Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_lightitalic.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_meditalic.ttf b/Shrine/app/src/main/res/font/ubuntu_meditalic.ttf new file mode 100644 index 00000000..36ac1aed Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_meditalic.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_medium.ttf b/Shrine/app/src/main/res/font/ubuntu_medium.ttf new file mode 100644 index 00000000..7340a40a Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_medium.ttf differ diff --git a/Shrine/app/src/main/res/font/ubuntu_reg.ttf b/Shrine/app/src/main/res/font/ubuntu_reg.ttf new file mode 100644 index 00000000..f98a2dab Binary files /dev/null and b/Shrine/app/src/main/res/font/ubuntu_reg.ttf differ diff --git a/Shrine/app/src/main/res/values-land/dimens.xml b/Shrine/app/src/main/res/values-land/dimens.xml new file mode 100644 index 00000000..7abc06d3 --- /dev/null +++ b/Shrine/app/src/main/res/values-land/dimens.xml @@ -0,0 +1 @@ + diff --git a/Shrine/app/src/main/res/values-w1240dp/dimens.xml b/Shrine/app/src/main/res/values-w1240dp/dimens.xml new file mode 100644 index 00000000..7abc06d3 --- /dev/null +++ b/Shrine/app/src/main/res/values-w1240dp/dimens.xml @@ -0,0 +1 @@ + diff --git a/Shrine/app/src/main/res/values-w600dp/dimens.xml b/Shrine/app/src/main/res/values-w600dp/dimens.xml new file mode 100644 index 00000000..7abc06d3 --- /dev/null +++ b/Shrine/app/src/main/res/values-w600dp/dimens.xml @@ -0,0 +1 @@ + diff --git a/Shrine/app/src/main/res/values/colors.xml b/Shrine/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..b97c64b8 --- /dev/null +++ b/Shrine/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF3700B3 + diff --git a/Shrine/app/src/main/res/values/dimens.xml b/Shrine/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..fdb9ce1c --- /dev/null +++ b/Shrine/app/src/main/res/values/dimens.xml @@ -0,0 +1,14 @@ + + 4dp + 8dp + 12dp + 16dp + 20dp + 24dp + 32dp + 40dp + 52dp + 64dp + 172dp + 30dp + diff --git a/Shrine/app/src/main/res/values/strings.xml b/Shrine/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..26e1ee6f --- /dev/null +++ b/Shrine/app/src/main/res/values/strings.xml @@ -0,0 +1,106 @@ + + Account + Shrine + Auth + Back + Dusty Pink Satchel + Change + Contact Us + Create Account + Create a passkey + High Tea Dish Set + abc@example.com + Full name + Get Help + Home + How passkeys work + Hopscotch Jacket + OK Glow Lamp + Learn More + Passwordless login with passkeys + What are passkeys? + Passkeys are encrypted digital keys you create using your fingerprint, face, or screen lock + Where are passkeys saved? + They are saved to a password manager so you can login on other devices + Are they better than passwords? + Yes, passkeys are more secure than passwords and provide a simpler login where you do not have to remember complex passwords + Main Menu + Not now + Password + With passkeys, you don’t need to remember complex passwords. Instead, you can use your fingerprint, face, or screen lock to sign in. + Create a passkey for faster, easier sign-in + Register + Security + Settings + Shop Shrine + Sign In + Signing In + A passkey is a faster and safer way to sign in than a + password. Your account is created with one unless you choose another option. + Sign Out + Sign Up + TODO + Username + Some error occurred, please check logs! + + Oops, An internal server error occurred. + Passkey created. + Passkey created. Try signin with passkeys + Email Icon + Email address + Password Icon + Passkey Icon + Wait, signing you in. + Enter valid username and password + Enter valid username and display name + shrine app menu mockup + large shrine logo + Demo + passkey logo + Heading Text + Last changed April 13, 2023 + Password created and saved + https://www.youtube.com/watch?v=2xdV-xut7EQ + This device is running on Android O or lower versions + Google Play Services are disabled for this device + The version of Google PLay on this device does not meet the minimum requirement + This device is not secure. Please add a screen lock first to continue + Dismiss + Error + Nav Host Route + Other ways to sign in + Other ways to sign up + Enter valid username + [{ + \"include\": \"https://project-sesame-426206.appspot.com/.well-known/assetlinks.json\" + }] + + Manage + Sign in faster next time + Icon passkeys + Passkeys + "You can sign in securely with your passkey using your fingerprint, face, or other screen lock method. " + Last changed: %1$s + Passkey Management + With passkeys, you can quickly and securely sign in using your fingerprint, face, or other screen-lock method. + Learn more about passkeys + Credential Provider Logo + Created: %1$s + Some error occurred while getting the list of passkeys. Please log out and try again. + Some error occurred while getting the list of aaguids. + Delete + Back Arrow on Toolbar + Sign up with password + Other options + Register with password + Passkey deleted successfully. + Error in deleting passkey, please check logs + Cannot call deletePasskey + Server error when attempting sign in with Google. Please check logs. + Sign in with Google + A network error occurred. Please check your connection and try again. + This username is already taken. Please choose another one. + A server error occurred. + An unknown error occurred. + Invalid credentials. Please check your username and password. + diff --git a/Shrine/app/src/main/res/values/themes.xml b/Shrine/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..4886ded6 --- /dev/null +++ b/Shrine/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + +