diff --git a/Shrine/app/build.gradle.kts b/Shrine/app/build.gradle.kts index 87445a23..09553c10 100644 --- a/Shrine/app/build.gradle.kts +++ b/Shrine/app/build.gradle.kts @@ -21,6 +21,7 @@ plugins { alias(libs.plugins.kotlin.android) id("dagger.hilt.android.plugin") alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.compose) } repositories { @@ -87,10 +88,6 @@ android { buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.2" - } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/Shrine/build.gradle.kts b/Shrine/build.gradle.kts index ec3e5380..6957f63f 100644 --- a/Shrine/build.gradle.kts +++ b/Shrine/build.gradle.kts @@ -21,6 +21,7 @@ plugins { alias(libs.plugins.spotless) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.compose) apply false } tasks.register("clean", Delete::class) { diff --git a/Shrine/gradle/libs.versions.toml b/Shrine/gradle/libs.versions.toml index d13a56d5..b641ecfa 100644 --- a/Shrine/gradle/libs.versions.toml +++ b/Shrine/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] activity = "1.9.0" androidxAppcompat = "1.7.0" -androidPlugin = "8.11.1" +androidPlugin = "8.12.1" browser = "1.8.0" coil = "2.7.0" coilSvg = "2.6.0" @@ -11,15 +11,15 @@ datastorePrefs = "1.1.1" googleFonts = "1.6.8" googleServicesPlugin = "4.4.1" googleid = "1.1.1" -hilt = "2.51" +hilt = "2.57.1" hiltCompiler = "1.2.0" hiltNavigationCompose = "1.2.0" -horologist = "0.6.23" -kotlin = "1.9.0" +horologist = "0.7.15" +kotlin = "2.2.0" kotlinCoroutines = "1.0" kotlinxCoroutines = "1.9.0" kotlinxSerialization = "1.7.3" -ksp = "1.9.0-1.0.13" +ksp = "2.2.0-2.0.2" ktlint = "0.50.0" ktx = "1.13.1" lifecycle = "2.7.0" diff --git a/Shrine/wear/build.gradle.kts b/Shrine/wear/build.gradle.kts index e574af91..b97873cb 100644 --- a/Shrine/wear/build.gradle.kts +++ b/Shrine/wear/build.gradle.kts @@ -2,34 +2,28 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) -} - -configurations.all { - resolutionStrategy { - // Force kotlin dependencies to use the versions needed for Wear so that I don't need to - // modify mobile dependency versions in this change. - // TODO(johnzoeller): Update mobile dependencies and remove this - force("org.jetbrains.kotlin:kotlin-stdlib:1.9.0") - force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0") - force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0") - force("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") - force("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - } + alias(libs.plugins.kotlin.compose) } android { namespace = "com.authentication.shrinewear" - compileSdk = 35 + compileSdk = 36 defaultConfig { - applicationId = "com.authentication.shrinewear" + applicationId = "com.authentication.shrine" minSdk = 30 - targetSdk = 35 + targetSdk = 36 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "API_BASE_URL", "\"https://project-sesame-426206.appspot.com\"") + buildConfigField( + "String", "API_BASE_URL", + "\"https://project-sesame-426206.appspot.com\"" + ) + buildConfigField( + "String", "GOOGLE_SIGN_IN_SERVER_CLIENT_ID", + "\"PASTE_YOUR_SERVER_CLIENT_ID_HERE\"" + ) } signingConfigs { @@ -51,16 +45,16 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } } buildFeatures { compose = true buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.2" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -97,8 +91,8 @@ android { implementation(libs.kotlinx.serialization.json) // New // Wear Horologist SIWG composables - implementation(libs.horologist.auth.ui) // New - implementation(libs.horologist.compose.layout) // New + implementation(libs.horologist.auth.ui) + implementation(libs.horologist.compose.layout) // GMS implementation(libs.google.id) // NO CHANGE diff --git a/Shrine/wear/src/main/AndroidManifest.xml b/Shrine/wear/src/main/AndroidManifest.xml index 3fed36e8..efa2ecf4 100644 --- a/Shrine/wear/src/main/AndroidManifest.xml +++ b/Shrine/wear/src/main/AndroidManifest.xml @@ -6,7 +6,8 @@ diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/Graph.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/Graph.kt index db946949..cda6fb98 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/Graph.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/Graph.kt @@ -18,6 +18,23 @@ package com.authentication.shrinewear import android.content.Context import com.authentication.shrinewear.authenticator.AuthenticationServer import com.authentication.shrinewear.authenticator.CredentialManagerAuthenticator +import com.authentication.shrinewear.network.AuthNetworkClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + + +/** + * Represents the possible authentication states of the application. + */ +enum class AuthenticationState { + LOGGED_OUT, + LOGGED_IN, + DISMISSED_BY_USER, + MISSING_CREDENTIALS, + FAILED, + UNKNOWN_ERROR, +} /** * A simple, manual dependency injection container for application-wide singletons. @@ -29,6 +46,14 @@ import com.authentication.shrinewear.authenticator.CredentialManagerAuthenticato * to initialize its dependencies. */ object Graph { + + private val authNetworkClient: AuthNetworkClient by lazy { + AuthNetworkClient() + } + val authenticationServer: AuthenticationServer by lazy { + AuthenticationServer(authNetworkClient) + } + /** * The authenticated instance of [CredentialManagerAuthenticator]. * This property is initialized once via the [provide] method and @@ -39,16 +64,13 @@ object Graph { */ lateinit var credentialManagerAuthenticator: CredentialManagerAuthenticator private set - lateinit var authenticationServer: AuthenticationServer - private set + + private val _authenticationState = MutableStateFlow(AuthenticationState.LOGGED_OUT) /** - * Stores the current authentication status code as an Android string resource ID. - * This can be used to reflect the authentication state across different parts of the UI. - * - * Defaults to [R.string.credman_status_logged_out]. + * Stores the current authentication status code. Defaults to [AuthenticationState.LOGGED_OUT]. */ - var authenticationStatusCode: Int = R.string.credman_status_logged_out + val authenticationState: StateFlow = _authenticationState.asStateFlow() /** * Provides and initializes the core dependencies for the application's [Graph]. @@ -59,7 +81,12 @@ object Graph { * @param context The application [Context] required to initialize services like [CredentialManagerAuthenticator]. */ fun provide(context: Context) { - credentialManagerAuthenticator = CredentialManagerAuthenticator(context) - authenticationServer = AuthenticationServer() + credentialManagerAuthenticator = CredentialManagerAuthenticator( + context, + authenticationServer) + } + + fun updateAuthenticationState(newState: AuthenticationState) { + _authenticationState.value = newState } } diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/AuthenticationServer.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/AuthenticationServer.kt index 14142d00..3c5eed04 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/AuthenticationServer.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/AuthenticationServer.kt @@ -15,45 +15,13 @@ */ package com.authentication.shrinewear.authenticator -import android.os.Build -import android.util.Base64 -import android.util.JsonReader -import android.util.JsonToken.STRING -import android.util.JsonWriter +import android.os.Bundle import android.util.Log -import androidx.credentials.CustomCredential -import androidx.credentials.PasswordCredential -import androidx.credentials.PublicKeyCredential -import com.authentication.shrinewear.BuildConfig -import com.authentication.shrinewear.api.AddHeaderInterceptor -import com.authentication.shrinewear.api.ApiException -import com.authentication.shrinewear.api.ApiResult -import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType.PUBLIC_KEY +import com.authentication.shrinewear.AuthenticationState +import com.authentication.shrinewear.Graph +import com.authentication.shrinewear.network.AuthNetworkClient +import com.authentication.shrinewear.network.NetworkResult import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.suspendCancellableCoroutine -import okhttp3.Call -import okhttp3.Callback -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request.Builder -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.logging.HttpLoggingInterceptor -import org.json.JSONArray -import org.json.JSONObject -import java.io.IOException -import java.io.StringReader -import java.io.StringWriter -import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -// Todo(reader): Add your server client ID here. -const val SERVER_CLIENT_ID = "" /** * Manages all client-side interactions with the authentication backend server. @@ -61,14 +29,11 @@ const val SERVER_CLIENT_ID = "" * This class serves as the primary interface for authenticating users using various credential types, * including Passkeys (WebAuthn), traditional username/password, and Google ID Tokens. It handles * sending credential data to the server, managing the session ID received from successful authentication, - * and parsing server responses into an [ApiResult] for consumption by higher-level logic. + * and parsing server responses into an [NetworkResult] for consumption by higher-level logic. * - * It utilizes [OkHttpClient] for network operations and includes - * helper methods for JSON request/response processing and error handling. + * Includes helper methods for JSON request/response processing and error handling. */ -class AuthenticationServer { - private val signedInState = MutableStateFlow(false) - private val httpClient: OkHttpClient +class AuthenticationServer(private val authNetworkClient: AuthNetworkClient) { private var sessionId: String? = null companion object { @@ -76,43 +41,6 @@ class AuthenticationServer { * The tag for logging. */ private const val TAG = "AuthApi" - - /** - * The base URL of the authentication server. - */ - private const val BASE_URL = BuildConfig.API_BASE_URL - - /** - * The media type for JSON requests. - */ - private val JSON = "application/json".toMediaTypeOrNull() - - /** - * The key used for the session ID cookie. - */ - private const val SESSION_ID_KEY = "SESAME_SESSION_COOKIE=" - - // String constants for JSON objects - private const val USERNAME = "username" - private const val PASSWORD = "password" - } - - init { - val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} " + - "(Android ${Build.VERSION.RELEASE}; ${Build.MODEL}; ${Build.BRAND})" - httpClient = OkHttpClient.Builder() - .addInterceptor(AddHeaderInterceptor(userAgent)) - .addInterceptor( - HttpLoggingInterceptor { message -> - println("LOG-APP: $message") - }.apply { - level = HttpLoggingInterceptor.Level.BODY - }, - ) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(40, TimeUnit.SECONDS) - .connectTimeout(40, TimeUnit.SECONDS) - .build() } /** @@ -125,52 +53,35 @@ class AuthenticationServer { * @return A JSON string containing the public key request options, or an empty string * if the server indicates a sign-out state or an error occurs during retrieval. */ - suspend fun getPublicKeyRequestOptions(): String { - return when (val publicKeyRequestOptions = retrievePublicKeyRequestOptions()) { - is ApiResult.Success -> { + internal suspend fun getPublicKeyRequestOptions(): String { + return when (val publicKeyRequestOptions = + authNetworkClient.fetchPublicKeyRequestOptions()) { + is NetworkResult.Success -> { publicKeyRequestOptions.sessionId?.let { newSessionId -> sessionId = newSessionId } publicKeyRequestOptions.data.toString() } - is ApiResult.SignedOutFromServer -> { + is NetworkResult.SignedOutFromServer -> { signOut() "" } } } - /** - * Retrieves passkeys public key request options from the auth server, if they exist. - * - * @return An [ApiResult] containing the public key - * credential request options, or an error if the API call fails. - */ - private suspend fun retrievePublicKeyRequestOptions(): ApiResult { - val httpResponse = httpClient.newCall( - okhttp3.Request.Builder().url( - buildString { append("$BASE_URL/webauthn/signinRequest") }, - ).method("POST", createJSONRequestBody {}).build(), - ).await() - - return httpResponse.result(errorMessage = "Error in SignIn with Passkeys Request") { - parsePublicKeyCredentialRequestOptions( - body ?: throw ApiException(message = "Empty response from signInRequest API"), - ) - } - } - /** * Attempts to log in a user by verifying a passkey credential with the server. * - * @param publicKeyCredential The credential object from the Android Credential Manager containing + * @param passkeyResponseJSON The passkey from the Android Credential Manager containing * the signed authentication challenge. * @return `true` on successful login and session update, `false` on failure. */ - suspend fun loginWithPasskey(publicKeyCredential: PublicKeyCredential): Boolean { - return when (val authorizationResult = authorizePasskeyWithServer(publicKeyCredential)) { - is ApiResult.Success -> { + internal suspend fun loginWithPasskey(passkeyResponseJSON: String): Boolean { + return when (val authorizationResult = authNetworkClient.authorizePasskeyWithServer( + passkeyResponseJSON, sessionId + )) { + is NetworkResult.Success -> { authorizationResult.sessionId?.let { newSessionId -> sessionId = newSessionId return true @@ -179,7 +90,7 @@ class AuthenticationServer { false } - is ApiResult.SignedOutFromServer -> { + is NetworkResult.SignedOutFromServer -> { signOut() Log.e(TAG, "Passkey authorization failed on server") false @@ -187,63 +98,24 @@ class AuthenticationServer { } } - /** - * Sends a public key credential to the authentication server for sign-in. - * - * @param publicKeyCredential: Passkey to be authorized by server - * @return An [ApiResult] indicating the success or failure of the operation. - */ - private suspend fun authorizePasskeyWithServer( - publicKeyCredential: PublicKeyCredential, - ): ApiResult { - val currentSessionId = sessionId ?: throw IllegalStateException( - "Requested Passkey was not provided with a valid session.", - ) - - val signInResponseJSON = JSONObject(publicKeyCredential.authenticationResponseJson) - val response = signInResponseJSON.getJSONObject("response") - val credentialId = signInResponseJSON.getString("rawId") - val httpResponse = httpClient.newCall( - okhttp3.Request.Builder().url("$BASE_URL/webauthn/signinResponse") - .addHeader("Cookie", formatCookie(currentSessionId)) - .method( - "POST", - createJSONRequestBody { - name("id").value(credentialId) - name("type").value(PUBLIC_KEY.toString()) - name("rawId").value(credentialId) - name("response").objectValue { - name("clientDataJSON").value(response.getString("clientDataJSON")) - name("authenticatorData").value(response.getString("authenticatorData")) - name("signature").value(response.getString("signature")) - name("userHandle").value(response.getString("userHandle")) - } - }, - ).build(), - ).await() - - return httpResponse.result(errorMessage = "Error in SignIn Response") { } - } - /** * Attempts to log in a user with the provided username and password. * * This function handles the full server authentication flow. It updates the local * session data on success and clears it on failure. * - * @param passwordCredential The object containing the user's ID and password. * @return `true` on successful login, `false` on failure. */ - suspend fun loginWithPassword(passwordCredential: PasswordCredential): Boolean { + internal suspend fun loginWithPassword(username: String, password: String): Boolean { val usernameSessionId = - when (val result = authorizeUsernameWithServer(passwordCredential.id)) { - is ApiResult.Success -> { + when (val result = authNetworkClient.authorizeUsernameWithServer(username)) { + is NetworkResult.Success -> { result.sessionId } - is ApiResult.SignedOutFromServer -> { + is NetworkResult.SignedOutFromServer -> { signOut() - Log.e(TAG, "Username ${passwordCredential.id} not found in server") + Log.e(TAG, "Username ${username} not found in server") return false } } @@ -254,83 +126,37 @@ class AuthenticationServer { return false } - return when ( - val result = - authorizePasswordWithServer(usernameSessionId, passwordCredential.password) - ) { - is ApiResult.Success -> { + return when (val result = + authNetworkClient.authorizePasswordWithServer(usernameSessionId, password)) { + is NetworkResult.Success -> { result.sessionId?.let { passwordSessionId -> sessionId = passwordSessionId } true } - is ApiResult.SignedOutFromServer -> { + is NetworkResult.SignedOutFromServer -> { signOut() - Log.e(TAG, "Password: ${passwordCredential.password} incorrect") + Log.e(TAG, "Password: ${password} incorrect") sessionId = null false } } } - /** - * Sends a username to the authentication server. - * - * @param username The username to be used for sign-in. - * @return An [ApiResult] indicating the success or failure of the operation. - */ - private suspend fun authorizeUsernameWithServer(username: String): ApiResult { - val httpResponse = httpClient.newCall( - Builder().url("$BASE_URL/username").method( - "POST", - createJSONRequestBody { - name(USERNAME).value(username) - }, - ).build(), - ).await() - - return httpResponse.result(errorMessage = "Error setting username") { } - } - - /** - * Sends a password to the authentication server. - * - * @param sessionId The session ID received from `username()`. - * @param password A password. - * @return An [ApiResult] indicating the success or failure of the operation. - */ - private suspend fun authorizePasswordWithServer( - sessionId: String, - password: String, - ): ApiResult { - val httpResponse = httpClient.newCall( - Builder().url("$BASE_URL/password").addHeader("Cookie", formatCookie(sessionId)) - .method( - "POST", - createJSONRequestBody { - name(PASSWORD).value(password) - }, - ).build(), - ).await() - - return httpResponse.result(errorMessage = "Error setting password") { } - } - /** * Processes a custom credential, works with google id tokens and can be expanded as a router * to handle other federated identity credential types. * - * @param credential The custom credential received from the Credential Manager. * @return {@code true} if the credential was successfully authorized; {@code false} otherwise. */ - suspend fun loginWithCustomCredential(credential: CustomCredential): Boolean { + internal suspend fun loginWithCustomCredential(type: String, data: Bundle): Boolean { val federatedToken: String - if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - federatedToken = GoogleIdTokenCredential.createFrom(credential.data).idToken + if (type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + federatedToken = GoogleIdTokenCredential.createFrom(data).idToken } else { - Log.e(TAG, "Unrecognized custom credential: ${credential.type}") + Log.e(TAG, "Unrecognized custom credential: ${type}") return false } @@ -348,16 +174,16 @@ class AuthenticationServer { * @return `true` if both federated options retrieval and token authorization are successful; * `false` otherwise. Failures are typically logged within the function. */ - suspend fun loginWithFederatedToken(federatedToken: String): Boolean { + internal suspend fun loginWithFederatedToken(federatedToken: String): Boolean { val federatedSessionId: String? - when (val federationOptions = retrieveFederationOptions()) { - is ApiResult.Success -> { + when (val federationOptions = authNetworkClient.fetchFederationOptions()) { + is NetworkResult.Success -> { federatedSessionId = federationOptions.sessionId ?: throw IllegalStateException("Session ID was null in server response") } - is ApiResult.SignedOutFromServer -> { + is NetworkResult.SignedOutFromServer -> { signOut() Log.e(TAG, "Failed to get federation options from server: $federationOptions") return false @@ -365,13 +191,16 @@ class AuthenticationServer { } return when (val authorizationResult = - authorizeFederatedTokenWithServer(federatedToken, federatedSessionId)) { - is ApiResult.Success -> { + authNetworkClient.authorizeFederatedTokenWithServer( + federatedToken, + federatedSessionId + )) { + is NetworkResult.Success -> { this.sessionId = authorizationResult.sessionId return true } - is ApiResult.SignedOutFromServer -> { + is NetworkResult.SignedOutFromServer -> { signOut() Log.e(TAG, "Federated Sign in failed.") false @@ -379,275 +208,14 @@ class AuthenticationServer { } } - /** - * Fetches a session ID from the backend server to enable ID token validation. - * - * @return [ApiResult.Success] with the session ID, or an [ApiResult.Error]/[ApiResult.SignedOutFromServer] on failure. - */ - private suspend fun retrieveFederationOptions(): ApiResult { - val httpResponse = httpClient.newCall( - Builder().url("$BASE_URL/federation/options").method( - "POST", - createJSONRequestBody { - name("urls").beginArray().value("https://accounts.google.com").endArray() - }).build(), - ).await() - - return httpResponse.result(errorMessage = "Error creating federation options") {} - } - - /** - * Authorizes a federated identity token with the backend server. - * - * This function sends a POST request to the server's `/federation/verifyIdToken` endpoint, - * passing the federated ID token and desired accounts URLs for verification. - * - * @param token The ID token obtained from the Sign-In. - * @return [ApiResult]<[Unit]> indicating the success or failure of the authorization. - * A [Unit] type for success implies no specific data is returned on successful authorization. - */ - private suspend fun authorizeFederatedTokenWithServer( - token: String, sessionId: String - ): ApiResult { - val requestHeaders = okhttp3.Headers.Builder().add( - "Cookie", "$SESSION_ID_KEY$sessionId" - ).build() - - val httpResponse = httpClient.newCall( - Builder().url("$BASE_URL/federation/verifyIdToken") - .headers(requestHeaders) - .method( - "POST", - createJSONRequestBody { - name("token").value(token) - name("url").value("https://accounts.google.com") - }, - ).build(), - ).await() - - return httpResponse.result(errorMessage = "Error signing in with the federated token") { } - } - /** * Signs out the current user by updating the `signedInState`. * * This function sets the internal `signedInState` to `false`, which should trigger * UI updates or other logic dependent on the user's sign-in status. */ - fun signOut() { - signedInState.update { false } + internal fun signOut() { + Graph.updateAuthenticationState(AuthenticationState.LOGGED_OUT) sessionId = null } - - /** - * Parses a public key credential request options object from a JSON response. - * - * @param responseBody The JSON response body. - * @return A [JSONObject] containing the parsed public key credential request options. - */ - private fun parsePublicKeyCredentialRequestOptions( - responseBody: ResponseBody, - ): JSONObject { - val credentialRequestOptions = JSONObject() - JsonReader(responseBody.byteStream().bufferedReader()).use { jsonReader -> - jsonReader.beginObject() - while (jsonReader.hasNext()) { - when (jsonReader.nextName()) { - "challenge" -> credentialRequestOptions.put( - "challenge", - jsonReader.nextString(), - ) - - "userVerification" -> jsonReader.skipValue() - "allowCredentials" -> credentialRequestOptions.put( - "allowCredentials", - parseCredentialDescriptors(jsonReader), - ) - - "rpId" -> credentialRequestOptions.put("rpId", jsonReader.nextString()) - "timeout" -> credentialRequestOptions.put("timeout", jsonReader.nextDouble()) - } - } - jsonReader.endObject() - } - return credentialRequestOptions - } - - /** - * Parses the `credentialDescriptors` array from the JSON response. - * - * @param jsonReader The JSON reader. - * @return A [JSONArray] containing the parsed `credentialDescriptors`. - */ - private fun parseCredentialDescriptors( - jsonReader: JsonReader, - ): JSONArray { - val jsonArray = JSONArray() - jsonReader.beginArray() - while (jsonReader.hasNext()) { - val jsonObject = JSONObject() - jsonReader.beginObject() - while (jsonReader.hasNext()) { - when (jsonReader.nextName()) { - "id" -> jsonObject.put("id", b64Decode(jsonReader.nextString())) - "type" -> jsonReader.skipValue() - "transports" -> jsonReader.skipValue() - else -> jsonReader.skipValue() - } - } - jsonReader.endObject() - if (jsonObject.length() != 0) { - jsonArray.put(jsonObject) - } - } - jsonReader.endArray() - return jsonArray - } - - /** - * Creates a JSON request body from the given lambda expression. - * - * @param body A lambda expression that writes to the [JsonWriter]. - * @return A [RequestBody] containing the JSON data. - */ - private fun createJSONRequestBody(body: JsonWriter.() -> Unit): RequestBody { - val output = StringWriter() - JsonWriter(output).use { writer -> - writer.beginObject() - writer.body() - writer.endObject() - } - return output.toString().toRequestBody(JSON) - } - - private fun b64Decode(str: String): ByteArray { - return Base64.decode(str, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE) - } - - /** - * Formats the session ID into a cookie string. - * - * @param sessionId The session ID. - * @return The cookie string. - */ - private fun formatCookie(sessionId: String): String { - return "$SESSION_ID_KEY$sessionId" - } - - /** - * Extension function for [Response] to convert it to an [ApiResult]. - * - * @param errorMessage The error message to use if the response is not successful. - * @param data A lambda expression that extracts the data from the response. - * @return An [ApiResult] containing the data or an error. - */ - private fun Response.result(errorMessage: String, data: Response.() -> T): ApiResult { - if (!isSuccessful) { - if (code == 401) { // Unauthorized - return ApiResult.SignedOutFromServer - } - // All other errors throw an exception. - throwResponseError(this, errorMessage) - } - val cookie = headers("set-cookie").find { it.startsWith(SESSION_ID_KEY) } - val sessionId = if (cookie != null) parseSessionId(cookie) else null - return ApiResult.Success(sessionId, data()) - } - - /** - * Parses the session ID from the given cookie. - * - * @param cookie The cookie string. - * @return The session ID. - */ - private fun parseSessionId(cookie: String): String { - val start = cookie.indexOf(SESSION_ID_KEY) - if (start < 0) { - throw ApiException("Cannot find $SESSION_ID_KEY") - } - 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) - } - - /** - * Parses the error message from the given response body. - * - * @param body The response body. - * @return The error message, or an empty string if it cannot be parsed. - */ - private fun parseError(body: ResponseBody): String { - val errorString = body.string() - try { - JsonReader(StringReader(errorString)).use { reader -> - reader.beginObject() - while (reader.hasNext()) { - val name = reader.nextName() - if (name == "error") { - val token = reader.peek() - if (token == STRING) { - return reader.nextString() - } - return "Unknown" - } else { - reader.skipValue() - } - } - reader.endObject() - } - } catch (e: Exception) { - Log.e(TAG, "Cannot parse the error: $errorString", e) - // Don't throw; this method is called during throwing. - } - return "" - } - - /** - * Throws an [ApiException] based on the given response and message. - * - * @param response The response object. - * @param message The error message. - */ - private fun throwResponseError(response: Response, message: String): Nothing { - val responseBody = response.body - if (responseBody != null) { - throw ApiException("$message; ${parseError(responseBody)}") - } else { - throw ApiException(message) - } - } - - /** - * Extension function for [JsonWriter] to write an object value. - * - * @param body A lambda expression that writes to the [JsonWriter]. - */ - private fun JsonWriter.objectValue(body: JsonWriter.() -> Unit) { - beginObject() - body() - endObject() - } - - private suspend fun Call.await(): Response { - return suspendCancellableCoroutine { continuation -> - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - continuation.resume(response) - } - - override fun onFailure(call: Call, e: IOException) { - if (continuation.isCancelled) return - continuation.resumeWithException(e) - } - }) - - continuation.invokeOnCancellation { - try { - cancel() - } catch (ex: Throwable) { - Log.w(TAG, "Exception thrown while trying to cancel a Call", ex) - } - } - } - } } diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/CredentialManagerAuthenticator.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/CredentialManagerAuthenticator.kt index e26398c6..dfd3f0b9 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/CredentialManagerAuthenticator.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/authenticator/CredentialManagerAuthenticator.kt @@ -19,6 +19,7 @@ import android.app.Activity import android.content.Context import android.util.Log import androidx.activity.ComponentActivity +import androidx.credentials.Credential import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest @@ -29,6 +30,7 @@ import androidx.credentials.PasswordCredential import androidx.credentials.PublicKeyCredential import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import com.authentication.shrinewear.BuildConfig import com.authentication.shrinewear.extensions.awaitState import com.google.android.libraries.identity.googleid.GetGoogleIdOption @@ -39,9 +41,12 @@ import com.google.android.libraries.identity.googleid.GetGoogleIdOption * using Passkeys, Passwords, and Sign-In With Google credentials. * * @param context The Android [Context] used to create the [CredentialManager]. + * @param authenticationServer The [AuthenticationServer] responsible for handling authentication requests. */ -class CredentialManagerAuthenticator(applicationContext: Context) { - private val authenticationServer = AuthenticationServer() +class CredentialManagerAuthenticator( + applicationContext: Context, + private val authenticationServer: AuthenticationServer +) { private val credentialManager: CredentialManager = CredentialManager.create(applicationContext) /** @@ -50,14 +55,14 @@ class CredentialManagerAuthenticator(applicationContext: Context) { * @param activity The [Context] (preferably a [ComponentActivity]) used for the `getCredential` call. * @return `true` if credential manager authenticated the user, else `false`. */ - suspend fun signInWithCredentialManager(activity: Activity): Boolean { - val getCredentialResponse = + internal suspend fun signInWithCredentialManager(activity: Activity): Boolean { + val getCredentialResponse: GetCredentialResponse = credentialManager.getCredential(activity, createGetCredentialRequest()) // DANGER: Do not call your auth server until the activity has resumed. (activity as? LifecycleOwner)?.lifecycle?.awaitState(Lifecycle.State.RESUMED) - return authenticate(getCredentialResponse) + return authenticate(getCredentialResponse.credential) } /**signInWithPasskeysRequest @@ -70,32 +75,36 @@ class CredentialManagerAuthenticator(applicationContext: Context) { credentialOptions = listOf( GetPublicKeyCredentialOption(authenticationServer.getPublicKeyRequestOptions()), GetPasswordOption(), - GetGoogleIdOption.Builder().setServerClientId(SERVER_CLIENT_ID).build(), + GetGoogleIdOption.Builder().setServerClientId(BuildConfig.GOOGLE_SIGN_IN_SERVER_CLIENT_ID).build(), ), ) } /** - * Processes the [GetCredentialResponse] received from [CredentialManager.getCredential]. + * Routes the credential received from `getCredential` to the appropriate authentication + * type handler on the [AuthenticationServer]. * - * Dispatches the credential to the appropriate authentication type handler on the - * [AuthenticationServer]. - * - * @param getCredentialResponse The response object from the Credential Manager. + * @param credential The selected cre * @return `true` if the credential was successfully processed and authenticated, else 'false'. */ - private suspend fun authenticate(getCredentialResponse: GetCredentialResponse): Boolean { - when (val credential = getCredentialResponse.credential) { + private suspend fun authenticate(credential: Credential): Boolean { + when (credential) { is PublicKeyCredential -> { - return authenticationServer.loginWithPasskey(credential) + return authenticationServer.loginWithPasskey(credential.authenticationResponseJson) } is PasswordCredential -> { - return authenticationServer.loginWithPassword(credential) + return authenticationServer.loginWithPassword( + credential.id, + credential.password + ) } is CustomCredential -> { - return authenticationServer.loginWithCustomCredential(credential) + return authenticationServer.loginWithCustomCredential( + credential.type, + credential.data + ) } else -> { diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/extensions/OkHttpExtensions.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/extensions/OkHttpExtensions.kt new file mode 100644 index 00000000..f0223f63 --- /dev/null +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/extensions/OkHttpExtensions.kt @@ -0,0 +1,128 @@ +package com.authentication.shrinewear.extensions + +import android.util.JsonReader +import android.util.JsonToken.STRING +import android.util.Log +import com.authentication.shrinewear.network.AuthNetworkClient.Companion.SESSION_ID_KEY +import com.authentication.shrinewear.network.NetworkException +import com.authentication.shrinewear.network.NetworkResult +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import okhttp3.ResponseBody +import java.io.IOException +import java.io.StringReader +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val TAG = "OkHttpExtension" + +suspend fun Call.await(): Response { + return suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + + override fun onFailure(call: Call, e: IOException) { + if (continuation.isCancelled) return + continuation.resumeWithException(e) + } + }) + + continuation.invokeOnCancellation { + try { + cancel() + } catch (ex: Throwable) { + Log.w(TAG, "Exception thrown while trying to cancel a Call", ex) + } + } + } +} + +/** + * Extension function for [Response] to convert it to an [NetworkResult]. + * + * @param errorMessage The error message to use if the response is not successful. + * @param data A lambda expression that extracts the data from the response. + * @return An [NetworkResult] containing the data or an error. + */ +fun Response.result( + errorMessage: String, + data: Response.() -> T +): NetworkResult { + if (!isSuccessful) { + if (code == 401) { // Unauthorized + return NetworkResult.SignedOutFromServer + } + // All other errors throw an exception. + throwResponseError(this, errorMessage) + } + val cookie = headers("set-cookie").find { it.startsWith(SESSION_ID_KEY) } + val sessionId = if (cookie != null) parseSessionId(cookie) else null + return NetworkResult.Success(sessionId, data()) +} + +/** + * Throws an [NetworkException] based on the given response and message. + * + * @param response The response object. + * @param message The error message. + */ +private fun throwResponseError(response: Response, message: String): Nothing { + val responseBody = response.body + if (responseBody != null) { + throw NetworkException("$message; ${parseError(responseBody)}") + } else { + throw NetworkException(message) + } +} + +/** + * Parses the error message from the given response body. + * + * @param body The response body. + * @return The error message, or an empty string if it cannot be parsed. + */ +private fun parseError(body: ResponseBody): String { + val errorString = body.string() + try { + JsonReader(StringReader(errorString)).use { reader -> + reader.beginObject() + while (reader.hasNext()) { + val name = reader.nextName() + if (name == "error") { + val token = reader.peek() + if (token == STRING) { + return reader.nextString() + } + return "Unknown" + } else { + reader.skipValue() + } + } + reader.endObject() + } + } catch (e: Exception) { + Log.e(TAG, "Cannot parse the error: $errorString", e) + // Don't throw; this method is called during throwing. + } + return "" +} + +/** + * Parses the session ID from the given cookie. + * + * @param cookie The cookie string. + * @return The session ID. + */ +private fun parseSessionId(cookie: String): String { + val start = cookie.indexOf(SESSION_ID_KEY) + if (start < 0) { + throw NetworkException("Cannot find $SESSION_ID_KEY") + } + 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) +} \ No newline at end of file diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/extensions/PasskeyJsonHelpers.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/extensions/PasskeyJsonHelpers.kt new file mode 100644 index 00000000..98d36d59 --- /dev/null +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/extensions/PasskeyJsonHelpers.kt @@ -0,0 +1,136 @@ +package com.authentication.shrinewear.extensions + +import android.util.Base64 +import android.util.JsonReader +import android.util.JsonWriter +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType.PUBLIC_KEY +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.StringWriter + +/** + * Extension function for [JsonWriter] to write an object value. + * + * @param body A lambda expression that writes to the [JsonWriter]. + */ +internal fun JsonWriter.writeObject(body: JsonWriter.() -> Unit) { + beginObject() + body() + endObject() +} + +/** + * Creates a JSON request body from the given lambda expression. + * + * @param body A lambda expression that writes to the [JsonWriter]. + * @return A [RequestBody] containing the JSON data. + */ +internal fun createJSONRequestBody(body: JsonWriter.() -> Unit): RequestBody { + val output = StringWriter() + JsonWriter(output).use { writer -> + writer.writeObject(body) + } + return output.toString().toRequestBody("application/json".toMediaTypeOrNull()) +} + +/** + * Parses a public key credential request options object from a JSON response. + * + * @param responseBody The JSON response body. + * @return A [JSONObject] containing the parsed public key credential request options. + */ +internal fun parsePublicKeyCredentialRequestOptions( + responseBody: ResponseBody, +): JSONObject { + val credentialRequestOptions = JSONObject() + JsonReader(responseBody.byteStream().bufferedReader()).use { jsonReader -> + jsonReader.beginObject() + while (jsonReader.hasNext()) { + when (jsonReader.nextName()) { + "challenge" -> credentialRequestOptions.put( + "challenge", + jsonReader.nextString(), + ) + + "userVerification" -> jsonReader.skipValue() + "allowCredentials" -> credentialRequestOptions.put( + "allowCredentials", + parseCredentialDescriptors(jsonReader), + ) + + "rpId" -> credentialRequestOptions.put("rpId", jsonReader.nextString()) + "timeout" -> credentialRequestOptions.put("timeout", jsonReader.nextDouble()) + } + } + jsonReader.endObject() + } + return credentialRequestOptions +} + +/** + * Creates an OkHttp [RequestBody] for a passkey sign-in validation request. + * + * This function parses the raw JSON response from the Android Credential Manager + * and formats it into the specific JSON structure required by the server's + * WebAuthn sign-in endpoint. + * + * @param passkeyResponseJSON The raw passkey data as a JSON string from the + * Credential Manager API. + * @return A [RequestBody] containing the formatted JSON data for the server. + */ +internal fun createPasskeyValidationRequest(passkeyResponseJSON: String): RequestBody { + val signedPasskeyData = JSONObject(passkeyResponseJSON) + val response = signedPasskeyData.getJSONObject("response") + val credentialId = signedPasskeyData.getString("rawId") + + return createJSONRequestBody { + name("id").value(credentialId) + name("type").value(PUBLIC_KEY.toString()) + name("rawId").value(credentialId) + name("response").writeObject { + name("clientDataJSON").value(response.getString("clientDataJSON")) + name("authenticatorData").value(response.getString("authenticatorData")) + name("signature").value(response.getString("signature")) + name("userHandle").value(response.getString("userHandle")) + } + } +} + +/** + * Parses the `credentialDescriptors` array from the JSON response. + * + * @param jsonReader The JSON reader. + * @return A [JSONArray] containing the parsed `credentialDescriptors`. + */ +private fun parseCredentialDescriptors( + jsonReader: JsonReader, +): JSONArray { + val jsonArray = JSONArray() + jsonReader.beginArray() + while (jsonReader.hasNext()) { + val jsonObject = JSONObject() + jsonReader.beginObject() + while (jsonReader.hasNext()) { + when (jsonReader.nextName()) { + "id" -> jsonObject.put("id", b64Decode(jsonReader.nextString())) + "type" -> jsonReader.skipValue() + "transports" -> jsonReader.skipValue() + else -> jsonReader.skipValue() + } + } + jsonReader.endObject() + if (jsonObject.length() != 0) { + jsonArray.put(jsonObject) + } + } + jsonReader.endArray() + return jsonArray +} + +private fun b64Decode(str: String): ByteArray { + return Base64.decode(str, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE) +} \ No newline at end of file diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/network/AuthNetworkClient.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/AuthNetworkClient.kt new file mode 100644 index 00000000..3b1b27c2 --- /dev/null +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/AuthNetworkClient.kt @@ -0,0 +1,186 @@ +package com.authentication.shrinewear.network + +import android.os.Build +import com.authentication.shrinewear.BuildConfig +import com.authentication.shrinewear.extensions.await +import com.authentication.shrinewear.extensions.createJSONRequestBody +import com.authentication.shrinewear.extensions.createPasskeyValidationRequest +import com.authentication.shrinewear.extensions.parsePublicKeyCredentialRequestOptions +import com.authentication.shrinewear.extensions.result +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.logging.HttpLoggingInterceptor +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class AuthNetworkClient { + private val httpClient: OkHttpClient + + companion object { + /** + * The base URL of the authentication server. + */ + private const val BASE_URL = BuildConfig.API_BASE_URL + + /** + * The key used for the session ID cookie. + */ + internal const val SESSION_ID_KEY = "SESAME_SESSION_COOKIE=" + } + + init { + val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} " + + "(Android ${Build.VERSION.RELEASE}; ${Build.MODEL}; ${Build.BRAND})" + httpClient = OkHttpClient.Builder() + .addInterceptor(NetworkAddHeaderInterceptor(userAgent)) + .addInterceptor( + HttpLoggingInterceptor { message -> + println("LOG-APP: $message") + }.apply { + level = HttpLoggingInterceptor.Level.BODY + }, + ) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(40, TimeUnit.SECONDS) + .connectTimeout(40, TimeUnit.SECONDS) + .build() + } + + /** + * Retrieves passkeys public key request options from the auth server, if they exist. + * + * @return An [NetworkResult] containing the public key + * credential request options, or an error if the API call fails. + */ + internal suspend fun fetchPublicKeyRequestOptions(): NetworkResult { + val httpResponse = httpClient.newCall( + Request.Builder().url( + buildString { append("$BASE_URL/webauthn/signinRequest") }, + ).method("POST", createJSONRequestBody {}).build(), + ).await() + + return httpResponse.result(errorMessage = "Error in SignIn with Passkeys Request") { + parsePublicKeyCredentialRequestOptions( + body ?: throw NetworkException(message = "Empty response from signInRequest API"), + ) + } + } + + /** + * Sends a public key credential to the authentication server for sign-in. + * + * @param signedPasskeyData: Passkey data needed for server authentication + * @return An [NetworkResult] indicating the success or failure of the operation. + */ + internal suspend fun authorizePasskeyWithServer( + passkeyResponseJSON: String, + sessionId: String? = null, + ): NetworkResult { + val currentSessionId = sessionId + ?: throw IllegalStateException("Requested Passkey was not provided with a valid session.") + + val httpResponse = httpClient.newCall( + Request.Builder().url("${BASE_URL}/webauthn/signinResponse") + .addHeader("Cookie", "$SESSION_ID_KEY$currentSessionId") + .method( + "POST", + createPasskeyValidationRequest(passkeyResponseJSON), + ).build(), + ).await() + + return httpResponse.result(errorMessage = "Error in SignIn Response") { } + } + + /** + * Sends a username to the authentication server. + * + * @param username The username to be used for sign-in. + * @return An [NetworkResult] indicating the success or failure of the operation. + */ + internal suspend fun authorizeUsernameWithServer(username: String): NetworkResult { + val httpResponse = httpClient.newCall( + Request.Builder().url("${BASE_URL}/auth/username").method( + "POST", + createJSONRequestBody { + name("username").value(username) + }, + ).build(), + ).await() + + return httpResponse.result(errorMessage = "Error setting username") { } + } + + /** + * Sends a password to the authentication server. + * + * @param sessionId The session ID received from `username()`. + * @param password A password. + * @return An [NetworkResult] indicating the success or failure of the operation. + */ + internal suspend fun authorizePasswordWithServer( + sessionId: String, + password: String, + ): NetworkResult { + val httpResponse = httpClient.newCall( + Request.Builder().url("${BASE_URL}/auth/password") + .addHeader("Cookie", "$SESSION_ID_KEY$sessionId") + .method( + "POST", + createJSONRequestBody { + name("password").value(password) + }, + ).build(), + ).await() + + return httpResponse.result(errorMessage = "Error setting password") { } + } + + /** + * Fetches a session ID from the backend server to enable ID token validation. + * + * @return [NetworkResult.Success] with the session ID, or an [NetworkResult.Error]/[NetworkResult.SignedOutFromServer] on failure. + */ + internal suspend fun fetchFederationOptions(): NetworkResult { + val httpResponse = httpClient.newCall( + Request.Builder().url("${BASE_URL}/federation/options").method( + "POST", + createJSONRequestBody { + name("urls").beginArray().value("https://accounts.google.com").endArray() + }).build(), + ).await() + + return httpResponse.result(errorMessage = "Error creating federation options") {} + } + + /** + * Authorizes a federated identity token with the backend server. + * + * This function sends a POST request to the server's `/federation/verifyIdToken` endpoint, + * passing the federated ID token and desired accounts URLs for verification. + * + * @param token The ID token obtained from the Sign-In. + * @return [NetworkResult]<[Unit]> indicating the success or failure of the authorization. + * A [Unit] type for success implies no specific data is returned on successful authorization. + */ + internal suspend fun authorizeFederatedTokenWithServer( + token: String, sessionId: String + ): NetworkResult { + val requestHeaders = okhttp3.Headers.Builder().add( + "Cookie", "$SESSION_ID_KEY$sessionId" + ).build() + + val httpResponse = httpClient.newCall( + Request.Builder().url("${BASE_URL}/federation/verifyIdToken") + .headers(requestHeaders) + .method( + "POST", + createJSONRequestBody { + name("token").value(token) + name("url").value("https://accounts.google.com") + }, + ).build(), + ).await() + + return httpResponse.result(errorMessage = "Error signing in with the federated token") { } + } +} \ No newline at end of file diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/api/AddHeaderInterceptor.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkAddHeaderInterceptor.kt similarity index 89% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/api/AddHeaderInterceptor.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkAddHeaderInterceptor.kt index d640249d..0e6594a1 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/api/AddHeaderInterceptor.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkAddHeaderInterceptor.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.api +package com.authentication.shrinewear.network import okhttp3.Interceptor import okhttp3.Response @@ -23,7 +23,7 @@ import okhttp3.Response * * @param userAgent The user agent string to use. */ -internal class AddHeaderInterceptor(private val userAgent: String) : okhttp3.Interceptor { +internal class NetworkAddHeaderInterceptor(private val userAgent: String) : okhttp3.Interceptor { /** * Intercepts the request and adds the headers. diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/api/ApiException.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkException.kt similarity index 86% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/api/ApiException.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkException.kt index be8e4486..61617f54 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/api/ApiException.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkException.kt @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.api +package com.authentication.shrinewear.network /** * An exception class for API errors. * * @param message The error message. */ -class ApiException(message: String) : RuntimeException(message) +class NetworkException(message: String) : RuntimeException(message) diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/api/ApiResult.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkResult.kt similarity index 88% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/api/ApiResult.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkResult.kt index a956f3d0..c91d8af5 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/api/ApiResult.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/network/NetworkResult.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.api +package com.authentication.shrinewear.network /** * Represents the result of an API call. @@ -22,7 +22,7 @@ package com.authentication.shrinewear.api * - [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 { +sealed class NetworkResult { /** * API returned successfully with data. @@ -34,10 +34,10 @@ sealed class ApiResult { class Success( val sessionId: String?, val data: T, - ) : ApiResult() + ) : NetworkResult() /** * API returned unsuccessfully with code 401, and the user should be considered signed out. */ - data object SignedOutFromServer : ApiResult() + data object SignedOutFromServer : NetworkResult() } diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/HomeScreen.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/HomeScreen.kt deleted file mode 100644 index 3109b34d..00000000 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/HomeScreen.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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.shrinewear.ui - -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.wear.compose.material3.CircularProgressIndicator -import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices -import com.authentication.shrinewear.Graph -import com.authentication.shrinewear.R - -/** - * Composable function representing the home screen of the application. - * It handles the authentication flow based on the current authentication status. - * - * @param credentialManagerViewModel The {@link CredentialManagerViewModel} to interact with - * the Credential Manager. - * @param navigateToLegacyLogin Callback function to navigate to the legacy login screen. - * @param navigateToSignOut Callback function to navigate to the sign-out screen. - */ -@Composable -fun HomeScreen( - credentialManagerViewModel: CredentialManagerViewModel, - navigateToLegacyLogin: () -> Unit, - navigateToSignOut: () -> Unit, -) { - val uiState by credentialManagerViewModel.uiState.collectAsState() - val activity = LocalContext.current as ComponentActivity - - if (!uiState.inProgress) { - when (Graph.authenticationStatusCode) { - R.string.credman_status_logged_out -> { - credentialManagerViewModel.login(activity) - } - R.string.credman_status_dismissed, R.string.credman_status_no_credentials -> { - navigateToLegacyLogin() - } - R.string.credman_status_authorized -> { - navigateToSignOut() - } - else -> { - navigateToLegacyLogin() - } - } - } else { - CircularProgressIndicator(modifier = Modifier.fillMaxSize()) - } -} - -/** - * Preview function for the {@link HomeScreen} composable. - */ -@WearPreviewDevices -@Composable -fun HomeScreenPreview() { - HomeScreen( - credentialManagerViewModel = CredentialManagerViewModel(), - navigateToLegacyLogin = {}, - navigateToSignOut = {}, - ) -} diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineApp.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineApp.kt index cfb6ed26..03b7337e 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineApp.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineApp.kt @@ -16,18 +16,10 @@ package com.authentication.shrinewear.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.wear.compose.material3.AlertDialog -import androidx.wear.compose.material3.AlertDialogDefaults -import androidx.wear.compose.material3.Text import androidx.wear.compose.navigation.rememberSwipeDismissableNavController -import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices -import com.authentication.shrinewear.R +import com.authentication.shrinewear.ui.navigation.ShrineNavActions +import com.authentication.shrinewear.ui.navigation.ShrineNavGraph /** * The main entry point composable for the Shrine Wear OS application. @@ -40,48 +32,5 @@ fun ShrineApp() { val navController = rememberSwipeDismissableNavController() val navigationActions = remember(navController) { ShrineNavActions(navController) } - // Todo(johnzoeller): This is behaving unpredictably with Credential Manager UI. Fix before - // final PR. - DemoInstructions() ShrineNavGraph(navController = navController, navigationActions = navigationActions) -} - -/** - * Displays an [AlertDialog] containing introductory demo instructions for the user. - * - * This dialog is shown upon the initial launch of the application and can be dismissed - * by the user. - * - * Note: The `AlertDialog` API used here (`edgeButton` and `visible`) might be from an - * older or specific alpha version of `androidx.wear.compose.material3`. For newer - * versions, consider using `confirmButton` and `dismissButton` for actions, and - * conditionally rendering the dialog using an `if` statement. - */ -@Composable -fun DemoInstructions() { - var showDialog by remember { mutableStateOf(true) } - - AlertDialog( - visible = showDialog, - onDismissRequest = { showDialog = false }, - edgeButton = { AlertDialogDefaults.EdgeButton(onClick = { showDialog = false }) }, - title = { - Text( - text = stringResource(R.string.shrine_sample), - textAlign = TextAlign.Center, - ) - }, - text = { Text(stringResource(R.string.see_readme_md_for_usage_directions)) }, - ) -} - -/** - * Preview for the [DemoInstructions] composable. - * - * This preview renders the dialog with the demo instructions as it would appear on Wear OS devices. - */ -@WearPreviewDevices -@Composable -fun DemoInstructionsPreview() { - DemoInstructions() -} +} \ No newline at end of file diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineNavActions.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/navigation/ShrineNavActions.kt similarity index 98% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineNavActions.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/navigation/ShrineNavActions.kt index 34e8101d..9b01eb1c 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineNavActions.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/navigation/ShrineNavActions.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.navigation import androidx.navigation.NavHostController import androidx.navigation.NavOptionsBuilder diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineNavGraph.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/navigation/ShrineNavGraph.kt similarity index 87% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineNavGraph.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/navigation/ShrineNavGraph.kt index 4cf49a55..4fdb417b 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/ShrineNavGraph.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/navigation/ShrineNavGraph.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.navigation import android.app.Application import androidx.compose.runtime.Composable @@ -23,6 +23,13 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.authentication.shrinewear.ui.screens.HomeScreen +import com.authentication.shrinewear.ui.screens.LegacyLoginScreen +import com.authentication.shrinewear.ui.screens.OAuthScreen +import com.authentication.shrinewear.ui.screens.SignOutScreen +import com.authentication.shrinewear.ui.viewmodel.CredentialManagerViewModel +import com.authentication.shrinewear.ui.viewmodel.LegacySignInWithGoogleViewModelFactory +import com.authentication.shrinewear.ui.viewmodel.OAuthViewModel import com.google.android.horologist.auth.ui.googlesignin.signin.GoogleSignInScreen /** @@ -47,8 +54,8 @@ fun ShrineNavGraph( navController = navController, startDestination = startDestination, ) { - val credentialManagerViewModel = CredentialManagerViewModel() composable(ShrineDestinations.HOME_ROUTE) { + val credentialManagerViewModel: CredentialManagerViewModel = viewModel() HomeScreen( credentialManagerViewModel = credentialManagerViewModel, navigateToLegacyLogin = navigationActions.navigateToLegacyLogin, diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/HomeScreen.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/HomeScreen.kt new file mode 100644 index 00000000..d6c70718 --- /dev/null +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/HomeScreen.kt @@ -0,0 +1,145 @@ +/* + * 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.shrinewear.ui.screens + +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.AlertDialogDefaults +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.authentication.shrinewear.AuthenticationState +import com.authentication.shrinewear.Graph +import com.authentication.shrinewear.R +import com.authentication.shrinewear.ui.viewmodel.CredentialManagerViewModel + +/** + * Composable function representing the home screen of the application. + * It handles the authentication flow based on the current authentication status. + * + * @param credentialManagerViewModel The {@link CredentialManagerViewModel} to interact with + * the Credential Manager. + * @param navigateToLegacyLogin Callback function to navigate to the legacy login screen. + * @param navigateToSignOut Callback function to navigate to the sign-out screen. + */ +@Composable +fun HomeScreen( + credentialManagerViewModel: CredentialManagerViewModel, + navigateToLegacyLogin: () -> Unit, + navigateToSignOut: () -> Unit, +) { + val inProgress by credentialManagerViewModel.inProgress.collectAsState() + val authenticationState by Graph.authenticationState.collectAsState() + val activity = LocalActivity.current as ComponentActivity + + DemoInstructions() + + if (!inProgress) { + when (authenticationState) { + AuthenticationState.LOGGED_IN -> { + navigateToSignOut() + } + + AuthenticationState.LOGGED_OUT -> { + credentialManagerViewModel.login(activity) + } + + AuthenticationState.DISMISSED_BY_USER, AuthenticationState.MISSING_CREDENTIALS -> { + navigateToLegacyLogin() + } + + else -> { + navigateToLegacyLogin() + } + } + } else { + CircularProgressIndicator(modifier = Modifier.fillMaxSize()) + } +} + + +object DemoInstructionsState { + var isFirstLaunch: Boolean = true +} + +/** + * Displays an [AlertDialog] containing introductory demo instructions for the user. + * + * This dialog is shown upon the initial launch of the application and can be dismissed + * by the user. + * + * Note: The `AlertDialog` API used here (`edgeButton` and `visible`) might be from an + * older or specific alpha version of `androidx.wear.compose.material3`. For newer + * versions, consider using `confirmButton` and `dismissButton` for actions, and + * conditionally rendering the dialog using an `if` statement. + */ +@Composable +private fun DemoInstructions() { + if (!DemoInstructionsState.isFirstLaunch) { + return + } + + AlertDialog( + visible = true, + onDismissRequest = { + DemoInstructionsState.isFirstLaunch = false + }, + edgeButton = { + AlertDialogDefaults.EdgeButton(onClick = { + DemoInstructionsState.isFirstLaunch = false + }) + }, + title = { + Text( + text = stringResource(R.string.shrine_sample), + textAlign = TextAlign.Center, + ) + }, + text = { Text(stringResource(R.string.see_readme_md_for_usage_directions)) }, + ) +} + +/** + * Preview for the [DemoInstructions] composable. + * + * This preview renders the dialog with the demo instructions as it would appear on Wear OS devices. + */ +@WearPreviewDevices +@Composable +fun DemoInstructionsPreview() { + DemoInstructions() +} + +/** + * Preview function for the {@link HomeScreen} composable. + */ +@WearPreviewDevices +@Composable +fun HomeScreenPreview() { + HomeScreen( + credentialManagerViewModel = CredentialManagerViewModel(), + navigateToLegacyLogin = {}, + navigateToSignOut = {}, + ) +} diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacyLoginScreen.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/LegacyLoginScreen.kt similarity index 95% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacyLoginScreen.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/LegacyLoginScreen.kt index 66f6f319..a896fc61 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacyLoginScreen.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/LegacyLoginScreen.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.screens import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -32,9 +32,10 @@ import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.authentication.shrinewear.AuthenticationState import com.authentication.shrinewear.Graph import com.authentication.shrinewear.R -//import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding /** * Composable for the header of the legacy login options list. @@ -119,7 +120,7 @@ fun CancelLoginButton(navigateToHome: () -> Unit) { Button( modifier = Modifier.fillMaxWidth(), onClick = { - Graph.authenticationStatusCode = R.string.credman_status_logged_out + Graph.updateAuthenticationState(AuthenticationState.LOGGED_OUT) navigateToHome() }, label = { @@ -161,7 +162,7 @@ fun LegacyLoginScreen( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, state = columnState, -// contentPadding = rememberResponsiveColumnPadding(), + contentPadding = rememberResponsiveColumnPadding(), ) { item { LegacyLoginHeader() } item { OAuthLoginButton(navigateToOAuth = navigateToOAuth) } diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/OAuthScreen.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/OAuthScreen.kt similarity index 94% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/OAuthScreen.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/OAuthScreen.kt index 62da9a6d..5dd86a88 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/OAuthScreen.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/OAuthScreen.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.screens import android.app.Application import androidx.compose.foundation.layout.fillMaxWidth @@ -32,9 +32,8 @@ import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import com.authentication.shrinewear.R - -// TODO(johnzoeller): Re-add this when I update mobiles version discrepancies. -// import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding +import com.authentication.shrinewear.ui.viewmodel.OAuthViewModel +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding /** * Composable that displays the header for the OAuth login screen. @@ -113,7 +112,7 @@ fun OAuthScreen( ScreenScaffold { TransformingLazyColumn( state = columnState, -// contentPadding = rememberResponsiveColumnPadding(), + contentPadding = rememberResponsiveColumnPadding(), ) { item { OAuthLoginHeader() } item { SignInWithOAuthButton(oAuthViewModel = oAuthViewModel) } diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/SignOutScreen.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/SignOutScreen.kt similarity index 95% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/SignOutScreen.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/SignOutScreen.kt index 9f99606b..8a740c0c 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/SignOutScreen.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/screens/SignOutScreen.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -25,6 +25,7 @@ import androidx.wear.compose.material3.AlertDialog import androidx.wear.compose.material3.AlertDialogDefaults import androidx.wear.compose.material3.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.authentication.shrinewear.AuthenticationState import com.authentication.shrinewear.Graph import com.authentication.shrinewear.R @@ -43,7 +44,6 @@ fun SignOutScreen( var showDialog by remember { mutableStateOf(true) } fun signOut() { - Graph.authenticationStatusCode = R.string.credman_status_logged_out Graph.authenticationServer.signOut() showDialog = false navigateToHome() diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/CredentialManagerViewModel.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/CredentialManagerViewModel.kt similarity index 50% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/CredentialManagerViewModel.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/CredentialManagerViewModel.kt index 4f401aba..5e6811dd 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/CredentialManagerViewModel.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/CredentialManagerViewModel.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.viewmodel import android.app.Activity import android.util.Log @@ -21,33 +21,20 @@ import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.NoCredentialException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.authentication.shrinewear.AuthenticationState import com.authentication.shrinewear.Graph -import com.authentication.shrinewear.R import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** - * Represents the UI state for the login screen. - * - * @property statusCode The string resource ID representing the current authentication status. - * Defaults to {@link R.string#credman_status_logged_out}. - * @property inProgress A boolean indicating whether a login operation is currently in progress. - * Defaults to false. - */ -data class LoginUiState( - val statusCode: Int = R.string.credman_status_logged_out, - val inProgress: Boolean = false, -) - /** * ViewModel responsible for managing the login flow using the Credential Manager. */ class CredentialManagerViewModel : ViewModel() { private val credManAuthenticator = Graph.credentialManagerAuthenticator - private val _uiState = MutableStateFlow(LoginUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _inProgress = MutableStateFlow(false) + val inProgress: StateFlow = _inProgress.asStateFlow() /** * Initiates the login process using the Android Credential Manager. @@ -56,37 +43,33 @@ class CredentialManagerViewModel : ViewModel() { */ fun login(activity: Activity) { viewModelScope.launch { - _uiState.value = _uiState.value.copy(inProgress = true) + _inProgress.value = true try { if (credManAuthenticator.signInWithCredentialManager(activity)) { - Graph.authenticationStatusCode = R.string.credman_status_authorized + Graph.updateAuthenticationState(AuthenticationState.LOGGED_IN) } else { - Graph.authenticationStatusCode = R.string.status_failed + Graph.updateAuthenticationState(AuthenticationState.FAILED) } } catch (e: GetCredentialCancellationException) { - Log.i(TAG, INFO_DISMISSED_FALLBACK.format(e.message)) - Graph.authenticationStatusCode = R.string.credman_status_dismissed - } catch (e: NoCredentialException) { - Log.e(TAG, ERROR_MISSING_CREDENTIALS) - Graph.authenticationStatusCode = R.string.credman_status_no_credentials + Log.i( + TAG, + "Dismissed, launching old authentication. Exception: %s".format(e.message) + ) + Graph.updateAuthenticationState(AuthenticationState.DISMISSED_BY_USER) + } catch (_: NoCredentialException) { + Log.e(TAG, "Missing credentials. Verify device SDK>35.") + Graph.updateAuthenticationState(AuthenticationState.MISSING_CREDENTIALS) } catch (e: Exception) { - Log.e(TAG, ERROR_UNKNOWN_EXCEPTION.format(e.message)) - Graph.authenticationStatusCode = R.string.credman_status_unknown + Log.e(TAG, "Unknown Authentication exception: %s".format(e.message)) + Graph.updateAuthenticationState(AuthenticationState.UNKNOWN_ERROR) } finally { - _uiState.value = _uiState.value.copy( - inProgress = false, - statusCode = Graph.authenticationStatusCode, - ) + _inProgress.value = false } } } companion object { private const val TAG = "CredentialManagerViewModel" - private const val ERROR_MISSING_CREDENTIALS = "Missing credentials. Verify device SDK>35." - private const val ERROR_UNKNOWN_EXCEPTION = "Unknown Authentication exception: %s" - private const val INFO_DISMISSED_FALLBACK = - "Credential Manager dismissed by user, falling back to legacy authentication. Exception details: %s" } } diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacySignInWithGoogleEventListener.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/LegacySignInWithGoogleEventListener.kt similarity index 98% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacySignInWithGoogleEventListener.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/LegacySignInWithGoogleEventListener.kt index c16d3792..585faedd 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacySignInWithGoogleEventListener.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/LegacySignInWithGoogleEventListener.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.viewmodel import android.util.Log import com.authentication.shrinewear.Graph diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacySignInWithGoogleViewModel.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/LegacySignInWithGoogleViewModel.kt similarity index 92% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacySignInWithGoogleViewModel.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/LegacySignInWithGoogleViewModel.kt index 3d3648de..4db25c22 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/LegacySignInWithGoogleViewModel.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/LegacySignInWithGoogleViewModel.kt @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.viewmodel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.authentication.shrinewear.authenticator.SERVER_CLIENT_ID +import com.authentication.shrinewear.BuildConfig import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.horologist.auth.ui.googlesignin.signin.GoogleSignInViewModel @@ -40,7 +40,7 @@ val LegacySignInWithGoogleViewModelFactory: ViewModelProvider.Factory = viewMode val gsiOptions = GoogleSignInOptions .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) - .requestIdToken(SERVER_CLIENT_ID) + .requestIdToken(BuildConfig.GOOGLE_SIGN_IN_SERVER_CLIENT_ID) .build() val googleSignInClient = GoogleSignIn.getClient(application, gsiOptions) diff --git a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/OAuthViewModel.kt b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/OAuthViewModel.kt similarity index 99% rename from Shrine/wear/src/main/java/com/authentication/shrinewear/ui/OAuthViewModel.kt rename to Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/OAuthViewModel.kt index b109eb7b..82719383 100644 --- a/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/OAuthViewModel.kt +++ b/Shrine/wear/src/main/java/com/authentication/shrinewear/ui/viewmodel/OAuthViewModel.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.authentication.shrinewear.ui +package com.authentication.shrinewear.ui.viewmodel import android.app.Application import android.content.Intent diff --git a/Shrine/wear/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Shrine/wear/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/Shrine/wear/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/Shrine/wear/src/main/res/drawable/ic_launcher_background.xml b/Shrine/wear/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9c..00000000 --- a/Shrine/wear/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Shrine/wear/src/main/res/drawable/shrine.xml b/Shrine/wear/src/main/res/drawable/shrine.xml new file mode 100644 index 00000000..aa914d29 --- /dev/null +++ b/Shrine/wear/src/main/res/drawable/shrine.xml @@ -0,0 +1,13 @@ + + + + diff --git a/Shrine/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Shrine/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cfe..00000000 --- a/Shrine/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Shrine/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Shrine/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index eca70cfe..00000000 --- a/Shrine/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Shrine/wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/Shrine/wear/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78e..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Shrine/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-mdpi/ic_launcher.webp b/Shrine/wear/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Shrine/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Shrine/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Shrine/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Shrine/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Shrine/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f508..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Shrine/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/Shrine/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Shrine/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37..00000000 Binary files a/Shrine/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ