Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" },
)
}
}
}
Expand All @@ -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()
Expand All @@ -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)
}
}
}
}

Expand All @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt
Original file line number Diff line number Diff line change
@@ -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<Uri>? = null

suspend fun launch(
context: Context,
redirectUrl: String,
): Uri {
pending?.cancel()
val deferred = CompletableDeferred<Uri>()
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,21 +127,28 @@ 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<FlowAction>,
components: List<FlowComponent>,
): List<FlowAction> {
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,
Expand Down Expand Up @@ -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 =
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading