diff --git a/build.gradle.kts b/build.gradle.kts index 882112b..ebd22c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("com.google.code.gson:gson:2.10.1") + implementation("androidx.browser:browser:1.8.0") val composeBom = platform("androidx.compose:compose-bom:2024.02.00") implementation(composeBom) diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt index f31aa7b..f6897d7 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -193,15 +192,8 @@ fun AuthScreen(applicationId: String) { sheetState = sheetState, ) { when (showSheet) { - "login" -> LoginSheetContent( - applicationId = applicationId, - onForgotPassword = { showSheet = "recover" }, - onSignUp = { showSheet = "signup" }, - ) + "login" -> LoginSheetContent(applicationId = applicationId) "signup" -> SignUpSheetContent() - "recover" -> RecoverSheetContent( - onBackToSignIn = { showSheet = "login" }, - ) } } } @@ -219,11 +211,7 @@ private fun SheetTitle(text: String) { } @Composable -private fun LoginSheetContent( - applicationId: String, - onForgotPassword: () -> Unit, - onSignUp: () -> Unit, -) { +private fun LoginSheetContent(applicationId: String) { Column( modifier = Modifier .fillMaxWidth() @@ -234,17 +222,6 @@ private fun LoginSheetContent( SheetTitle("Sign in") Spacer(Modifier.height(8.dp)) SignIn(applicationId = applicationId, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) - Spacer(Modifier.height(8.dp)) - TextButton(onClick = onForgotPassword) { - Text("Forgot password?", color = PrimaryBlue, fontSize = 14.sp) - } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Don't have an account?", fontSize = 14.sp, color = TextMuted) - TextButton(onClick = onSignUp) { - Text("Create one", color = PrimaryBlue, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - } - } } } @@ -263,37 +240,6 @@ private fun SignUpSheetContent() { } } -@Composable -private fun RecoverSheetContent(onBackToSignIn: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SheetTitle("Reset password") - Spacer(Modifier.height(8.dp)) - Text( - text = "Enter your email below", - fontSize = 14.sp, - color = TextMuted, - modifier = Modifier.padding(start = 0.dp), - textAlign = TextAlign.Start, - ) - Spacer(Modifier.height(32.dp)) - OutlinedButton( - onClick = onBackToSignIn, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(10.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, PrimaryBlue), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PrimaryBlue), - ) { - Text("Back to sign in", fontWeight = FontWeight.Medium) - } - } -} - @Composable private fun ThunderLogoMark( modifier: Modifier = Modifier, diff --git a/src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt b/src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt new file mode 100644 index 0000000..98cbcac --- /dev/null +++ b/src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt @@ -0,0 +1,47 @@ +package dev.thunderid.android.auth + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import kotlinx.coroutines.CompletableDeferred + +/** + * Bridges the browser round-trip for TRIGGER (federated/social login) actions. + * + * The flow-execute REDIRECTION mechanism is separate from the whole-app OAuth2/PKCE redirect + * mode in [dev.thunderid.android.ThunderIDClient.buildSignInUrl]/`handleRedirectCallback` — this + * only opens a Custom Tab and waits for the app's registered callback scheme to come back. + * + * The hosting Activity must forward its deep-link `Uri` to [onRedirect] from `onNewIntent`, + * the same way [dev.thunderid.compose.components.flow.Callback] expects the app to hand it a + * deep-link URL explicitly. Call [cancelIfPending] from `onResume` to resolve gracefully if the + * user dismissed the browser without completing sign-in. + */ +object FederatedAuthSession { + private var pending: CompletableDeferred? = null + + suspend fun launch( + context: Context, + redirectUrl: String, + ): Uri { + pending?.cancel() + val deferred = CompletableDeferred() + pending = deferred + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(redirectUrl)) + return try { + deferred.await() + } finally { + if (pending === deferred) pending = null + } + } + + fun onRedirect(uri: Uri) { + pending?.complete(uri) + pending = null + } + + fun cancelIfPending() { + pending?.cancel() + pending = null + } +} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt index 42e9d3c..2f66c17 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt @@ -18,13 +18,13 @@ package dev.thunderid.compose.components.actions.adapters -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size -import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import dev.thunderid.android.R /** * "Continue with GitHub" federated sign-in trigger, styled to match the outlined action @@ -42,35 +42,12 @@ fun GitHubButton( isLoading = isLoading, onClick = onClick, modifier = modifier, - icon = { GitHubGlyph() }, + icon = { + Image( + painter = painterResource(id = R.drawable.ic_provider_github), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, ) } - -/** - * Simplified GitHub "octocat" silhouette, drawn with plain [Canvas] circles rather than ported - * SVG path data — a rounded head with two ears, visually recognizable without pixel-perfect - * brand fidelity. - */ -@Composable -private fun GitHubGlyph() { - val glyphColor = LocalContentColor.current - Canvas(modifier = Modifier.size(18.dp)) { - val headRadius = size.minDimension * 0.42f - val center = Offset(size.width / 2f, size.height / 2f) - val earRadius = size.minDimension * 0.14f - - // Ears. - drawCircle( - color = glyphColor, - radius = earRadius, - center = Offset(center.x - headRadius * 0.62f, center.y - headRadius * 0.62f), - ) - drawCircle( - color = glyphColor, - radius = earRadius, - center = Offset(center.x + headRadius * 0.62f, center.y - headRadius * 0.62f), - ) - // Head/body. - drawCircle(color = glyphColor, radius = headRadius, center = center) - } -} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt index 6ace4b1..fdab5dd 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt @@ -18,15 +18,13 @@ package dev.thunderid.compose.components.actions.adapters -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import dev.thunderid.android.R /** * "Continue with Google" federated sign-in trigger, styled to match the outlined action @@ -44,69 +42,12 @@ fun GoogleButton( isLoading = isLoading, onClick = onClick, modifier = modifier, - icon = { GoogleGlyph() }, + icon = { + Image( + painter = painterResource(id = R.drawable.ic_provider_google), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, ) } - -/** - * Simplified four-color Google "G" mark, drawn with plain [Canvas] arcs rather than ported SVG - * path data — visually recognizable without pixel-perfect brand fidelity. - */ -@Composable -private fun GoogleGlyph() { - Canvas(modifier = Modifier.size(18.dp)) { - val strokeWidth = size.minDimension * 0.22f - val radius = (size.minDimension - strokeWidth) / 2f - val center = Offset(size.width / 2f, size.height / 2f) - val topLeft = Offset(center.x - radius, center.y - radius) - val arcSize = Size(radius * 2f, radius * 2f) - val stroke = Stroke(width = strokeWidth) - - // Red: top arc. - drawArc( - color = Color(0xFFEA4335), - startAngle = 270f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - // Green: bottom-right arc. - drawArc( - color = Color(0xFF34A853), - startAngle = 0f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - // Yellow: bottom-left arc. - drawArc( - color = Color(0xFFFBBC05), - startAngle = 90f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - // Blue: top-left arc, plus the crossbar of the "G". - drawArc( - color = Color(0xFF4285F4), - startAngle = 180f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - drawLine( - color = Color(0xFF4285F4), - start = center, - end = Offset(center.x + radius, center.y), - strokeWidth = strokeWidth, - ) - } -} diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt index 33d16d8..5f3ac2a 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt @@ -127,10 +127,19 @@ class SignInState { } } +/** + * The real Flow Execution API identifies the same node with `ref` on the flat `data.actions` + * array but `id` on the matching node inside `data.meta.components` — the two never share a + * field name, so callers must compare whichever identifier each side actually populated. + */ +private fun FlowAction.identifierKey(): String? = ref ?: id + +private fun FlowComponent.identifierKey(): String? = ref ?: id + /** * Fills in any `null` presentation fields on the flat `actions` array (label, eventType, - * variant, icon) from the matching `ACTION`-typed node in the component tree, matched by `ref` - * (falling back to `id`). Explicit flat values always win. + * variant, icon) from the matching `ACTION`-typed node in the component tree, matched by + * whichever of `ref`/`id` each side populated. Explicit flat values always win. */ private fun enrichActions( actions: List, @@ -138,10 +147,8 @@ private fun enrichActions( ): List { val actionComponents = flattenActionComponents(components) return actions.map { action -> - val match = - actionComponents.firstOrNull { - (it.ref != null && it.ref == action.ref) || (it.id != null && it.id == action.id) - } ?: return@map action + val key = action.identifierKey() ?: return@map action + val match = actionComponents.firstOrNull { it.identifierKey() == key } ?: return@map action action.copy( label = action.label ?: match.label, eventType = action.eventType ?: match.eventType, @@ -323,10 +330,8 @@ private fun ActionComponentView( i18n: ThunderIDI18n, modifier: Modifier = Modifier, ) { - val action = - signInState.actions.firstOrNull { - (it.ref != null && it.ref == component.ref) || (it.id != null && it.id == component.id) - } ?: return + val componentKey = component.identifierKey() ?: return + val action = signInState.actions.firstOrNull { it.identifierKey() == componentKey } ?: return val actionId = action.id ?: action.ref ?: return val resolver = signInState.templateResolver val label = diff --git a/src/main/res/drawable-nodpi/ic_provider_github.png b/src/main/res/drawable-nodpi/ic_provider_github.png new file mode 100644 index 0000000..7658449 Binary files /dev/null and b/src/main/res/drawable-nodpi/ic_provider_github.png differ diff --git a/src/main/res/drawable-nodpi/ic_provider_google.png b/src/main/res/drawable-nodpi/ic_provider_google.png new file mode 100644 index 0000000..f7b7850 Binary files /dev/null and b/src/main/res/drawable-nodpi/ic_provider_google.png differ