diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5a5196db..f3afdb4df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ accompanist = "0.36.0" activityKtx = "1.12.1" android-googleid = "1.1.1" -androidGradlePlugin = "8.13.1" +androidGradlePlugin = "8.13.2" androidx-activity-compose = "1.12.1" androidx-appcompat = "1.7.0" androidx-compose-bom = "2025.12.00" @@ -96,6 +96,7 @@ wearOngoing = "1.1.0" wearToolingPreview = "1.0.0" webkit = "1.14.0" wearPhoneInteractions = "1.1.0" +wearRemoteInteractions = "1.1.0" [libraries] accompanist-adaptive = "com.google.accompanist:accompanist-adaptive:0.37.3" @@ -237,6 +238,7 @@ validator-push = { module = "com.google.android.wearable.watchface.validator:val wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" } wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } androidx-wear-phone-interactions = { group = "androidx.wear", name = "wear-phone-interactions", version.ref = "wearPhoneInteractions" } +androidx-wear-remote-interactions = { group = "androidx.wear", name = "wear-remote-interactions", version.ref = "wearRemoteInteractions" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/wear/.gitignore b/wear/.gitignore index 42afabfd2..9f014db8c 100644 --- a/wear/.gitignore +++ b/wear/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +local.properties diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 23efcf637..f27403e35 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -38,6 +38,9 @@ android { } kotlin { jvmToolchain(21) + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } } buildFeatures { @@ -49,10 +52,6 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } - kotlinOptions { - jvmTarget = "21" - } - testOptions { unitTests { isIncludeAndroidResources = true @@ -62,9 +61,14 @@ android { dependencies { implementation(libs.androidx.core.ktx) + implementation(libs.androidx.credentials) + implementation((libs.androidx.credentials.play.services.auth)) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.androidx.wear.input) + implementation(libs.androidx.wear.phone.interactions) + implementation(libs.android.identity.googleid) + implementation(libs.androidx.wear.remote.interactions) val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index ea4f18966..87f3bdffa 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -376,6 +376,18 @@ + + + + + + + diff --git a/wear/src/main/java/com/example/wear/snippets/auth/CredentialManager.kt b/wear/src/main/java/com/example/wear/snippets/auth/CredentialManager.kt new file mode 100644 index 000000000..09145528c --- /dev/null +++ b/wear/src/main/java/com/example/wear/snippets/auth/CredentialManager.kt @@ -0,0 +1,135 @@ +/* + * 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.example.wear.snippets.auth + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.GetCredentialCancellationException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption + +/** + * Handles authentication operations using the Android Credential Manager API. + * + * This class interacts with an [AuthenticationServer] to facilitate sign-in processes + * 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, +) { + private val credentialManager: CredentialManager = CredentialManager.create(applicationContext) + + internal suspend fun signInWithCredentialManager(activity: Activity): Boolean { + // [START android_wear_credential_manager_secondary_fallback] + try { + val getCredentialResponse: GetCredentialResponse = + credentialManager.getCredential(activity, createGetCredentialRequest()) + return authenticate(getCredentialResponse.credential) + } catch (_: GetCredentialCancellationException) { + navigateToSecondaryAuthentication() + } + // [END android_wear_credential_manager_secondary_fallback] + return false + } + + /** + * Creates a [GetCredentialRequest] with standard Wear Credential types. + * + * @return A configured [GetCredentialRequest] ready to be used with [CredentialManager.getCredential]. + */ + private fun createGetCredentialRequest(): GetCredentialRequest { + return GetCredentialRequest( + credentialOptions = listOf( + GetPublicKeyCredentialOption(authenticationServer.getPublicKeyRequestOptions()), + GetPasswordOption(), + GetGoogleIdOption.Builder() + .setServerClientId(" { + return authenticationServer.loginWithPasskey(credential.authenticationResponseJson) + } + + is PasswordCredential -> { + return authenticationServer.loginWithPassword( + credential.id, + credential.password, + ) + } + + is CustomCredential -> { + return authenticationServer.loginWithCustomCredential( + credential.type, + credential.data, + ) + } + + else -> { + return false + } + } + } +} + +/** Placeholder authentication server would make network calls to your authentication server.*/ +class AuthenticationServer { + + /** Retrieves the public key credential request options from the authentication server.*/ + internal fun getPublicKeyRequestOptions(): String { + return "result of network call" + } + + fun loginWithPasskey(passkeyResponseJSON: String): Boolean { + return true + } + + fun loginWithPassword(username: String, password: String): Boolean { + return true + } + + fun loginWithCustomCredential(type: String, data: Bundle): Boolean { + return true + } +} + +/** placeholder navigation function. */ +fun navigateToSecondaryAuthentication() { +} diff --git a/wear/src/main/java/com/example/wear/snippets/auth/OAuthDAG.kt b/wear/src/main/java/com/example/wear/snippets/auth/OAuthDAG.kt new file mode 100644 index 000000000..04de6fe53 --- /dev/null +++ b/wear/src/main/java/com/example/wear/snippets/auth/OAuthDAG.kt @@ -0,0 +1,78 @@ +/* + * 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.example.wear.snippets.auth + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.wear.remote.interactions.RemoteActivityHelper + +class DeviceGrantManager(private val context: Context) { + + /** Executes the full Device Grant flow. */ + suspend fun startAuthFlow(): Result { + // 1: Get device info from server + val deviceVerificationInfo = getFakeVerificationInfo() + + // 2: Show user code UI and open URL on phone + verifyDeviceAuthGrant(deviceVerificationInfo.verificationUri) + + // 3: Fetch for the DAG token + val token = fetchToken(deviceVerificationInfo.deviceCode) + + // Step 3: Use token to get profile + return retrieveUserProfile(token) + } + + // The response data when retrieving the verification + data class VerificationInfo( + val verificationUri: String, + val userCode: String, + val deviceCode: String, + ) + + /* A fake server call to retrieve */ + private fun getFakeVerificationInfo(): VerificationInfo { + // An example of a verificationURI w + return VerificationInfo( + "your client backend", + userCode = "placeholderUser", + deviceCode = "myDeviceCode", + ) + } + + // [START android_wear_auth_oauth_dag_authorize_device] + // Request access from the authorization server and receive Device Authorization Response. + private fun verifyDeviceAuthGrant(verificationUri: String) { + RemoteActivityHelper(context).startRemoteActivity( + Intent(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + data = Uri.parse(verificationUri) + }, + null + ) + } + // [END android_wear_auth_oauth_dag_authorize_device] + + private fun fetchToken(deviceCode: String): Result { + return Result.success("placeholderToken") + } + + private fun retrieveUserProfile(token: Result): Result { + return Result.success("placeholderProfile") + } +} diff --git a/wear/src/main/java/com/example/wear/snippets/auth/OAuthPKCE.kt b/wear/src/main/java/com/example/wear/snippets/auth/OAuthPKCE.kt new file mode 100644 index 000000000..7324d4e0b --- /dev/null +++ b/wear/src/main/java/com/example/wear/snippets/auth/OAuthPKCE.kt @@ -0,0 +1,115 @@ +/* + * 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.example.wear.snippets.auth + +import android.content.Context +import android.net.Uri +import androidx.wear.phone.interactions.authentication.CodeChallenge +import androidx.wear.phone.interactions.authentication.CodeVerifier +import androidx.wear.phone.interactions.authentication.OAuthRequest +import androidx.wear.phone.interactions.authentication.OAuthResponse +import androidx.wear.phone.interactions.authentication.RemoteAuthClient +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +private const val CLIENT_ID = "my_fake_client_id" +private const val GOOGLE_OAUTH_BACKEND = "https://accounts.google.com/o/oauth2/v2/auth" +private const val GOOGLE_USER_INFO_SCOPE = "https://www.googleapis.com/auth/userinfo.profile" + +class WearOAuthPKCEManager(private val context: Context) { + + /** Start the authentication flow. */ + suspend fun startAuthFlow() { + val codeChallenge = CodeChallenge(CodeVerifier()) + + // Create the authorization Uri that will be shown to the user on the phone. This will + // be different depending on the OAuth backend your app uses, we use the google backend. + val uri = Uri.Builder() + .encodedPath(GOOGLE_OAUTH_BACKEND) + .appendQueryParameter("scope", GOOGLE_USER_INFO_SCOPE) + .build() + + // [START android_wear_auth_oauth_pkce_create_request] + val oauthRequest = OAuthRequest.Builder(context) + .setAuthProviderUrl(uri) + .setCodeChallenge(codeChallenge) + .setClientId(CLIENT_ID) + .build() + // [END android_wear_auth_oauth_pkce_create_request] + + // 1. Retrieve oauth code + val code = retrieveOAuthCode(oauthRequest, context).getOrElse {} + + // 2. Retrieve auth token from your backend. + val token = retrieveToken(code as String, oauthRequest).getOrElse {} + + // 3. Request user profile from your backend + retrieveUserProfile(token as String).getOrElse {} + } + + /** + * Use the [RemoteAuthClient] class to authorize the user. The library will handle the + * communication with the paired device, where the user can log in. + */ + private suspend fun retrieveOAuthCode( + oauthRequest: OAuthRequest, + context: Context + ): Result { + return suspendCoroutine { continuation -> + // [START android_wear_auth_oauth_pkce_send_request] + RemoteAuthClient.create(context).sendAuthorizationRequest( + request = oauthRequest, + executor = { command -> command?.run() }, + clientCallback = object : RemoteAuthClient.Callback() { + override fun onAuthorizationResponse( + request: OAuthRequest, + response: OAuthResponse + ) { + // Extract the token from the response, store it, and use it in requests. + continuation.resume(parseCodeFromResponse(response)) + } + override fun onAuthorizationError(request: OAuthRequest, errorCode: Int) { + // Handle Errors + continuation.resume(Result.failure(IOException("Authorization failed"))) + } + } + ) + // [END android_wear_auth_oauth_pkce_send_request] + } + } + + private fun parseCodeFromResponse(response: OAuthResponse): Result { + val responseUrl = response.responseUrl + val code = responseUrl?.getQueryParameter("code") + return if (code.isNullOrBlank()) { + Result.failure(IOException("Authorization failed")) + } else { + Result.success(code) + } + } + + /** placeholder token retrieval function. */ + private fun retrieveToken(code: String, oauthRequest: OAuthRequest): Result { + return Result.success("placeholderToken") + } + + /** placeholder user profile retrieval. */ + private fun retrieveUserProfile(token: String): Result { + return Result.success("placeholderProfile") + } +} diff --git a/wear/src/main/java/com/example/wear/snippets/auth/WristDetectionUtility.kt b/wear/src/main/java/com/example/wear/snippets/auth/WristDetectionUtility.kt new file mode 100644 index 000000000..12a636aa5 --- /dev/null +++ b/wear/src/main/java/com/example/wear/snippets/auth/WristDetectionUtility.kt @@ -0,0 +1,39 @@ +/* + * 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. + */ + +import android.app.KeyguardManager +import android.content.Context +import androidx.core.content.getSystemService + +// This setting differs across OEMs, so you may want to check several in your detection function +// This one works for the pixel watch. +private const val PIXEL_WRIST_AUTOLOCK_SETTING_STATE = "wrist_detection_auto_locking_enabled" + +// [START android_wear_auth_wrist_detection] +fun isWristDetectionAutoLockingEnabled(context: Context): Boolean { + // [END android_wear_auth_wrist_detection] + // Use the keyguard manager to check for the presence of a lock mechanism + val keyguardManager = context.getSystemService() + val isSecured = keyguardManager?.isDeviceSecure == true + + // Use OEM-specific system settings to verify that on-body autolock is enabled. + val isWristDetectionOn = android.provider.Settings.Global.getInt( + context.contentResolver, PIXEL_WRIST_AUTOLOCK_SETTING_STATE, + 0 + ) == 1 + + return isSecured && isWristDetectionOn +} diff --git a/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerActivity.kt b/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerActivity.kt index ecf3ff7e3..a0aebf591 100644 --- a/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerActivity.kt +++ b/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerActivity.kt @@ -124,6 +124,20 @@ private fun Context.sendImagePutDataMapRequest(): Task { } // [END android_wear_datalayer_imageputdatamap] +private fun Context.sendAuthTokenPutDataMapRequest(): Task { + // [START android_wear_datalayer_auth_token_sharing] + + val token = "..." // Auth token to transmit to the Wear OS device. + val putDataReq: PutDataRequest = PutDataMapRequest.create("/auth").run { + dataMap.putString("token", token) + asPutDataRequest() + } + val putDataTask: Task = Wearable.getDataClient(this).putDataItem(putDataReq) + // [END android_wear_datalayer_auth_token_sharing] + + return putDataTask +} + class DataLayerActivity2 : ComponentActivity(), DataClient.OnDataChangedListener { // [START android_wear_datalayer_ondatachanged_assetextract] override fun onDataChanged(dataEvents: DataEventBuffer) { diff --git a/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerService.kt b/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerService.kt index c69d5114b..5197db3ae 100644 --- a/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerService.kt +++ b/wear/src/main/java/com/example/wear/snippets/datalayer/DataLayerService.kt @@ -21,6 +21,7 @@ import android.util.Log import com.google.android.gms.wearable.DataClient import com.google.android.gms.wearable.DataEvent import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataMapItem import com.google.android.gms.wearable.Wearable import com.google.android.gms.wearable.WearableListenerService @@ -58,6 +59,29 @@ class DataLayerListenerService : WearableListenerService() { } // [END android_wear_datalayer_datalayerlistenerservice] +// [START android_wear_datalayer_auth_token_sharing_listener] +class AuthDataListenerService : WearableListenerService() { + override fun onDataChanged(dataEvents: DataEventBuffer) { + dataEvents.forEach { event -> + if (event.type == DataEvent.TYPE_CHANGED) { + val dataItemPath = event.dataItem.uri.path ?: "" + + if (dataItemPath.startsWith("/auth")) { + val token = DataMapItem.fromDataItem(event.dataItem) + .dataMap + .getString("token") + // Display an interstitial screen to notify the user that they're being signed + // in. Then, store the token and use it in network requests. +// [END android_wear_datalayer_auth_token_sharing_listener] + handleSignInSequence(token) + } + } + } + } + /** placeholder sign in handler. */ + fun handleSignInSequence(token: String?) {} +} + // [START android_wear_datalayer_ondatachangedlisteneer] class MainActivity : Activity(), DataClient.OnDataChangedListener {