diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index 22322df..d0cb511 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -140,20 +140,54 @@ jobs: java-version: '17' distribution: 'temurin' + # The sample lives in thunder-id/samples; fetch it and build it against this checkout. + - name: πŸ“₯ Fetch Quickstart Sample + run: tests/e2e/scripts/prepare-sample.sh + - name: πŸ—„οΈ Cache Gradle Packages uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-sample-${{ hashFiles('samples/quickstart/**/*.gradle.kts', 'samples/quickstart/**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-gradle-sample-${{ hashFiles('*.gradle.kts', 'tests/e2e/.samples/quickstarts/android/**/*.gradle.kts', 'tests/e2e/.samples/quickstarts/android/**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle-sample- - name: πŸ”¨ Build Quickstart Sample - working-directory: samples/quickstart + working-directory: tests/e2e/.samples/quickstarts/android run: ./gradlew build -x test + build-playground: + name: πŸ› οΈ Build Playground + if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: πŸ“₯ Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: β˜• Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: πŸ—„οΈ Cache Gradle Packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-playground-${{ hashFiles('*.gradle.kts', 'playground/*.gradle.kts', 'playground/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-playground- + + # The playground builds the SDK from this checkout through includeBuild(".."). + - name: πŸ”¨ Lint and Build Playground + working-directory: playground + run: ./gradlew ktlintCheck assembleDebug + e2e: name: 🎭 E2E Tests if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0114d19..9b20636 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,8 +45,6 @@ jobs: needs: [validate] if: needs.validate.result == 'success' runs-on: ubuntu-latest - outputs: - version: ${{ steps.bump.outputs.version }} steps: - name: πŸ“₯ Checkout Code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -117,47 +115,3 @@ jobs: gh release create "v${VERSION}" \ --title "ThunderID Android SDK v${VERSION}" \ --generate-notes - - package-quickstart: - name: πŸ“¦ Package Quickstart Sample - needs: [release] - runs-on: ubuntu-latest - env: - VERSION: ${{ needs.release.outputs.version }} - steps: - - name: πŸ“₯ Checkout Tag - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: "v${{ needs.release.outputs.version }}" - persist-credentials: false - - - name: πŸ“¦ Extract Quickstart Sample - run: git archive "v${VERSION}" samples/quickstart | tar -x -C /tmp - - - name: πŸ”— Detach Sample from Local SDK Source - working-directory: /tmp/samples/quickstart - run: | - sed -i '/includeBuild("\.\.\/\.\.")\s*{\s*name = "android"\s*}/d' settings.gradle.kts - perl -0777 -pi -e 's/(RepositoriesMode\.FAIL_ON_PROJECT_REPOS\)\n\s*repositories \{)/$1\n maven("https:\/\/jitpack.io")/' settings.gradle.kts - sed -i "s#implementation(\"dev.thunderid:android\")#implementation(\"com.github.thunder-id:android-sdks:v${VERSION}\")#" build.gradle.kts - grep -q "com.github.thunder-id:android-sdks:v${VERSION}" build.gradle.kts - grep -qF 'maven("https://jitpack.io")' settings.gradle.kts - ! grep -q "includeBuild" settings.gradle.kts - - - name: 🚫 Reject Symlinks - working-directory: /tmp/samples/quickstart - run: | - if find . -type l | grep -q .; then - echo "Symbolic links found in the sample tree β€” refusing to archive." >&2 - find . -type l -exec echo " {}" \; - exit 1 - fi - - - name: πŸ—œοΈ Zip Sample - working-directory: /tmp/samples - run: mv quickstart android-quickstart && zip -r "android-quickstart-v${VERSION}.zip" android-quickstart - - - name: πŸš€ Upload to Release - env: - GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} - run: gh release upload "v${VERSION}" "/tmp/samples/android-quickstart-v${VERSION}.zip" diff --git a/.gitignore b/.gitignore index cbd7d3c..44ae15f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ google-services.json # Maestro JUnit report from the E2E suite. tests/e2e/report.xml + +# Samples repository checkout fetched by tests/e2e/scripts/prepare-sample.sh +tests/e2e/.samples/ diff --git a/AGENTS.md b/AGENTS.md index 7d4fc5a..6454844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project overview -Kotlin Android library providing the ThunderID authentication SDK (`dev.thunderid.android`) and a Jetpack Compose component library (`dev.thunderid.compose`). The `samples/quickstart` directory contains a standalone demo app. +Kotlin Android library providing the ThunderID authentication SDK (`dev.thunderid.android`) and a Jetpack Compose component library (`dev.thunderid.compose`). The Quickstart demo app lives in the samples repository at [`quickstarts/android`](https://github.com/thunder-id/samples/tree/main/quickstarts/android). The `playground/` directory holds a development app that exercises every SDK capability against the SDK built from source. ## SDK development specification @@ -45,8 +45,15 @@ The SDK is white-labelable: a consuming app can override the brand/vendor namesp # Run lint and ktlint checks (must pass before any PR) ./gradlew lint ktlintCheck + +# Lint and build the playground against the local SDK (see playground/README.md) +cd playground && ./gradlew ktlintCheck assembleDebug ``` +When you add or change an SDK capability or Compose component, update the playground's checks +(`playground/src/main/kotlin/dev/thunderid/playground/Checks.kt`) or component stories +(`Stories.kt`) to cover it. + ## Ktlint compliance (required) All code **must pass `ktlintCheck` with zero violations**. The CI `lint` job runs this check on every PR and treats any violation as a build failure. @@ -91,7 +98,8 @@ src/main/kotlin/dev/thunderid/ user/ User, UserDropdown, UserProfile i18n/ Localization (ThunderI18n, DefaultStrings) src/test/kotlin/ Unit tests -samples/quickstart/ Demo app (not part of the SDK) +playground/ Development app (checks, component explorer, session inspector); builds the SDK via includeBuild +tests/e2e/ Maestro E2E suite (fetches the Quickstart sample from thunder-id/samples) build.gradle.kts SDK library build config settings.gradle.kts Project settings ``` @@ -100,7 +108,7 @@ settings.gradle.kts Project settings - **Layer 1–2** (`dev.thunderid.android`): platform SDK β€” HTTP, token management, auth flows, PKCE, storage. - **Layer 3** (`dev.thunderid.compose`): Compose UI wrappers around the platform SDK. -- **Layer 4** (`samples/quickstart`): standalone demo app that depends on the SDK via `includeBuild`. +- **Layer 4** ([`quickstarts/android`](https://github.com/thunder-id/samples/tree/main/quickstarts/android) in thunder-id/samples): standalone demo app that depends on the published SDK from JitPack. The E2E suite and the PR builder fetch it with `tests/e2e/scripts/prepare-sample.sh`, which points it at the local checkout via `includeBuild`. Every Compose component ships in two variants: - **Styled** β€” opinionated Material 3 defaults (e.g. `SignInButton`). diff --git a/README.md b/README.md index c904fd4..f652225 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Android SDK for ThunderID. Provides authentication and user management for nativ - [Quickstart](https://thunderid.dev/docs/next/getting-started/connect-your-application/android/) - [API reference](https://thunderid.dev/docs/next/sdks/android/overview/) +- [Sample app](https://github.com/thunder-id/samples/tree/main/quickstarts/android) ## Installation @@ -38,6 +39,12 @@ dependencies { > [!NOTE] > Replace `` with the [latest release tag](https://github.com/thunder-id/android-sdks/releases) of the `android-sdks` repository. +## Playground + +The [`playground`](playground) directory contains a Jetpack Compose app that builds this SDK from source and exercises +every capability: capability checks, a component explorer, and a session inspector. See the +[playground README](playground/README.md) to run it against a local ThunderID server. + ## Contributing Please refer to the [Contributing Guide](https://thunderid.dev/community/overview) for the different ways to contribute to this project and the relevant guidelines. diff --git a/samples/quickstart/.gitignore b/playground/.gitignore similarity index 73% rename from samples/quickstart/.gitignore rename to playground/.gitignore index 6cba03b..3b6de85 100644 --- a/samples/quickstart/.gitignore +++ b/playground/.gitignore @@ -8,10 +8,6 @@ local.properties # Local build config (copy config.properties.example and fill in your values) config.properties -# Local Digital Asset Links file (copy assetlinks.json.example, fill in your values, and -# host it at https:///.well-known/assetlinks.json β€” not served from this app) -assetlinks.json - # Log/OS Files *.log .DS_Store diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..7824359 --- /dev/null +++ b/playground/README.md @@ -0,0 +1,65 @@ +# ThunderID Android Playground + +A Jetpack Compose app for exercising every capability of the ThunderID Android SDK from source. +It builds the SDK in this repository through `includeBuild("..")`, so changes to `src/` show up the +next time you run it. It is a development tool, not a sample: for a minimal integration, see the +[Android Quickstart](https://github.com/thunder-id/samples/tree/main/quickstarts/android). + +## What it shows + +- **Checks**: capability checks that call the real SDK and report pass or fail with a reason. + Sign-in through the system browser (Custom Tabs), app-native sign-in (`SignIn`), passkeys + (Credential Manager), sign-out, the redirect callback, token refresh, secure token storage, + user info, theming, and translations. Capabilities the Android SDK has no API for (biometric + unlock, organizations) are shown as unavailable. "Run all checks" runs everything that does not + need interaction; the browser sign-in, app-native sign-in, and sign-out checks run on tap. The + summary card also probes the server's `/health/readiness` endpoint. +- **Components**: an explorer for the SDK's Compose components. Pick a component, switch stories, + edit props in Controls, toggle a light or dark preview, and copy the Compose snippet generated + from the current props in Code. +- **Session**: the signed-in user, an access token countdown, refresh and sign-out, the stored + tokens (reveal and copy), the decoded access token claims, and session events. +- **Logs**: everything the playground observed from the SDK: auth state changes, storage writes + (key names only), redirects, and check results. + +## Prerequisites + +- JDK 17 and the Android SDK (API 34). Set `ANDROID_HOME`, or add `sdk.dir` to a + `local.properties` file in this directory. +- A running ThunderID server. See [Get started](https://thunderid.dev/docs/next/getting-started/) + to run one locally at `https://localhost:8090`. + +## Configure + +1. In the ThunderID Console, create a mobile application. Note its application ID and OAuth client + ID, and register `dev.thunderid.playground://callback` as a redirect URI. +2. Copy the example config and fill in your values: + + ```bash + cp config.properties.example config.properties + ``` + + | Key | Description | + |---|---| + | `THUNDERID_BASE_URL` | ThunderID server URL. The emulator reaches your machine's `localhost` at `10.0.2.2`, so a local server is `https://10.0.2.2:8090`. | + | `THUNDERID_APPLICATION_ID` | Application ID for the app-native flow. | + | `THUNDERID_CLIENT_ID` | OAuth client ID for the browser sign-in and token refresh. | + | `THUNDERID_AFTER_SIGN_IN_URL` | Sign-in redirect URI. Its scheme is registered on the app, so changing it here is enough. | + | `THUNDERID_AFTER_SIGN_OUT_URL` | Sign-out redirect URI. | + | `THUNDERID_SCOPES` | Space separated scopes. Defaults to `openid profile email`. | + + `config.properties` is gitignored. Every key can also come from an environment variable of the + same name. + +Debug builds accept the local server's self-signed certificate. The browser sign-in opens the server +in a Custom Tab, which shows a certificate warning for a self-signed certificate; continue past it, +or install the certificate on the emulator. + +## Run + +```bash +./gradlew installDebug +``` + +Or open this directory in Android Studio and run it. Without a configured +server the app still starts: checks that need the server fail with the reason. diff --git a/playground/build.gradle.kts b/playground/build.gradle.kts new file mode 100644 index 0000000..672b24f --- /dev/null +++ b/playground/build.gradle.kts @@ -0,0 +1,84 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jlleitschuh.gradle.ktlint") +} + +// ThunderID settings come from config.properties (gitignored) or the environment. +// Copy config.properties.example to config.properties and fill in your values. +val configProps = + Properties().apply { + val file = rootProject.file("config.properties") + if (file.exists()) file.inputStream().use { load(it) } + } + +fun config( + key: String, + default: String = "", +): String = configProps.getProperty(key) ?: System.getenv(key) ?: default + +val afterSignInUrl = config("THUNDERID_AFTER_SIGN_IN_URL", "dev.thunderid.playground://callback") + +android { + namespace = "dev.thunderid.playground" + compileSdk = 34 + + defaultConfig { + applicationId = "dev.thunderid.playground" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + buildConfigField("String", "THUNDERID_BASE_URL", "\"${config("THUNDERID_BASE_URL")}\"") + buildConfigField("String", "THUNDERID_APPLICATION_ID", "\"${config("THUNDERID_APPLICATION_ID")}\"") + buildConfigField("String", "THUNDERID_CLIENT_ID", "\"${config("THUNDERID_CLIENT_ID")}\"") + buildConfigField("String", "THUNDERID_AFTER_SIGN_IN_URL", "\"$afterSignInUrl\"") + buildConfigField( + "String", + "THUNDERID_AFTER_SIGN_OUT_URL", + "\"${config("THUNDERID_AFTER_SIGN_OUT_URL", "dev.thunderid.playground://signed-out")}\"", + ) + buildConfigField("String", "THUNDERID_SCOPES", "\"${config("THUNDERID_SCOPES", "openid profile email")}\"") + + // The redirect URI's scheme is registered on MainActivity, so the callback lands back here. + manifestPlaceholders["redirectScheme"] = afterSignInUrl.substringBefore("://") + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +ktlint { + version.set("1.8.0") +} + +dependencies { + implementation("dev.thunderid:android") + + val composeBom = platform("androidx.compose:compose-bom:2024.02.00") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") +} diff --git a/playground/config.properties.example b/playground/config.properties.example new file mode 100644 index 0000000..4d922aa --- /dev/null +++ b/playground/config.properties.example @@ -0,0 +1,19 @@ +# Copy to config.properties (gitignored) and fill in the values for your ThunderID server. +# Every key can also be supplied as an environment variable of the same name. + +# ThunderID server. The Android emulator reaches the host machine's localhost at 10.0.2.2. +THUNDERID_BASE_URL=https://10.0.2.2:8090 + +# Application ID, used by the app-native flow (SignIn, SignUp, flow execution). +THUNDERID_APPLICATION_ID=your-application-id + +# OAuth2 client ID, used by the system browser (Custom Tabs) sign-in and token refresh. +THUNDERID_CLIENT_ID=your-client-id + +# Redirect URIs. Register the sign-in redirect URI on the application in ThunderID. +# The URI scheme is wired into the manifest, so a custom scheme here is all you need to change. +THUNDERID_AFTER_SIGN_IN_URL=dev.thunderid.playground://callback +THUNDERID_AFTER_SIGN_OUT_URL=dev.thunderid.playground://signed-out + +# Space separated OAuth2 scopes. +THUNDERID_SCOPES=openid profile email diff --git a/samples/quickstart/gradle.properties b/playground/gradle.properties similarity index 80% rename from samples/quickstart/gradle.properties rename to playground/gradle.properties index 9881f6c..ba0e3ac 100644 --- a/samples/quickstart/gradle.properties +++ b/playground/gradle.properties @@ -1,2 +1,3 @@ android.useAndroidX=true +kotlin.code.style=official org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError diff --git a/samples/quickstart/gradle/wrapper/gradle-wrapper.jar b/playground/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from samples/quickstart/gradle/wrapper/gradle-wrapper.jar rename to playground/gradle/wrapper/gradle-wrapper.jar diff --git a/samples/quickstart/gradle/wrapper/gradle-wrapper.properties b/playground/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from samples/quickstart/gradle/wrapper/gradle-wrapper.properties rename to playground/gradle/wrapper/gradle-wrapper.properties diff --git a/samples/quickstart/gradlew b/playground/gradlew similarity index 100% rename from samples/quickstart/gradlew rename to playground/gradlew diff --git a/playground/gradlew.bat b/playground/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/playground/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/quickstart/settings.gradle.kts b/playground/settings.gradle.kts similarity index 54% rename from samples/quickstart/settings.gradle.kts rename to playground/settings.gradle.kts index d48764d..687a0c9 100644 --- a/samples/quickstart/settings.gradle.kts +++ b/playground/settings.gradle.kts @@ -7,6 +7,7 @@ pluginManagement { plugins { id("com.android.application") version "8.2.2" id("org.jetbrains.kotlin.android") version "1.9.22" + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" } } @@ -18,5 +19,12 @@ dependencyResolutionManagement { } } -rootProject.name = "AndroidQuickstart" -includeBuild("../..") { name = "android" } +rootProject.name = "AndroidPlayground" + +// Build the SDK from this checkout: `dev.thunderid:android` resolves to the parent build. +includeBuild("..") { + name = "android" + dependencySubstitution { + substitute(module("dev.thunderid:android")).using(project(":")) + } +} diff --git a/samples/quickstart/src/main/AndroidManifest.xml b/playground/src/main/AndroidManifest.xml similarity index 54% rename from samples/quickstart/src/main/AndroidManifest.xml rename to playground/src/main/AndroidManifest.xml index 4d70a24..10ba793 100644 --- a/samples/quickstart/src/main/AndroidManifest.xml +++ b/playground/src/main/AndroidManifest.xml @@ -2,12 +2,22 @@ + + + + + + + + + + android:label="ThunderID Playground" + android:networkSecurityConfig="@xml/network_security_config" + android:theme="@style/Theme.Playground"> - + - + diff --git a/playground/src/main/kotlin/dev/thunderid/playground/Checks.kt b/playground/src/main/kotlin/dev/thunderid/playground/Checks.kt new file mode 100644 index 0000000..168d88f --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/Checks.kt @@ -0,0 +1,421 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import android.content.Context +import android.content.Intent +import android.hardware.biometrics.BiometricManager +import android.net.Uri +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import dev.thunderid.android.EmbeddedFlowRequestConfig +import dev.thunderid.android.EmbeddedFlowResponse +import dev.thunderid.android.EmbeddedSignInPayload +import dev.thunderid.android.FlowComponent +import dev.thunderid.android.FlowStatus +import dev.thunderid.android.auth.FederatedAuthSession +import dev.thunderid.compose.ThunderIDState +import dev.thunderid.compose.i18n.DefaultStrings +import dev.thunderid.compose.i18n.ThunderIDI18n +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.security.cert.X509Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +enum class CheckStatus { IDLE, RUNNING, PASS, FAIL, UNAVAILABLE } + +data class CheckResult( + val status: CheckStatus, + val note: String = "", +) + +data class CheckDef( + val id: String, + val section: String, + val name: String, + val api: String, + // Interactive checks open a browser or a sheet, so "Run all" only runs their preflight. + val interactive: Boolean = false, +) + +val CheckDefs = + listOf( + CheckDef("browser", "Authentication", "Sign in (system browser)", "buildSignInUrl() Β· Custom Tabs", interactive = true), + CheckDef("native", "Authentication", "Sign in (app-native flow)", "SignIn() Β· client.signIn()", interactive = true), + CheckDef("passkey", "Authentication", "Passkey sign-in", "PasskeyClient Β· Credential Manager"), + CheckDef("biometric", "Authentication", "Biometric unlock", "BiometricPrompt"), + CheckDef("signout", "Authentication", "Sign out", "client.signOut()", interactive = true), + CheckDef("deeplink", "Authentication", "Redirect callback", "afterSignInUrl Β· intent filter"), + CheckDef("refresh", "Session", "Token refresh", "getAccessToken() Β· auto refresh"), + CheckDef("storage", "Session", "Secure token storage", "EncryptedStorageAdapter"), + CheckDef("user", "Session", "User info", "client.getUser()"), + CheckDef("orgs", "Session", "Organizations", "organizations()"), + CheckDef("theme", "UI", "Theming and dark mode", "MaterialTheme Β· Material 3"), + CheckDef("i18n", "UI", "Translations", "ThunderIDI18n"), + ) + +enum class Health { UNKNOWN, CHECKING, HEALTHY, UNHEALTHY } + +/** Runs the capability checks against the real SDK and holds their results across tab switches. */ +class ChecksController( + private val context: Context, + private val storage: RecordingStorageAdapter, +) { + val results = mutableStateMapOf() + var runningAll by mutableStateOf(false) + private set + var health by mutableStateOf(Health.UNKNOWN) + private set + var healthNote by mutableStateOf("") + private set + + /** Set while the app-native SignIn sheet is open for the "native" check. */ + var signInSheetOpen by mutableStateOf(false) + + fun result(id: String): CheckResult = results[id] ?: CheckResult(CheckStatus.IDLE) + + suspend fun runAll( + state: ThunderIDState, + scheme: ColorScheme, + ) { + if (runningAll) return + runningAll = true + EventLog.info("checks:runAll", "${CheckDefs.size} checks", source = "playground") + CheckDefs.forEach { results[it.id] = CheckResult(CheckStatus.RUNNING) } + try { + checkHealth() + CheckDefs.forEach { run(it, state, scheme, interactive = false) } + } finally { + runningAll = false + } + } + + suspend fun run( + def: CheckDef, + state: ThunderIDState, + scheme: ColorScheme, + interactive: Boolean = true, + ) { + results[def.id] = CheckResult(CheckStatus.RUNNING) + val result = + try { + if (!state.isInitialized && def.id !in OFFLINE_CHECKS) { + fail("SDK not initialized: ${state.error ?: "check config.properties"}") + } else { + execute(def, state, scheme, interactive) + } + } catch (e: CancellationException) { + fail("Cancelled: the browser was closed before the redirect") + } catch (e: Exception) { + fail(e.message ?: e.javaClass.simpleName) + } + results[def.id] = result + val detail = result.note.ifBlank { result.status.name.lowercase() } + when (result.status) { + CheckStatus.PASS -> EventLog.ok("check:${def.id}", detail, source = "check") + CheckStatus.FAIL -> EventLog.error("check:${def.id}", detail, source = "check") + else -> EventLog.info("check:${def.id}", detail, source = "check") + } + } + + /** Called by the SignIn sheet when the app-native flow finishes. */ + fun completeNativeSignIn( + success: Boolean, + note: String, + ) { + signInSheetOpen = false + results["native"] = if (success) pass(note) else fail(note) + if (success) EventLog.ok("signIn:completed", "mode=app-native", source = "sdk") + } + + suspend fun checkHealth() { + health = Health.CHECKING + val url = "${PlaygroundSettings.baseUrl}/health/readiness" + val outcome = + withContext(Dispatchers.IO) { + runCatching { + val connection = URL(url).openConnection() as HttpURLConnection + if (BuildConfig.DEBUG && connection is HttpsURLConnection) trustAll(connection) + connection.connectTimeout = 4000 + connection.readTimeout = 4000 + try { + connection.responseCode + } finally { + connection.disconnect() + } + } + } + val code = outcome.getOrNull() + health = if (code in 200..299) Health.HEALTHY else Health.UNHEALTHY + healthNote = + when { + PlaygroundSettings.baseUrl.isBlank() -> "THUNDERID_BASE_URL not set" + code != null -> "HTTP $code" + else -> outcome.exceptionOrNull()?.message ?: "unreachable" + } + if (health == Health.HEALTHY) { + EventLog.ok("server:healthy", url, source = "playground") + } else { + EventLog.error("server:unhealthy", healthNote, source = "playground") + } + } + + private suspend fun execute( + def: CheckDef, + state: ThunderIDState, + scheme: ColorScheme, + interactive: Boolean, + ): CheckResult = + when (def.id) { + "browser" -> browserSignIn(state, interactive) + "native" -> nativeSignIn(state, interactive) + "passkey" -> passkey(state) + "biometric" -> biometric() + "signout" -> signOut(state, interactive) + "deeplink" -> redirectCallback() + "refresh" -> tokenRefresh(state) + "storage" -> secureStorage() + "user" -> userInfo(state) + "orgs" -> unavailable("The Android SDK has no organizations API. Only ThunderIDConfig.organizationHandle exists.") + "theme" -> theme(scheme) + "i18n" -> translations() + else -> fail("Unknown check") + } + + private suspend fun browserSignIn( + state: ThunderIDState, + interactive: Boolean, + ): CheckResult { + if (PlaygroundSettings.clientId.isBlank()) return fail("THUNDERID_CLIENT_ID is not set in config.properties") + val provider = + context.packageManager + .queryIntentServices(Intent("android.support.customtabs.action.CustomTabsService"), 0) + .firstOrNull() + ?.serviceInfo + ?.packageName + ?: return fail("No browser with Custom Tabs support is installed") + val url = state.client.buildSignInUrl() + if (!interactive) return idle("Ready ($provider). Tap to sign in through the browser.") + EventLog.info("signIn:started", "mode=system-browser", source = "sdk") + val redirect = FederatedAuthSession.launch(context, url) + redirect.getQueryParameter("error")?.let { return fail("Server returned error=$it") } + val user = state.client.handleRedirectCallback(redirect.toString()) + state.refresh() + EventLog.ok("signIn:completed", "mode=system-browser", source = "sdk") + return pass("Signed in as ${user.username ?: user.email ?: user.sub ?: "user"} via $provider") + } + + private suspend fun nativeSignIn( + state: ThunderIDState, + interactive: Boolean, + ): CheckResult { + if (interactive) { + if (PlaygroundSettings.applicationId.isBlank()) return fail("THUNDERID_APPLICATION_ID is not set") + EventLog.info("signIn:started", "mode=app-native", source = "sdk") + signInSheetOpen = true + return CheckResult(CheckStatus.RUNNING, "Complete the flow in the sheet") + } + val response = initiateFlow(state) + val inputs = response.data?.inputs?.size ?: 0 + val actions = flowComponents(response).size + return idle("Flow ready: $inputs inputs, $actions components. Tap to sign in.") + } + + private suspend fun passkey(state: ThunderIDState): CheckResult { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return fail("Credential Manager passkeys need Android 9 or later") + } + val response = initiateFlow(state) + val offered = + flowComponents(response).any { component -> + listOf(component.ref, component.label, component.icon, component.id, component.type) + .any { it?.contains("passkey", ignoreCase = true) == true } + } || + response.data?.actions.orEmpty().any { action -> + listOf(action.ref, action.label, action.icon, action.id) + .any { it?.contains("passkey", ignoreCase = true) == true } + } + return if (offered) { + pass("Flow offers a passkey action; SignIn() runs it through PasskeyClient") + } else { + fail("The sign-in flow for this application has no passkey action") + } + } + + private fun biometric(): CheckResult { + val device = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val manager = context.getSystemService(BiometricManager::class.java) + val code = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + } else { + @Suppress("DEPRECATION") + manager.canAuthenticate() + } + when (code) { + BiometricManager.BIOMETRIC_SUCCESS -> "biometrics enrolled" + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "no biometrics enrolled" + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> "no biometric hardware" + else -> "biometrics unavailable ($code)" + } + } else { + "BiometricManager needs Android 10" + } + return unavailable("The Android SDK has no biometric unlock API. Device: $device.") + } + + private suspend fun signOut( + state: ThunderIDState, + interactive: Boolean, + ): CheckResult { + if (!state.isSignedIn) return fail("Not signed in. Sign in first.") + if (!interactive) return idle("Signed in. Tap to sign out (ends the session).") + EventLog.info("signOut:requested", "client.signOut()", source = "sdk") + val after = state.client.signOut() + state.refresh() + return if (state.isSignedIn) { + fail("Session still present after signOut()") + } else { + EventLog.ok("signOut:completed", after, source = "sdk") + pass("Tokens cleared and revoked; afterSignOutUrl=$after") + } + } + + private fun redirectCallback(): CheckResult { + val uri = Uri.parse(PlaygroundSettings.afterSignInUrl) + val intent = Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE) + val handler = + context.packageManager + .queryIntentActivities(intent, 0) + .firstOrNull { it.activityInfo.packageName == context.packageName } + ?: return fail("No activity in this app handles ${uri.scheme}://") + return pass("${uri.scheme}://${uri.host} opens ${handler.activityInfo.name.substringAfterLast('.')}") + } + + private suspend fun tokenRefresh(state: ThunderIDState): CheckResult { + if (!state.isSignedIn) return fail("Not signed in. Sign in first.") + val before = state.client.getAccessToken() + if (storage.find("refresh_token") == null) { + return fail("No refresh token stored. App-native sign-in returns an assertion only.") + } + storage.expireAccessToken() + val after = state.client.getAccessToken() + EventLog.ok("token:refreshed", "grant_type=refresh_token", source = "sdk") + val exp = runCatching { state.client.decodeJwtToken(after)["exp"] }.getOrNull() + return if (after != before) pass("Access token rotated; new exp=$exp") else fail("Token did not change after refresh") + } + + private fun secureStorage(): CheckResult { + val probeKey = "playground.probe" + val probeValue = "probe-${System.nanoTime()}" + storage.store(probeKey, probeValue) + val readBack = storage.retrieve(probeKey) + val raw = context.getSharedPreferences("dev.thunderid.sdk.prefs", Context.MODE_PRIVATE).all + storage.delete(probeKey) + if (readBack != probeValue) return fail("Round trip through EncryptedStorageAdapter failed") + val leaked = raw.keys.any { it == probeKey } || raw.values.any { it == probeValue } + return if (leaked) { + fail("Plaintext found in the shared preferences file") + } else { + pass("Round trip ok; the prefs file holds only ciphertext (Android Keystore AES256)") + } + } + + private suspend fun userInfo(state: ThunderIDState): CheckResult { + if (!state.isSignedIn) return fail("Not signed in. Sign in first.") + val user = state.client.getUser() + val profile = runCatching { state.client.getUserProfile() } + val profileNote = + profile.fold( + { "profile ${it.attributes.size} attributes" }, + { "profile unavailable (${it.message})" }, + ) + return pass("sub=${user.sub}, ${user.claims.size} claims, $profileNote") + } + + private fun theme(scheme: ColorScheme): CheckResult { + val dark = scheme.background.luminance() < 0.5f + val primary = String.format("#%06X", scheme.primary.toArgb() and 0xFFFFFF) + return if (dark) { + pass("Material 3 dark scheme, primary $primary; SDK components read MaterialTheme") + } else { + fail("The active color scheme is not dark") + } + } + + private fun translations(): CheckResult { + val i18n = ThunderIDI18n(bundles = PlaygroundBundles) + i18n.setLocale("fr-FR") + val translated = i18n.resolve("signIn.button") + val fallback = i18n.resolve("signIn.title") + return when { + translated != "Se connecter" -> fail("fr-FR signIn.button resolved to \"$translated\"") + fallback != DefaultStrings.all["signIn.title"] -> fail("Missing key did not fall back to English") + else -> pass("fr-FR: \"$translated\"; missing keys fall back to English") + } + } + + private suspend fun initiateFlow(state: ThunderIDState): EmbeddedFlowResponse { + val applicationId = PlaygroundSettings.applicationId + require(applicationId.isNotBlank()) { "THUNDERID_APPLICATION_ID is not set in config.properties" } + val response = state.client.signIn(EmbeddedSignInPayload(), EmbeddedFlowRequestConfig(applicationId)) + if (response.flowStatus == FlowStatus.ERROR) { + error(response.failureReason ?: response.error?.message?.defaultValue ?: "Flow returned ERROR") + } + EventLog.info("flow:initiated", "executionId=${response.flowId}", source = "sdk") + return response + } + + private fun flowComponents(response: EmbeddedFlowResponse): List { + fun flatten(list: List?): List = list.orEmpty().flatMap { listOf(it) + flatten(it.components) } + return flatten(response.data?.meta?.components) + } + + private fun pass(note: String) = CheckResult(CheckStatus.PASS, note) + + private fun fail(note: String) = CheckResult(CheckStatus.FAIL, note) + + private fun idle(note: String) = CheckResult(CheckStatus.IDLE, note) + + private fun unavailable(note: String) = CheckResult(CheckStatus.UNAVAILABLE, note) + + private companion object { + // Checks that do not need an initialized SDK. + val OFFLINE_CHECKS = setOf("biometric", "deeplink", "storage", "orgs", "theme", "i18n") + + // Development only: the local ThunderID server uses a self-signed certificate. + fun trustAll(connection: HttpsURLConnection) { + val trustManager = + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) = Unit + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() + } + val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) } + connection.sslSocketFactory = ssl.socketFactory + connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/ChecksScreen.kt b/playground/src/main/kotlin/dev/thunderid/playground/ChecksScreen.kt new file mode 100644 index 0000000..1e168ba --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/ChecksScreen.kt @@ -0,0 +1,357 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +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.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.thunderid.compose.LocalThunderID +import dev.thunderid.compose.components.presentation.auth.SignIn +import dev.thunderid.compose.components.presentation.user.UserAvatar +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChecksScreen( + checks: ChecksController, + onOpenSession: () -> Unit, +) { + val state = LocalThunderID.current + val scheme = MaterialTheme.colorScheme + val scope = rememberCoroutineScope() + + val passCount = CheckDefs.count { checks.result(it.id).status == CheckStatus.PASS } + val unavailableCount = CheckDefs.count { checks.result(it.id).status == CheckStatus.UNAVAILABLE } + val total = CheckDefs.size + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Header(signedIn = state.isSignedIn, onOpenSession = onOpenSession) + } + item { + HeroCard { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + SummaryRing(fraction = passCount / total.toFloat()) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("$passCount of $total passing", fontSize = 15.sp, color = Pg.Text) + if (unavailableCount > 0) { + Text("$unavailableCount unavailable in this SDK", fontSize = 12.sp, color = Pg.WarnSoft) + } + HealthLine(checks) + } + } + RunAllButton(running = checks.runningAll) { + scope.launch { checks.runAll(state, scheme) } + } + } + } + CheckDefs.groupBy { it.section }.forEach { (section, defs) -> + item(key = section) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionLabel(section) + ListCard { + defs.forEachIndexed { index, def -> + CheckRow(def = def, result = checks.result(def.id)) { + scope.launch { checks.run(def, state, scheme) } + } + if (index < defs.lastIndex) Hairline() + } + } + } + } + } + } + + if (checks.signInSheetOpen) { + ModalBottomSheet( + onDismissRequest = { checks.completeNativeSignIn(false, "Sheet dismissed before the flow completed") }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Pg.Surface, + ) { + Column(Modifier.padding(start = 20.dp, end = 20.dp, bottom = 32.dp)) { + Text("App-native sign-in", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = Pg.Text) + Text( + "Rendered by SignIn(applicationId) from the server's flow", + fontSize = 12.sp, + color = Pg.TextMuted, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + SignIn( + applicationId = PlaygroundSettings.applicationId, + onComplete = { + val who = state.user?.username ?: state.user?.email ?: state.user?.sub ?: "user" + checks.completeNativeSignIn(true, "Signed in as $who") + }, + onError = { message -> EventLog.error("signIn:error", message) }, + ) + } + } + } +} + +@Composable +private fun Header( + signedIn: Boolean, + onOpenSession: () -> Unit, +) { + Row( + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp, start = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ThunderIDMark(height = 28.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Playground", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = Pg.Text) + Text( + "dev.thunderid:android Β· from source", + fontSize = 11.5.sp, + fontFamily = Pg.Mono, + color = Pg.Accent, + ) + } + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .clickable(role = Role.Button, onClick = onOpenSession), + contentAlignment = Alignment.Center, + ) { + if (signedIn) { + UserAvatar(size = 40.dp) + } else { + Box( + Modifier + .fillMaxSize() + .background(Pg.Control, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text("?", color = Pg.TextMuted, fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Composable +private fun SummaryRing(fraction: Float) { + Box(Modifier.size(60.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.size(60.dp)) { + val stroke = 5.dp.toPx() + val inset = stroke / 2 + val arcSize = Size(size.width - stroke, size.height - stroke) + drawArc(Color.White.copy(alpha = 0.1f), 0f, 360f, false, Offset(inset, inset), arcSize, style = Stroke(stroke)) + drawArc(Pg.Primary, -90f, 360f * fraction, false, Offset(inset, inset), arcSize, style = Stroke(stroke)) + } + Text("${(fraction * 100).toInt()}%", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Pg.Text) + } +} + +@Composable +private fun HealthLine(checks: ChecksController) { + val (color, label) = + when (checks.health) { + Health.HEALTHY -> Pg.Pass to "healthy" + Health.UNHEALTHY -> Pg.Fail to "unreachable Β· ${checks.healthNote}" + Health.CHECKING -> Pg.Primary to "checking" + Health.UNKNOWN -> Pg.Idle to "not checked" + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + Modifier + .size(7.dp) + .background(color, CircleShape), + ) + Text( + "${PlaygroundSettings.hostLabel} Β· $label", + fontSize = 11.5.sp, + fontFamily = Pg.Mono, + color = Pg.TextMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun RunAllButton( + running: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 46.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Pg.PrimaryGradient) + .clickable(enabled = !running, role = Role.Button, onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (running) { + CircularProgressIndicator(Modifier.size(16.dp), color = Color.White, strokeWidth = 2.dp) + } else { + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) + } + Spacer(Modifier.width(8.dp)) + Text( + if (running) "Running checks" else "Run all checks", + fontSize = 14.5.sp, + fontWeight = FontWeight.Medium, + color = Color.White, + ) + } +} + +@Composable +private fun CheckRow( + def: CheckDef, + result: CheckResult, + onRun: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 60.dp) + .clickable(enabled = result.status != CheckStatus.RUNNING, role = Role.Button, onClick = onRun) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + StatusIcon(result.status) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(def.name, fontSize = 14.sp, color = Color.White.copy(alpha = 0.92f)) + Text( + def.api, + fontSize = 11.sp, + fontFamily = Pg.Mono, + color = Pg.TextFaint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (result.note.isNotBlank()) { + val noteColor = + when (result.status) { + CheckStatus.FAIL -> Pg.FailSoft + CheckStatus.PASS -> Pg.PassSoft.copy(alpha = 0.85f) + CheckStatus.UNAVAILABLE -> Pg.WarnSoft + else -> Pg.TextMuted + } + Text(result.note, fontSize = 11.5.sp, color = noteColor) + } + } + Icon( + Icons.Outlined.Refresh, + contentDescription = "Run ${def.name}", + tint = Color.White.copy(alpha = 0.35f), + modifier = Modifier.size(16.dp), + ) + } +} + +@Composable +private fun StatusIcon(status: CheckStatus) { + val (base, fg) = + when (status) { + CheckStatus.PASS -> Pg.Pass to Pg.PassSoft + CheckStatus.FAIL -> Pg.Fail to Pg.FailSoft + CheckStatus.RUNNING -> Pg.Primary to Pg.PrimarySoft + CheckStatus.UNAVAILABLE -> Pg.Warn to Pg.WarnSoft + CheckStatus.IDLE -> Pg.Idle to Pg.TextMuted + } + Box( + Modifier + .size(26.dp) + .clip(CircleShape) + .background(base.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + when (status) { + CheckStatus.RUNNING -> { + val angle by rememberInfiniteTransition(label = "spin").animateFloat( + 0f, + 360f, + infiniteRepeatable(tween(800, easing = LinearEasing), RepeatMode.Restart), + label = "angle", + ) + CircularProgressIndicator( + progress = 0.75f, + modifier = + Modifier + .size(13.dp) + .rotate(angle), + color = fg, + strokeWidth = 2.dp, + ) + } + + else -> { + val icon = + when (status) { + CheckStatus.PASS -> Icons.Outlined.Check + CheckStatus.FAIL -> Icons.Outlined.Close + CheckStatus.UNAVAILABLE -> Icons.Outlined.Block + else -> Icons.Outlined.Remove + } + Icon(icon, contentDescription = status.name.lowercase(), tint = fg, modifier = Modifier.size(15.dp)) + } + } + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/EventLog.kt b/playground/src/main/kotlin/dev/thunderid/playground/EventLog.kt new file mode 100644 index 0000000..fc42ee8 --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/EventLog.kt @@ -0,0 +1,118 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.runtime.mutableStateListOf +import dev.thunderid.android.StorageAdapter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +enum class EventKind { OK, INFO, ERROR } + +data class PlaygroundEvent( + val time: String, + val name: String, + val detail: String, + val kind: EventKind, + val source: String, +) + +/** + * The playground's event log. The Android SDK has no event emitter, so the app records what it + * can observe from outside: the calls it makes, the auth state the provider publishes, and the + * reads and writes the SDK makes through the [StorageAdapter] it is given. + */ +object EventLog { + private const val MAX_EVENTS = 300 + private val format = SimpleDateFormat("HH:mm:ss", Locale.US) + + val events = mutableStateListOf() + + fun ok( + name: String, + detail: String = "", + source: String = "sdk", + ) = add(name, detail, EventKind.OK, source) + + fun info( + name: String, + detail: String = "", + source: String = "sdk", + ) = add(name, detail, EventKind.INFO, source) + + fun error( + name: String, + detail: String = "", + source: String = "sdk", + ) = add(name, detail, EventKind.ERROR, source) + + fun clear() = events.clear() + + private fun add( + name: String, + detail: String, + kind: EventKind, + source: String, + ) { + val event = PlaygroundEvent(format.format(Date()), name, detail, kind, source) + android.os.Handler(android.os.Looper.getMainLooper()).post { + events.add(0, event) + if (events.size > MAX_EVENTS) events.removeRange(MAX_EVENTS, events.size) + } + } +} + +/** + * Wraps the storage adapter handed to the SDK so the playground can log token writes and show the + * stored tokens in the Session tab. Values are never logged, only key names. + */ +class RecordingStorageAdapter( + private val delegate: StorageAdapter, +) : StorageAdapter { + override fun store( + key: String, + value: String, + ) { + delegate.store(key, value) + EventLog.info("storage:write", key, source = "storage") + } + + override fun retrieve(key: String): String? = delegate.retrieve(key) + + override fun delete(key: String) { + delegate.delete(key) + EventLog.info("storage:delete", key, source = "storage") + } + + override fun clear() { + delegate.clear() + EventLog.info("storage:clear", "token set removed", source = "storage") + } + + /** Reads a stored value whose key ends with [suffix] (for example `refresh_token`). */ + fun find(suffix: String): String? = TOKEN_KEYS.firstOrNull { it.endsWith(suffix) }?.let(delegate::retrieve) + + /** + * Marks the stored access token as expired, so the next `getAccessToken()` call takes the SDK's + * refresh path. The SDK has no public "refresh now" operation. + */ + fun expireAccessToken(): Boolean { + val key = TOKEN_KEYS.firstOrNull { it.endsWith("token_expiry") } ?: return false + delegate.store(key, "0") + return true + } + + private companion object { + // Key names the SDK's token store writes through the adapter. + val TOKEN_KEYS = + listOf( + "thunder.access_token", + "thunder.refresh_token", + "thunder.id_token", + "thunder.token_expiry", + "thunder.scope", + ) + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/ExplorerScreen.kt b/playground/src/main/kotlin/dev/thunderid/playground/ExplorerScreen.kt new file mode 100644 index 0000000..7d66d8b --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/ExplorerScreen.kt @@ -0,0 +1,525 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.thunderid.android.User +import dev.thunderid.compose.LocalThunderID +import dev.thunderid.compose.components.actions.BaseSignInButton +import dev.thunderid.compose.components.actions.BaseSignOutButton +import dev.thunderid.compose.components.actions.BaseSignUpButton +import dev.thunderid.compose.components.actions.adapters.GitHubButton +import dev.thunderid.compose.components.actions.adapters.GoogleButton +import dev.thunderid.compose.components.actions.adapters.OutlinedTriggerButton +import dev.thunderid.compose.components.actions.adapters.PasskeyButton +import dev.thunderid.compose.components.guards.SignedIn +import dev.thunderid.compose.components.guards.SignedOut +import dev.thunderid.compose.components.presentation.auth.FlowErrorBanner +import dev.thunderid.compose.components.presentation.auth.SignIn +import dev.thunderid.compose.components.presentation.organization.BaseLanguageSwitcher +import dev.thunderid.compose.components.presentation.organization.LanguageSwitcher +import dev.thunderid.compose.components.presentation.user.BaseUserAvatar +import dev.thunderid.compose.components.presentation.user.UserAvatar + +@Composable +fun ExplorerScreen() { + var componentId by rememberSaveable { mutableStateOf(ComponentSpecs.first().id) } + val spec = ComponentSpecs.first { it.id == componentId } + var storyId by rememberSaveable(componentId) { mutableStateOf(spec.stories.first().id) } + var dark by rememberSaveable { mutableStateOf(true) } + var panel by rememberSaveable { mutableStateOf("controls") } + var taps by remember(componentId) { mutableIntStateOf(0) } + val values = remember { mutableStateMapOf>() } + val props = values[componentId] ?: (spec.defaults + spec.stories.first().preset) + val setProp: (String, Any) -> Unit = { key, value -> values[componentId] = props + (key to value) } + + Column(Modifier.fillMaxSize()) { + ExplorerHeader( + spec = spec, + dark = dark, + onPick = { componentId = it }, + onToggleTheme = { dark = !dark }, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + spec.stories.forEach { story -> + StoryChip(story.name, story.id == storyId) { + storyId = story.id + values[componentId] = props + story.preset + taps = 0 + } + } + } + Preview(dark = dark, taps = taps) { + StoryPreview(spec.id, storyId, props) { taps++ } + } + Column( + modifier = + Modifier + .weight(1f) + .offset(y = (-14).dp) + .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) + .background(Pg.Surface) + .drawBehind { drawLine(Color.White.copy(alpha = 0.1f), Offset.Zero, Offset(size.width, 0f), 2f) }, + ) { + Box( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = 36.dp, height = 4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color.White.copy(alpha = 0.2f)), + ) + } + Segmented( + options = listOf("controls" to "Controls", "code" to "Code"), + selected = panel, + onSelect = { panel = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp), + ) { + if (panel == "controls") { + spec.controls.forEach { control -> ControlRow(control, props[control.name], setProp) } + } else { + CodePanel(spec.code(storyId, props)) + } + } + } + } +} + +@Composable +private fun ExplorerHeader( + spec: ComponentSpec, + dark: Boolean, + onPick: (String) -> Unit, + onToggleTheme: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + Row( + modifier = Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(spec.group, fontSize = 11.5.sp, color = Color.White.copy(alpha = 0.5f)) + Box { + Row( + modifier = Modifier.clickable(role = Role.DropdownList) { open = true }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(spec.name, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Pg.Text) + Icon(Icons.Outlined.ExpandMore, contentDescription = "Pick a component", tint = Pg.TextMuted) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + ComponentSpecs.forEach { item -> + DropdownMenuItem( + text = { Text("${item.group} Β· ${item.name}") }, + onClick = { + open = false + onPick(item.id) + }, + ) + } + } + } + } + Box( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Pg.Control) + .clickable(role = Role.Button, onClick = onToggleTheme), + contentAlignment = Alignment.Center, + ) { + Icon( + if (dark) Icons.Outlined.DarkMode else Icons.Outlined.LightMode, + contentDescription = "Toggle preview theme", + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier.size(18.dp), + ) + } + } +} + +@Composable +private fun StoryChip( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(999.dp) + Box( + modifier = + Modifier + .defaultMinSize(minHeight = 36.dp) + .clip(shape) + .background(if (selected) Pg.Primary.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.04f)) + .border(1.dp, if (selected) Pg.Primary.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.1f), shape) + .clickable(role = Role.Tab, onClick = onClick) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = if (selected) Pg.PrimarySoft else Color.White.copy(alpha = 0.7f), + ) + } +} + +@Composable +private fun Preview( + dark: Boolean, + taps: Int, + content: @Composable () -> Unit, +) { + val background = if (dark) Color(0xFF071022) else Color(0xFFEEF1F6) + val dot = if (dark) Color.White.copy(alpha = 0.08f) else Color(0x1F0F172A) + Box( + modifier = + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .height(250.dp) + .clip(RoundedCornerShape(18.dp)) + .background(background) + .drawBehind { + val step = 16.dp.toPx() + var x = step / 2 + while (x < size.width) { + var y = step / 2 + while (y < size.height) { + drawCircle(dot, radius = 1.dp.toPx(), center = Offset(x, y)) + y += step + } + x += step + } + }.border(1.dp, Pg.Border, RoundedCornerShape(18.dp)), + ) { + MaterialTheme(colorScheme = if (dark) PlaygroundDarkScheme else PlaygroundLightScheme) { + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 34.dp), + contentAlignment = Alignment.Center, + ) { + content() + } + } + if (taps > 0) { + Text( + "onClick Γ— $taps", + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Pg.Primary.copy(alpha = 0.18f)) + .padding(horizontal = 8.dp, vertical = 5.dp), + fontSize = 11.sp, + fontFamily = Pg.Mono, + color = Pg.PrimarySoft, + ) + } + Text( + if (dark) "dark" else "light", + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(12.dp), + fontSize = 11.sp, + fontFamily = Pg.Mono, + color = if (dark) Pg.TextFaint else Color(0x990F172A), + ) + } +} + +/** Renders the real SDK component for the selected story with the current props. */ +@Composable +private fun StoryPreview( + componentId: String, + storyId: String, + props: Map, + onTap: () -> Unit, +) { + val label = props["label"] as? String ?: "" + val loading = props["isLoading"] as? Boolean ?: false + val disabled = props["disabled"] as? Boolean ?: false + when (componentId) { + "actions" -> { + // Base variants are unstyled (BasicText), so the story styles them through the modifier. + val modifier = + Modifier + .then(if (props["fullWidth"] == true) Modifier.fillMaxWidth() else Modifier) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(horizontal = 20.dp) + when (storyId) { + "signup" -> BaseSignUpButton(label = label, modifier = modifier, onClick = onTap) + "signout" -> BaseSignOutButton(label = label, isLoading = loading, modifier = modifier, onClick = onTap) + else -> BaseSignInButton(label = label, isLoading = loading, modifier = modifier, onClick = onTap) + } + } + + "trigger" -> { + when (storyId) { + "github" -> GitHubButton(label = label, isLoading = loading, onClick = onTap, disabled = disabled) + "passkey" -> PasskeyButton(label = label, isLoading = loading, onClick = onTap, disabled = disabled) + "outlined" -> OutlinedTriggerButton(label = label, isLoading = loading, onClick = onTap, disabled = disabled) + else -> GoogleButton(label = label, isLoading = loading, onClick = onTap, disabled = disabled) + } + } + + "error" -> { + FlowErrorBanner(message = props["message"] as? String ?: "") + } + + "avatar" -> { + val size = ((props["size"] as? String)?.toIntOrNull() ?: 48).dp + if (storyId == "current") { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + UserAvatar(size = size) + if (!LocalThunderID.current.isSignedIn) { + Text("Not signed in: UserAvatar falls back to Guest", fontSize = 12.sp, color = Pg.TextMuted) + } + } + } else { + BaseUserAvatar(user = User(mapOf("displayName" to (props["name"] as? String ?: ""))), size = size) + } + } + + "language" -> { + val locales = (props["locales"] as? String ?: "en-US").split(" ") + if (storyId == "styled") { + LanguageSwitcher( + locales = locales, + modifier = + Modifier + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp), + ) + } else { + BaseLanguageSwitcher(locales = locales) { available, active, select -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + available.forEach { locale -> + FilterChip(selected = locale == active, onClick = { select(locale) }, label = { Text(locale) }) + } + } + } + } + } + + "guards" -> { + val fallback = props["fallback"] as? String ?: "" + val textColor = MaterialTheme.colorScheme.onBackground + if (storyId == "signedout") { + SignedOut(fallback = { Text(fallback, color = textColor) }) { Text("Welcome, please sign in", color = textColor) } + } else { + SignedIn(fallback = { Text(fallback, color = textColor) }) { Text("Signed-in content", color = textColor) } + } + } + + "signin" -> { + val applicationId = props["applicationId"] as? String ?: "" + if (applicationId.isBlank()) { + Text("Set THUNDERID_APPLICATION_ID to render the live flow", color = Pg.TextMuted, fontSize = 13.sp) + } else { + SignIn( + applicationId = applicationId, + modifier = Modifier.fillMaxWidth(), + onComplete = { EventLog.ok("signIn:completed", "mode=app-native (explorer)") }, + onError = { EventLog.error("signIn:error", it) }, + ) + } + } + } +} + +@Composable +private fun ControlRow( + control: Control, + value: Any?, + onChange: (String, Any) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + control.name, + modifier = Modifier.weight(1f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + fontFamily = Pg.Mono, + color = Pg.Accent, + ) + if (control is Control.Bool) { + Switch( + checked = value == true, + onCheckedChange = { onChange(control.name, it) }, + colors = SwitchDefaults.colors(checkedTrackColor = Pg.Primary), + ) + } + } + when (control) { + is Control.Text -> { + BasicTextField( + value = value as? String ?: "", + onValueChange = { onChange(control.name, it) }, + singleLine = true, + textStyle = TextStyle(color = Color.White, fontSize = 15.sp), + cursorBrush = SolidColor(Pg.Primary), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) + } + + is Control.Choice -> { + Segmented( + options = control.options.map { it to it }, + selected = value as? String ?: "", + onSelect = { onChange(control.name, it) }, + ) + } + + is Control.Bool -> { + Unit + } + } + Hairline() + } +} + +@Composable +private fun CodePanel(code: String) { + val clipboard = LocalClipboardManager.current + var copied by remember(code) { mutableStateOf(false) } + Column(Modifier.padding(top = 6.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Jetpack Compose", + modifier = Modifier.weight(1f), + fontSize = 11.5.sp, + fontFamily = Pg.Mono, + color = Color.White.copy(alpha = 0.5f), + ) + Row( + modifier = + Modifier + .defaultMinSize(minHeight = 36.dp) + .clip(ControlShape) + .background(Pg.Control) + .clickable(role = Role.Button) { + clipboard.setText(AnnotatedString(code)) + copied = true + }.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = null, + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier.size(13.dp), + ) + Box(Modifier.width(6.dp)) + Text(if (copied) "Copied" else "Copy", fontSize = 12.5.sp, color = Color.White.copy(alpha = 0.8f)) + } + } + Text( + code, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Pg.CodeBg) + .border(1.dp, Pg.Border, RoundedCornerShape(12.dp)) + .horizontalScroll(rememberScrollState()) + .padding(14.dp), + fontSize = 12.5.sp, + lineHeight = 20.sp, + fontFamily = Pg.Mono, + color = Pg.Code, + softWrap = false, + ) + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/LogsScreen.kt b/playground/src/main/kotlin/dev/thunderid/playground/LogsScreen.kt new file mode 100644 index 0000000..e8534ec --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/LogsScreen.kt @@ -0,0 +1,115 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val LogFilters = + listOf( + "all" to "All", + "sdk" to "SDK", + "storage" to "Storage", + "redirect" to "Redirect", + "check" to "Checks", + "error" to "Errors", + ) + +/** Everything the playground observed from the SDK, newest first. */ +@Composable +fun LogsScreen() { + var filter by rememberSaveable { mutableStateOf("all") } + val events = + EventLog.events.filter { + when (filter) { + "all" -> true + "error" -> it.kind == EventKind.ERROR + else -> it.source == filter + } + } + + Column(Modifier.fillMaxSize()) { + Row( + Modifier.padding(start = 20.dp, end = 16.dp, top = 16.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Logs", fontSize = 26.sp, color = Pg.Text) + Text("${EventLog.events.size} events", fontSize = 12.sp, fontFamily = Pg.Mono, color = Pg.TextMuted) + } + SmallButton("Clear", { EventLog.clear() }) + } + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LogFilters.forEach { (id, label) -> + FilterChip( + selected = filter == id, + onClick = { filter = id }, + label = { Text(label) }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = Pg.Primary.copy(alpha = 0.2f), + selectedLabelColor = Pg.PrimarySoft, + labelColor = Pg.TextMuted, + ), + ) + } + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + ) { + if (events.isEmpty()) { + item { EmptyNote("Nothing logged for this filter yet.") } + } + itemsIndexed(events) { index, event -> + val top = index == 0 + val bottom = index == events.lastIndex + val shape = + RoundedCornerShape( + topStart = if (top) 14.dp else 0.dp, + topEnd = if (top) 14.dp else 0.dp, + bottomStart = if (bottom) 14.dp else 0.dp, + bottomEnd = if (bottom) 14.dp else 0.dp, + ) + Column( + Modifier + .clip(shape) + .background(Pg.Fill), + ) { + EventRow(event, showSource = true) + } + } + } + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/MainActivity.kt b/playground/src/main/kotlin/dev/thunderid/playground/MainActivity.kt new file mode 100644 index 0000000..faa600f --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/MainActivity.kt @@ -0,0 +1,100 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.remember +import dev.thunderid.android.EncryptedStorageAdapter +import dev.thunderid.android.ThunderIDClient +import dev.thunderid.android.ThunderIDConfig +import dev.thunderid.android.auth.FederatedAuthSession +import dev.thunderid.compose.ThunderIDProvider +import dev.thunderid.compose.i18n.ThunderIDI18n + +/** Values from config.properties, baked into BuildConfig at build time. */ +object PlaygroundSettings { + val baseUrl: String = BuildConfig.THUNDERID_BASE_URL + val applicationId: String = BuildConfig.THUNDERID_APPLICATION_ID + val clientId: String = BuildConfig.THUNDERID_CLIENT_ID + val afterSignInUrl: String = BuildConfig.THUNDERID_AFTER_SIGN_IN_URL + val afterSignOutUrl: String = BuildConfig.THUNDERID_AFTER_SIGN_OUT_URL + val scopes: List = BuildConfig.THUNDERID_SCOPES.split(" ").filter { it.isNotBlank() } + + val hostLabel: String + get() = baseUrl.substringAfter("://").ifBlank { "no server configured" } +} + +/** Translations the playground registers, so the translations check and LanguageSwitcher have a second locale. */ +val PlaygroundBundles: Map> = + mapOf( + "fr-FR" to + mapOf( + "signIn.button" to "Se connecter", + "signUp.button" to "CrΓ©er un compte", + "signOut.button" to "Se dΓ©connecter", + ), + "es-ES" to + mapOf( + "signIn.button" to "Iniciar sesiΓ³n", + "signUp.button" to "Registrarse", + "signOut.button" to "Cerrar sesiΓ³n", + ), + ) + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + ) + + val storage = RecordingStorageAdapter(EncryptedStorageAdapter(applicationContext)) + val config = + ThunderIDConfig( + baseUrl = PlaygroundSettings.baseUrl, + clientId = PlaygroundSettings.clientId.takeIf { it.isNotBlank() }, + applicationId = PlaygroundSettings.applicationId.takeIf { it.isNotBlank() }, + afterSignInUrl = PlaygroundSettings.afterSignInUrl, + afterSignOutUrl = PlaygroundSettings.afterSignOutUrl, + scopes = PlaygroundSettings.scopes, + storage = storage, + allowInsecureConnections = BuildConfig.DEBUG, + ) + + setContent { + val client = remember { ThunderIDClient() } + val i18n = + remember { + ThunderIDI18n(bundles = PlaygroundBundles, storageKey = "playground_locale", context = applicationContext) + } + PlaygroundTheme { + ThunderIDProvider(config = config, client = client, i18n = i18n) { + PlaygroundApp(storage = storage) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.data?.let { uri -> + val params = uri.queryParameterNames.joinToString("&") { "$it=…" } + EventLog.info("callback:received", "${uri.scheme}://${uri.host}?$params", source = "redirect") + FederatedAuthSession.onRedirect(uri) + } + } + + override fun onResume() { + super.onResume() + // A callback arrives in onNewIntent before this runs, so a sign-in still pending here + // means the user dismissed the browser. + FederatedAuthSession.cancelIfPending() + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/PlaygroundApp.kt b/playground/src/main/kotlin/dev/thunderid/playground/PlaygroundApp.kt new file mode 100644 index 0000000..a2a1910 --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/PlaygroundApp.kt @@ -0,0 +1,137 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.FormatListBulleted +import androidx.compose.material.icons.outlined.GridView +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.thunderid.compose.LocalThunderID + +enum class Tab( + val label: String, + val icon: ImageVector, +) { + CHECKS("Checks", Icons.Outlined.CheckCircle), + COMPONENTS("Components", Icons.Outlined.GridView), + SESSION("Session", Icons.Outlined.Key), + LOGS("Logs", Icons.Outlined.FormatListBulleted), +} + +@Composable +fun PlaygroundApp(storage: RecordingStorageAdapter) { + val state = LocalThunderID.current + val context = LocalContext.current + val checks = remember { ChecksController(context.applicationContext, storage) } + var tab by rememberSaveable { mutableStateOf(Tab.CHECKS) } + + // The SDK has no event stream, so log the auth state the provider publishes. + LaunchedEffect(state) { + snapshotFlow { Triple(state.isInitialized, state.user?.sub, state.error) }.collect { (ready, sub, error) -> + when { + error != null -> EventLog.error("state:error", error) + !ready -> EventLog.info("sdk:initializing", PlaygroundSettings.hostLabel) + sub != null -> EventLog.ok("state:signedIn", "sub=$sub") + else -> EventLog.info("state:signedOut", "isSignedIn=false") + } + } + } + LaunchedEffect(Unit) { checks.checkHealth() } + + Column( + Modifier + .fillMaxSize() + .background(Pg.Background) + .windowInsetsPadding(WindowInsets.statusBars), + ) { + Box(Modifier.weight(1f)) { + when (tab) { + Tab.CHECKS -> ChecksScreen(checks = checks, onOpenSession = { tab = Tab.SESSION }) + Tab.COMPONENTS -> ExplorerScreen() + Tab.SESSION -> SessionScreen(storage = storage, onOpenChecks = { tab = Tab.CHECKS }) + Tab.LOGS -> LogsScreen() + } + } + TabBar(selected = tab, onSelect = { tab = it }) + } +} + +@Composable +private fun TabBar( + selected: Tab, + onSelect: (Tab) -> Unit, +) { + Column(Modifier.background(Color(0xF5060D1C))) { + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Pg.Border), + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.SpaceAround, + ) { + Tab.entries.forEach { item -> + val on = item == selected + val color = if (on) Pg.PrimarySoft else Color.White.copy(alpha = 0.55f) + Column( + modifier = + Modifier + .defaultMinSize(minWidth = 64.dp, minHeight = 48.dp) + .semantics { this.selected = on } + .clickable(role = Role.Tab) { onSelect(item) } + .padding(top = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(item.icon, contentDescription = null, tint = color, modifier = Modifier.size(22.dp)) + Text(item.label, fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color) + } + } + } + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/SessionScreen.kt b/playground/src/main/kotlin/dev/thunderid/playground/SessionScreen.kt new file mode 100644 index 0000000..256efac --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/SessionScreen.kt @@ -0,0 +1,438 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.thunderid.compose.LocalThunderID +import dev.thunderid.compose.components.presentation.user.UserAvatar +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private data class TokenView( + val id: String, + val name: String, + val meta: String, + val value: String, +) + +@Composable +fun SessionScreen( + storage: RecordingStorageAdapter, + onOpenChecks: () -> Unit, +) { + val state = LocalThunderID.current + val scope = rememberCoroutineScope() + var tab by rememberSaveable { mutableStateOf("tokens") } + var accessToken by remember { mutableStateOf(null) } + var version by remember { mutableStateOf(0) } + var now by remember { mutableLongStateOf(System.currentTimeMillis() / 1000) } + + LaunchedEffect(state.user, version) { + accessToken = if (state.isSignedIn) runCatching { state.client.getAccessToken() }.getOrNull() else null + } + LaunchedEffect(Unit) { + while (true) { + now = System.currentTimeMillis() / 1000 + delay(1000) + } + } + + val claims = + remember(accessToken) { + accessToken?.let { token -> runCatching { state.client.decodeJwtToken(token) }.getOrNull() }.orEmpty() + } + val idToken = remember(accessToken) { storage.find("id_token") } + val refreshToken = remember(accessToken) { storage.find("refresh_token") } + val exp = (claims["exp"] as? Number)?.toLong() + val iat = (claims["iat"] as? Number)?.toLong() + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + Modifier.padding(start = 4.dp, end = 4.dp, top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Session", modifier = Modifier.weight(1f), fontSize = 26.sp, color = Pg.Text) + if (state.isSignedIn) { + StatusPill("Signed in", Pg.PassSoft, Pg.Pass.copy(alpha = 0.12f)) + } else { + StatusPill("Signed out", Pg.TextMuted, Pg.Control) + } + } + + if (!state.isSignedIn) { + SignedOutCard(state.error, onOpenChecks) + } else { + HeroCard { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + UserAvatar(size = 48.dp) + Column(Modifier.weight(1f)) { + val user = state.user + val name = + listOfNotNull(user?.get("given_name") as? String, user?.get("family_name") as? String) + .joinToString(" ") + .ifBlank { user?.displayName ?: user?.username ?: user?.sub ?: "User" } + Text(name, fontSize = 16.sp, color = Pg.Text) + Text( + listOfNotNull(user?.email, state.client.getConfiguration().organizationHandle).joinToString(" Β· ").ifBlank { + "sub ${user?.sub}" + }, + fontSize = 12.5.sp, + color = Pg.TextMuted, + ) + } + } + TokenCountdown(exp = exp, iat = iat, now = now) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ActionButton("Refresh token", Pg.Control, Color.White.copy(alpha = 0.1f), Color.White, Modifier.weight(1f)) { + scope.launch { + val before = accessToken + val refreshed = + runCatching { + if (storage.find("refresh_token") == null) error("No refresh token stored for this session") + storage.expireAccessToken() + state.client.getAccessToken() + } + refreshed + .onSuccess { + EventLog.ok("token:refreshed", if (it != before) "new access token" else "token unchanged") + }.onFailure { EventLog.error("token:refreshFailed", it.message ?: "refresh failed") } + version++ + } + } + ActionButton("Sign out", Color.Transparent, Pg.Fail.copy(alpha = 0.45f), Color(0xFFF87171), Modifier.weight(1f)) { + scope.launch { + EventLog.info("signOut:requested", "client.signOut()") + runCatching { state.client.signOut() } + .onSuccess { EventLog.ok("signOut:completed", it) } + .onFailure { EventLog.error("signOut:failed", it.message ?: "") } + state.refresh() + tab = "events" + } + } + } + } + } + + Segmented( + options = listOf("tokens" to "Tokens", "claims" to "Claims", "events" to "Events"), + selected = tab, + onSelect = { tab = it }, + ) + + when (tab) { + "tokens" -> { + val tokens = + listOfNotNull( + accessToken?.let { TokenView("access", "Access token", jwtMeta(state, it), it) }, + idToken?.let { TokenView("id", "ID token", jwtMeta(state, it), it) }, + refreshToken?.let { TokenView("refresh", "Refresh token", "opaque Β· rotates", it) }, + ) + TokensPanel(tokens) + } + + "claims" -> { + ClaimsPanel(claims.ifEmpty { state.user?.claims.orEmpty() }) + } + + else -> { + EventsPanel(EventLog.events.filter { it.source != "check" }) + } + } + } +} + +private fun jwtMeta( + state: dev.thunderid.compose.ThunderIDState, + token: String, +): String { + if (token.count { it == '.' } != 2) return "opaque" + val header = + runCatching { + val part = token.substringBefore('.') + String(android.util.Base64.decode(part, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)) + }.getOrNull() + val alg = header?.let { Regex("\"alg\"\\s*:\\s*\"([^\"]+)\"").find(it)?.groupValues?.get(1) } + val decodes = runCatching { state.client.decodeJwtToken(token) }.isSuccess + return if (decodes) "JWT Β· ${alg ?: "unknown alg"}" else "opaque" +} + +@Composable +private fun SignedOutCard( + error: String?, + onOpenChecks: () -> Unit, +) { + HeroCard { + Text("No active session", fontSize = 16.sp, color = Pg.Text) + Text( + error?.let { "SDK error: $it" } + ?: "Sign in from the Checks tab with the system browser or the app-native flow to inspect tokens and claims.", + fontSize = 13.sp, + color = if (error != null) Pg.FailSoft else Pg.TextMuted, + ) + ActionButton("Go to checks", Pg.Primary, Color.Transparent, Color.White, Modifier.fillMaxWidth(), onOpenChecks) + } +} + +@Composable +private fun TokenCountdown( + exp: Long?, + iat: Long?, + now: Long, +) { + val remaining = exp?.let { (it - now).coerceAtLeast(0) } + val lifetime = if (exp != null && iat != null && exp > iat) exp - iat else 3600 + val fraction = remaining?.let { (it.toFloat() / lifetime).coerceIn(0f, 1f) } ?: 0f + val text = + when { + remaining == null -> "no exp claim" + remaining == 0L -> "expired" + else -> "expires in %02d:%02d".format(remaining / 60, remaining % 60) + } + Column { + Row(Modifier.padding(bottom = 7.dp)) { + Text("Access token", modifier = Modifier.weight(1f), fontSize = 12.5.sp, color = Color.White.copy(alpha = 0.6f)) + Text(text, fontSize = 12.5.sp, fontFamily = Pg.Mono, color = Color.White.copy(alpha = 0.6f)) + } + Box( + Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color.White.copy(alpha = 0.08f)), + ) { + Box( + Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .clip(RoundedCornerShape(999.dp)) + .background(Pg.TokenGradient), + ) + } + } +} + +@Composable +private fun ActionButton( + label: String, + background: Color, + border: Color, + color: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = + modifier + .defaultMinSize(minHeight = 44.dp) + .clip(RoundedCornerShape(10.dp)) + .background(background) + .border(1.dp, border, RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = color) + } +} + +@Composable +private fun TokensPanel(tokens: List) { + val clipboard = LocalClipboardManager.current + val revealed = remember { mutableStateMapOf() } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (tokens.isEmpty()) EmptyNote("No tokens stored.") + tokens.forEach { token -> + val shown = revealed[token.id] == true + Column( + Modifier + .fillMaxWidth() + .clip(ListShape) + .background(Pg.Fill) + .border(1.dp, Pg.Border, ListShape) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(token.name, modifier = Modifier.weight(1f), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = Pg.Text) + Text(token.meta, fontSize = 11.5.sp, fontFamily = Pg.Mono, color = Color.White.copy(alpha = 0.5f)) + } + Text( + if (shown) token.value else token.value.take(12) + "β€’".repeat(23), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Pg.CodeBg) + .padding(horizontal = 12.dp, vertical = 10.dp), + fontSize = 12.sp, + lineHeight = 18.sp, + fontFamily = Pg.Mono, + color = Pg.Code, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton(if (shown) "Hide" else "Reveal", { revealed[token.id] = !shown }) + SmallButton("Copy", { + clipboard.setText(AnnotatedString(token.value)) + EventLog.info("token:copied", token.name, source = "playground") + }) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + Icons.Outlined.Lock, + contentDescription = null, + tint = Color.White.copy(alpha = 0.5f), + modifier = + Modifier + .padding(top = 2.dp) + .size(14.dp), + ) + Text( + "Stored in EncryptedSharedPreferences backed by Android Keystore.", + fontSize = 12.sp, + lineHeight = 18.sp, + color = Color.White.copy(alpha = 0.5f), + ) + } + } +} + +@Composable +private fun ClaimsPanel(claims: Map) { + if (claims.isEmpty()) { + EmptyNote("No claims. Sign in to decode the access token.") + return + } + ListCard { + claims.entries.sortedBy { it.key }.forEachIndexed { index, (key, value) -> + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(key, fontSize = 12.5.sp, fontWeight = FontWeight.Medium, fontFamily = Pg.Mono, color = Pg.Accent) + Text( + value.toString(), + modifier = Modifier.weight(1f), + fontSize = 12.5.sp, + fontFamily = Pg.Mono, + color = Color.White.copy(alpha = 0.8f), + textAlign = TextAlign.End, + ) + } + if (index < claims.size - 1) Hairline() + } + } +} + +@Composable +fun EventsPanel(events: List) { + if (events.isEmpty()) { + EmptyNote("No events yet.") + return + } + ListCard { + events.forEach { event -> EventRow(event, showSource = false) } + } +} + +@Composable +fun EventRow( + event: PlaygroundEvent, + showSource: Boolean, +) { + val color = + when (event.kind) { + EventKind.OK -> Pg.PassSoft + EventKind.INFO -> Pg.PrimarySoft + EventKind.ERROR -> Pg.FailSoft + } + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + event.name, + modifier = Modifier.weight(1f), + fontSize = 12.5.sp, + fontWeight = FontWeight.Medium, + fontFamily = Pg.Mono, + color = color, + ) + if (showSource) Text(event.source, fontSize = 11.sp, fontFamily = Pg.Mono, color = Pg.TextFaint) + Text(event.time, fontSize = 11.5.sp, fontFamily = Pg.Mono, color = Color.White.copy(alpha = 0.4f)) + } + if (event.detail.isNotBlank()) { + Text(event.detail, fontSize = 11.5.sp, fontFamily = Pg.Mono, color = Pg.TextMuted) + } + } + Hairline() +} + +@Composable +fun EmptyNote(text: String) { + Text( + text, + modifier = + Modifier + .fillMaxWidth() + .clip(ListShape) + .background(Pg.Fill) + .border(1.dp, Pg.Border, ListShape) + .padding(16.dp), + fontSize = 13.sp, + color = Pg.TextMuted, + ) +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/Stories.kt b/playground/src/main/kotlin/dev/thunderid/playground/Stories.kt new file mode 100644 index 0000000..505475f --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/Stories.kt @@ -0,0 +1,213 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +/** A prop the Controls panel can edit. */ +sealed class Control( + val name: String, +) { + class Text( + name: String, + ) : Control(name) + + class Bool( + name: String, + ) : Control(name) + + class Choice( + name: String, + val options: List, + ) : Control(name) +} + +data class Story( + val id: String, + val name: String, + val preset: Map, +) + +data class ComponentSpec( + val id: String, + val group: String, + val name: String, + val controls: List, + val defaults: Map, + val stories: List, + val code: (story: String, props: Map) -> String, +) + +private fun Map.text(key: String) = this[key] as? String ?: "" + +private fun Map.flag(key: String) = this[key] as? Boolean ?: false + +private fun quote(value: String) = "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + +private const val BASE_MODIFIER = + "Modifier\n .background(Color.White, RoundedCornerShape(12.dp))\n .padding(horizontal = 20.dp)" + +val ComponentSpecs: List = + listOf( + ComponentSpec( + id = "actions", + group = "Actions", + name = "Sign-in actions", + controls = listOf(Control.Text("label"), Control.Bool("isLoading"), Control.Bool("fullWidth")), + defaults = mapOf("label" to "Sign in", "isLoading" to false, "fullWidth" to false), + stories = + listOf( + Story("signin", "BaseSignInButton", mapOf("label" to "Sign in", "isLoading" to false)), + Story("signup", "BaseSignUpButton", mapOf("label" to "Create account", "isLoading" to false)), + Story("signout", "BaseSignOutButton", mapOf("label" to "Sign out", "isLoading" to false)), + Story("loading", "Loading", mapOf("label" to "Sign in", "isLoading" to true)), + ), + code = { story, props -> + val fn = + when (story) { + "signup" -> "BaseSignUpButton" + "signout" -> "BaseSignOutButton" + else -> "BaseSignInButton" + } + val modifier = + if (props.flag( + "fullWidth", + ) + ) { + BASE_MODIFIER.replace("Modifier\n", "Modifier\n .fillMaxWidth()\n") + } else { + BASE_MODIFIER + } + buildString { + append("$fn(\n label = ${quote(props.text("label"))},\n") + if (fn != "BaseSignUpButton" && props.flag("isLoading")) append(" isLoading = true,\n") + append(" modifier = $modifier,\n") + append(")") + append(" {\n // handle tap\n}") + } + }, + ), + ComponentSpec( + id = "trigger", + group = "Actions", + name = "Social sign-in buttons", + controls = listOf(Control.Text("label"), Control.Bool("isLoading"), Control.Bool("disabled")), + defaults = mapOf("label" to "Continue with Google", "isLoading" to false, "disabled" to false), + stories = + listOf( + Story("google", "GoogleButton", mapOf("label" to "Continue with Google")), + Story("github", "GitHubButton", mapOf("label" to "Continue with GitHub")), + Story("passkey", "PasskeyButton", mapOf("label" to "Sign in with a passkey")), + Story("outlined", "OutlinedTriggerButton", mapOf("label" to "Continue with SSO")), + ), + code = { story, props -> + val fn = + when (story) { + "github" -> "GitHubButton" + "passkey" -> "PasskeyButton" + "outlined" -> "OutlinedTriggerButton" + else -> "GoogleButton" + } + buildString { + append("$fn(\n label = ${quote(props.text("label"))},\n") + append(" isLoading = ${props.flag("isLoading")},\n") + append(" onClick = { /* handle tap */ },\n") + if (props.flag("disabled")) append(" disabled = true,\n") + append(")") + } + }, + ), + ComponentSpec( + id = "error", + group = "Feedback", + name = "FlowErrorBanner", + controls = listOf(Control.Text("message")), + defaults = mapOf("message" to "The username or password is incorrect."), + stories = + listOf( + Story("credentials", "Invalid credentials", mapOf("message" to "The username or password is incorrect.")), + Story("locked", "Account locked", mapOf("message" to "Your account is locked. Try again in 15 minutes.")), + Story("network", "Network", mapOf("message" to "Could not reach the server. Check your connection.")), + ), + code = { _, props -> "FlowErrorBanner(\n message = ${quote(props.text("message"))},\n)" }, + ), + ComponentSpec( + id = "avatar", + group = "User", + name = "UserAvatar", + controls = listOf(Control.Text("name"), Control.Choice("size", listOf("32", "48", "72"))), + defaults = mapOf("name" to "Liam Chen", "size" to "48"), + stories = + listOf( + Story("initials", "Initials", mapOf("name" to "Liam Chen")), + Story("single", "Single name", mapOf("name" to "Ada")), + Story("current", "Signed-in user", emptyMap()), + ), + code = { story, props -> + if (story == "current") { + "UserAvatar(size = ${props.text("size")}.dp)" + } else { + "BaseUserAvatar(\n user = User(mapOf(\"displayName\" to ${quote(props.text("name"))})),\n" + + " size = ${props.text("size")}.dp,\n)" + } + }, + ), + ComponentSpec( + id = "language", + group = "Presentation", + name = "LanguageSwitcher", + controls = listOf(Control.Choice("locales", listOf("en-US", "en-US fr-FR", "en-US fr-FR es-ES"))), + defaults = mapOf("locales" to "en-US fr-FR es-ES"), + stories = + listOf( + Story("chips", "Base with chips", emptyMap()), + Story("styled", "Styled", emptyMap()), + ), + code = { story, props -> + val locales = props.text("locales").split(" ").joinToString(", ") { quote(it) } + if (story == "styled") { + "LanguageSwitcher(\n locales = listOf($locales),\n modifier = Modifier.background(Color.White),\n)" + } else { + "BaseLanguageSwitcher(locales = listOf($locales)) { available, active, select ->\n" + + " Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n" + + " available.forEach { locale ->\n" + + " FilterChip(\n" + + " selected = locale == active,\n" + + " onClick = { select(locale) },\n" + + " label = { Text(locale) },\n" + + " )\n" + + " }\n" + + " }\n" + + "}" + } + }, + ), + ComponentSpec( + id = "guards", + group = "Guards", + name = "SignedIn / SignedOut", + controls = listOf(Control.Text("fallback")), + defaults = mapOf("fallback" to "Sign in to see this"), + stories = + listOf( + Story("signedin", "SignedIn", emptyMap()), + Story("signedout", "SignedOut", emptyMap()), + ), + code = { story, props -> + val guard = if (story == "signedout") "SignedOut" else "SignedIn" + val content = if (story == "signedout") "Welcome, please sign in" else "Signed-in content" + "$guard(\n fallback = { Text(${quote(props.text("fallback"))}) },\n) {\n Text(${quote(content)})\n}" + }, + ), + ComponentSpec( + id = "signin", + group = "Flows", + name = "SignIn", + controls = listOf(Control.Text("applicationId")), + defaults = mapOf("applicationId" to PlaygroundSettings.applicationId), + stories = listOf(Story("live", "Live flow", emptyMap())), + code = { _, props -> + "SignIn(\n applicationId = ${quote(props.text("applicationId"))},\n" + + " onComplete = { /* signed in */ },\n onError = { message -> /* show message */ },\n)" + }, + ), + ) diff --git a/playground/src/main/kotlin/dev/thunderid/playground/Theme.kt b/playground/src/main/kotlin/dev/thunderid/playground/Theme.kt new file mode 100644 index 0000000..9cde7e7 --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/Theme.kt @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily + +object Pg { + val Background = Color(0xFF050B18) + val Surface = Color(0xFF0A1428) + val CardTop = Color(0xFA09142C) + val CardBottom = Color(0xFF040A18) + val Primary = Color(0xFF3688FF) + val PrimaryDeep = Color(0xFF2060C8) + val PrimarySoft = Color(0xFF9CC4FF) + val Accent = Color(0xFF8BF9FA) + val Pass = Color(0xFF34D399) + val PassSoft = Color(0xFF6EE7B7) + val Fail = Color(0xFFEF4444) + val FailSoft = Color(0xFFFCA5A5) + val Warn = Color(0xFFF59E0B) + val WarnSoft = Color(0xFFFCD34D) + val Idle = Color(0xFF94A3B8) + val Text = Color.White + val TextMuted = Color.White.copy(alpha = 0.55f) + val TextFaint = Color.White.copy(alpha = 0.45f) + val Border = Color.White.copy(alpha = 0.08f) + val Fill = Color.White.copy(alpha = 0.025f) + val Control = Color.White.copy(alpha = 0.06f) + val CodeBg = Color.Black.copy(alpha = 0.35f) + val Code = Color(0xFFCFE3FF) + val Mono = FontFamily.Monospace + + val PrimaryGradient = Brush.horizontalGradient(listOf(Primary, PrimaryDeep)) + val CardGradient = Brush.linearGradient(listOf(CardTop, CardBottom)) + val TokenGradient = Brush.horizontalGradient(listOf(Primary, Accent)) +} + +/** Material 3 dark scheme in the ThunderID palette. The SDK's Compose components read these colors. */ +val PlaygroundDarkScheme = + darkColorScheme( + primary = Pg.Primary, + onPrimary = Color.White, + secondary = Pg.Accent, + background = Pg.Background, + onBackground = Color.White, + surface = Pg.Surface, + onSurface = Color.White, + surfaceVariant = Color(0xFF13203A), + onSurfaceVariant = Color.White.copy(alpha = 0.7f), + outline = Color.White.copy(alpha = 0.2f), + outlineVariant = Color.White.copy(alpha = 0.1f), + error = Pg.FailSoft, + ) + +val PlaygroundLightScheme = + lightColorScheme( + primary = Pg.Primary, + secondary = Color(0xFF345F91), + background = Color(0xFFEEF1F6), + surface = Color.White, + surfaceVariant = Color(0xFFF2F2F2), + onSurfaceVariant = Color(0xFF49454F), + outlineVariant = Color(0x1F1D1B20), + ) + +@Composable +fun PlaygroundTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = PlaygroundDarkScheme, content = content) +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/ThunderIDMark.kt b/playground/src/main/kotlin/dev/thunderid/playground/ThunderIDMark.kt new file mode 100644 index 0000000..a59ea2f --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/ThunderIDMark.kt @@ -0,0 +1,75 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** The ThunderID mark, with the top bar lightened for the playground's dark background. */ +@Composable +fun ThunderIDMark( + height: Dp = 26.dp, + modifier: Modifier = Modifier, +) { + Canvas( + modifier = + modifier + .size(width = height * (207f / 257f), height = height) + .semantics { contentDescription = "ThunderID" }, + ) { + val scaleX = size.width / 207f + val scaleY = size.height / 257f + + fun sx(x: Float) = x * scaleX + + fun sy(y: Float) = y * scaleY + + val topBar = + Path().apply { + moveTo(sx(55.4763f), sy(26.4391f)) + lineTo(sx(58.8866f), sy(0f)) + lineTo(sx(0f), sy(0f)) + lineTo(sx(0f), sy(26.4391f)) + close() + } + drawPath(topBar, Color(0xFFE8F4FF)) + + val leftBar = + Path().apply { + moveTo(sx(39.8438f), sy(147.407f)) + lineTo(sx(49.5455f), sy(72.2839f)) + lineTo(sx(0f), sy(72.2839f)) + lineTo(sx(0f), sy(256.743f)) + lineTo(sx(60.5602f), sy(256.743f)) + lineTo(sx(80.048f), sy(147.407f)) + close() + } + drawPath(leftBar, Pg.Primary) + + val bolt = + Path().apply { + moveTo(sx(192.42f), sy(59.361f)) + cubicTo(sx(182.782f), sy(40.2307f), sx(168.929f), sy(25.5705f), sx(150.903f), sy(15.3381f)) + cubicTo(sx(145.501f), sy(12.2662f), sx(139.761f), sy(9.6605f), sx(133.703f), sy(7.5208f)) + lineTo(sx(115.401f), sy(103.702f)) + lineTo(sx(159.757f), sy(103.702f)) + lineTo(sx(76.2987f), sy(256.743f)) + lineTo(sx(83.3735f), sy(256.743f)) + cubicTo(sx(109.449f), sy(256.743f), sx(131.69f), sy(251.574f), sx(150.14f), sy(241.236f)) + cubicTo(sx(168.569f), sy(230.897f), sx(182.634f), sy(216.131f), sx(192.356f), sy(196.959f)) + cubicTo(sx(202.058f), sy(177.765f), sx(206.909f), sy(154.8f), sx(206.909f), sy(128.043f)) + cubicTo(sx(206.909f), sy(101.286f), sx(202.079f), sy(78.5123f), sx(192.441f), sy(59.3821f)) + close() + } + drawPath(bolt, Pg.Primary) + } +} diff --git a/playground/src/main/kotlin/dev/thunderid/playground/Widgets.kt b/playground/src/main/kotlin/dev/thunderid/playground/Widgets.kt new file mode 100644 index 0000000..696338d --- /dev/null +++ b/playground/src/main/kotlin/dev/thunderid/playground/Widgets.kt @@ -0,0 +1,174 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.playground + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +val CardShape = RoundedCornerShape(16.dp) +val ListShape = RoundedCornerShape(14.dp) +val ControlShape = RoundedCornerShape(8.dp) + +/** Gradient summary card used at the top of Checks and Session. */ +@Composable +fun HeroCard(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(CardShape) + .background(Pg.CardGradient) + .border(1.dp, Color.White.copy(alpha = 0.09f), CardShape) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content, + ) +} + +/** Bordered list container with hairline rows. */ +@Composable +fun ListCard(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(ListShape) + .background(Pg.Fill) + .border(1.dp, Pg.Border, ListShape), + content = content, + ) +} + +@Composable +fun SectionLabel(text: String) { + Text( + text = text.uppercase(), + modifier = Modifier.padding(start = 4.dp, top = 6.dp), + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.9.sp, + color = Pg.TextFaint, + ) +} + +/** Segmented control: equal-width options in a tinted track. */ +@Composable +fun Segmented( + options: List>, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + options.forEach { (id, label) -> + val on = id == selected + Box( + modifier = + Modifier + .weight(1f) + .defaultMinSize(minHeight = 36.dp) + .clip(ControlShape) + .background(if (on) Pg.Primary.copy(alpha = 0.2f) else Color.Transparent) + .clickable(role = Role.RadioButton) { onSelect(id) }, + contentAlignment = Alignment.Center, + ) { + Text( + label, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = if (on) Color.White else Color.White.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +fun SmallButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .defaultMinSize(minHeight = 36.dp) + .clip(ControlShape) + .background(Pg.Control) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, fontSize = 12.5.sp, color = Color.White.copy(alpha = 0.85f)) + } +} + +/** Pill with a leading status dot. */ +@Composable +fun StatusPill( + text: String, + color: Color, + background: Color, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(background) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(color), + ) + Text(text, fontSize = 12.sp, fontWeight = FontWeight.Medium, color = color) + } +} + +@Composable +fun Hairline() { + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.06f)), + ) +} diff --git a/samples/quickstart/src/main/res/drawable-night/ic_launcher_foreground.png b/playground/src/main/res/drawable-night/ic_launcher_foreground.png similarity index 100% rename from samples/quickstart/src/main/res/drawable-night/ic_launcher_foreground.png rename to playground/src/main/res/drawable-night/ic_launcher_foreground.png diff --git a/samples/quickstart/src/main/res/drawable/ic_launcher_foreground.png b/playground/src/main/res/drawable/ic_launcher_foreground.png similarity index 100% rename from samples/quickstart/src/main/res/drawable/ic_launcher_foreground.png rename to playground/src/main/res/drawable/ic_launcher_foreground.png diff --git a/samples/quickstart/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/playground/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from samples/quickstart/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to playground/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/samples/quickstart/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/playground/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from samples/quickstart/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to playground/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/samples/quickstart/src/main/res/mipmap-hdpi/ic_launcher.png b/playground/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-hdpi/ic_launcher.png rename to playground/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/samples/quickstart/src/main/res/mipmap-hdpi/ic_launcher_round.png b/playground/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-hdpi/ic_launcher_round.png rename to playground/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/samples/quickstart/src/main/res/mipmap-mdpi/ic_launcher.png b/playground/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-mdpi/ic_launcher.png rename to playground/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/samples/quickstart/src/main/res/mipmap-mdpi/ic_launcher_round.png b/playground/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-mdpi/ic_launcher_round.png rename to playground/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/samples/quickstart/src/main/res/mipmap-xhdpi/ic_launcher.png b/playground/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-xhdpi/ic_launcher.png rename to playground/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/samples/quickstart/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/playground/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-xhdpi/ic_launcher_round.png rename to playground/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/samples/quickstart/src/main/res/mipmap-xxhdpi/ic_launcher.png b/playground/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to playground/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/samples/quickstart/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/playground/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-xxhdpi/ic_launcher_round.png rename to playground/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/samples/quickstart/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/playground/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to playground/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/samples/quickstart/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/playground/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from samples/quickstart/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png rename to playground/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/samples/quickstart/src/main/res/values-night/ic_launcher_colors.xml b/playground/src/main/res/values-night/ic_launcher_colors.xml similarity index 100% rename from samples/quickstart/src/main/res/values-night/ic_launcher_colors.xml rename to playground/src/main/res/values-night/ic_launcher_colors.xml diff --git a/samples/quickstart/src/main/res/values/ic_launcher_colors.xml b/playground/src/main/res/values/ic_launcher_colors.xml similarity index 100% rename from samples/quickstart/src/main/res/values/ic_launcher_colors.xml rename to playground/src/main/res/values/ic_launcher_colors.xml diff --git a/playground/src/main/res/values/themes.xml b/playground/src/main/res/values/themes.xml new file mode 100644 index 0000000..deac718 --- /dev/null +++ b/playground/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + #050B18 + + + diff --git a/samples/quickstart/src/main/res/xml/network_security_config.xml b/playground/src/main/res/xml/network_security_config.xml similarity index 100% rename from samples/quickstart/src/main/res/xml/network_security_config.xml rename to playground/src/main/res/xml/network_security_config.xml diff --git a/samples/quickstart/README.md b/samples/quickstart/README.md deleted file mode 100644 index 1fc0573..0000000 --- a/samples/quickstart/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# ThunderID Android Quickstart - -ThunderID Android Quickstart demonstrates the full authentication lifecycle using the `dev.thunderid:android` SDK on Android. - -**Flow demonstrated:** -1. App opens β†’ unauthenticated state (sign-in screen) -2. User initiates sign-in / sign-up β†’ SDK starts app-native Flow Execution -3. User completes the flow -4. Sign-in β†’ authenticated state with profile information, token debugging, and sign-out button. - Sign-up creates the account but does not start a session, so it returns to the landing screen - and the new credentials have to be used to sign in. -5. User taps Sign Out β†’ session terminated, returns to sign-in screen - -## Setup & Run - -See [Try the Android Sample App](https://thunderid.dev/docs/v1.0.x/sdks/android/guides/try-the-sample-app) -in the Android SDK docs for prerequisites, configuration, attestation, passkeys, and run instructions. diff --git a/samples/quickstart/assetlinks.json.example b/samples/quickstart/assetlinks.json.example deleted file mode 100644 index 49e55f8..0000000 --- a/samples/quickstart/assetlinks.json.example +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "relation": ["delegate_permission/common.get_login_creds"], - "target": { - "namespace": "android_app", - "package_name": "dev.thunderid.Quickstart", - "sha256_cert_fingerprints": [ - "" - ] - } - } -] diff --git a/samples/quickstart/build.gradle.kts b/samples/quickstart/build.gradle.kts deleted file mode 100644 index b0a1c10..0000000 --- a/samples/quickstart/build.gradle.kts +++ /dev/null @@ -1,66 +0,0 @@ -import java.util.Properties - -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") -} - -android { - namespace = "dev.thunderid.quickstart" - compileSdk = 34 - - defaultConfig { - applicationId = "dev.thunderid.Quickstart" - minSdk = 26 - targetSdk = 34 - versionCode = 1 - versionName = "1.0.0" - - // Load ThunderID config from config.properties (gitignored). - // Copy config.properties.example to config.properties and fill in your values. - val configProps = Properties() - val configFile = rootProject.file("config.properties") - if (configFile.exists()) configFile.inputStream().use { configProps.load(it) } - fun config(key: String) = configProps.getProperty(key) ?: System.getenv(key) ?: "" - val baseUrl = config("THUNDERID_BASE_URL") - val appId = config("THUNDERID_APPLICATION_ID") - val attestationEnabled = config("THUNDERID_ATTESTATION_ENABLED") - val cloudProjectNumber = config("THUNDERID_CLOUD_PROJECT_NUMBER") - buildConfigField("String", "THUNDERID_BASE_URL", "\"$baseUrl\"") - buildConfigField("String", "THUNDERID_APPLICATION_ID", "\"$appId\"") - buildConfigField("boolean", "THUNDERID_ATTESTATION_ENABLED", attestationEnabled.toBoolean().toString()) - buildConfigField("long", "THUNDERID_CLOUD_PROJECT_NUMBER", "${cloudProjectNumber.toLongOrNull() ?: 0L}L") - } - - buildFeatures { - compose = true - buildConfig = true - } - - composeOptions { - kotlinCompilerExtensionVersion = "1.5.8" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } -} - -dependencies { - implementation("dev.thunderid:android") - implementation("com.google.android.play:integrity:1.6.0") - - val composeBom = platform("androidx.compose:compose-bom:2024.02.00") - implementation(composeBom) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.foundation:foundation") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.activity:activity-compose:1.8.2") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") -} diff --git a/samples/quickstart/config.properties.example b/samples/quickstart/config.properties.example deleted file mode 100644 index e605b50..0000000 --- a/samples/quickstart/config.properties.example +++ /dev/null @@ -1,4 +0,0 @@ -THUNDERID_BASE_URL=https://10.0.2.2:8090 -THUNDERID_APPLICATION_ID=your-application-id -THUNDERID_ATTESTATION_ENABLED=false -THUNDERID_CLOUD_PROJECT_NUMBER= diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt deleted file mode 100644 index 831dd3b..0000000 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -package dev.thunderid.quickstart - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.thunderid.compose.components.presentation.auth.SignIn -import dev.thunderid.compose.components.presentation.auth.SignUp - -private val PrimaryBlue = Color(0xFF3688FF) -private val BgLight = Color(0xFFF7F9FC) -private val BorderLight = Color(0xFFDDE3EC) -private val TextPrimary = Color(0xFF05213F) -private val TextMuted = Color(0xFF5A7085) - -private val featureTags = listOf("OAuth 2.0", "PKCE", "JWT", "MFA", "SSO") - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AuthScreen(applicationId: String) { - var showSheet by remember { mutableStateOf(null) } - - Box( - modifier = Modifier - .fillMaxSize() - .background(BgLight), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - // Upper area β€” scrollable - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(72.dp)) - - // Logo mark - ThunderLogoMark( - modifier = Modifier - .height(80.dp) - .width(65.dp), - darkColor = TextPrimary, - blueColor = PrimaryBlue, - ) - - Spacer(Modifier.height(32.dp)) - - // Headline - Text( - text = "Authentication\nfor developers.", - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - letterSpacing = (-0.5).sp, - color = TextPrimary, - textAlign = TextAlign.Center, - lineHeight = 34.sp, - ) - - Spacer(Modifier.height(12.dp)) - - // Subtext - Text( - text = "OAuth 2.0, PKCE, MFA, and JWT -\nout of the box in minutes.", - fontSize = 15.sp, - color = TextMuted, - textAlign = TextAlign.Center, - lineHeight = 22.sp, - ) - - Spacer(Modifier.height(28.dp)) - - // Feature tags - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - featureTags.forEach { tag -> - Text( - text = tag, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = PrimaryBlue, - modifier = Modifier - .border(1.dp, BorderLight, RoundedCornerShape(20.dp)) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) - } - } - - Spacer(Modifier.height(48.dp)) - } - - // Bottom CTAs - Column( - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(bottom = 40.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Button( - onClick = { showSheet = "signup" }, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - ) { - Text("Get started", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - } - - OutlinedButton( - onClick = { showSheet = "login" }, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(10.dp), - border = androidx.compose.foundation.BorderStroke(1.5.dp, PrimaryBlue), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PrimaryBlue), - ) { - Text("Sign in", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - } - } - } - } - - // Bottom sheets - if (showSheet != null) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = { showSheet = null }, - sheetState = sheetState, - ) { - when (showSheet) { - "login" -> LoginSheetContent(applicationId = applicationId) - "signup" -> SignUpSheetContent(onComplete = { showSheet = null }) - } - } - } -} - -@Composable -private fun SheetTitle(text: String) { - Text( - text = text, - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = TextPrimary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp), - ) -} - -@Composable -private fun LoginSheetContent(applicationId: String) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(bottom = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // The flow's own components (fetched by `SignIn`) already include a HEADING_1 - // title β€” adding a sheet title here would duplicate it, unlike SignUp below - // which has no server-rendered heading of its own. - SignIn(applicationId = applicationId, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) - } -} - -@Composable -private fun SignUpSheetContent(onComplete: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(bottom = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SheetTitle("Create account") - Spacer(Modifier.height(8.dp)) - // Registration finishes without establishing a session, so the app stays on AuthScreen and - // this sheet is not torn down with it the way the sign-in sheet is (a successful sign-in - // swaps the whole screen for HomeScreen). Without closing it here the completed flow - // leaves an empty sheet covering the app. - SignUp( - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - onComplete = onComplete, - ) - } -} - -@Composable -private fun ThunderLogoMark( - modifier: Modifier = Modifier, - darkColor: Color, - blueColor: Color, -) { - Canvas(modifier = modifier) { - val scaleY = size.height / 257f - val scaleX = size.width / 207f - - fun sx(x: Float) = x * scaleX - fun sy(y: Float) = y * scaleY - - // Path 1 β€” dark fill (top-left quad) - val path1 = Path().apply { - moveTo(sx(55.4763f), sy(26.4391f)) - lineTo(sx(58.8866f), sy(0f)) - lineTo(sx(0f), sy(0f)) - lineTo(sx(0f), sy(26.4391f)) - close() - } - drawPath(path1, darkColor) - - // Path 2 β€” blue left column - val path2 = Path().apply { - moveTo(sx(39.8438f), sy(147.407f)) - lineTo(sx(49.5455f), sy(72.2839f)) - lineTo(sx(0f), sy(72.2839f)) - lineTo(sx(0f), sy(256.743f)) - lineTo(sx(60.5602f), sy(256.743f)) - lineTo(sx(80.048f), sy(147.407f)) - close() - } - drawPath(path2, blueColor) - - // Path 3 β€” blue right arc / body - val path3 = Path().apply { - moveTo(sx(192.42f), sy(59.361f)) - cubicTo( - sx(182.782f), sy(40.2307f), - sx(168.929f), sy(25.5705f), - sx(150.903f), sy(15.3381f), - ) - cubicTo( - sx(145.501f), sy(12.2662f), - sx(139.761f), sy(9.6605f), - sx(133.703f), sy(7.5208f), - ) - lineTo(sx(115.401f), sy(103.702f)) - lineTo(sx(159.757f), sy(103.702f)) - lineTo(sx(76.2987f), sy(256.743f)) - lineTo(sx(83.3735f), sy(256.743f)) - cubicTo( - sx(109.449f), sy(256.743f), - sx(131.69f), sy(251.574f), - sx(150.14f), sy(241.236f), - ) - cubicTo( - sx(168.569f), sy(230.897f), - sx(182.634f), sy(216.131f), - sx(192.356f), sy(196.959f), - ) - cubicTo( - sx(202.058f), sy(177.765f), - sx(206.909f), sy(154.8f), - sx(206.909f), sy(128.043f), - ) - cubicTo( - sx(206.909f), sy(101.286f), - sx(202.079f), sy(78.5123f), - sx(192.441f), sy(59.3821f), - ) - lineTo(sx(192.42f), sy(59.361f)) - close() - } - drawPath(path3, blueColor) - } -} diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt deleted file mode 100644 index 8190173..0000000 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt +++ /dev/null @@ -1,730 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -package dev.thunderid.quickstart - -import android.graphics.BitmapFactory -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.thunderid.android.R -import dev.thunderid.compose.LocalThunderID -import dev.thunderid.compose.components.actions.SignOutButton -import dev.thunderid.compose.components.presentation.user.ChangeCredential -import dev.thunderid.compose.components.presentation.user.UserAvatar -import dev.thunderid.compose.components.presentation.user.UserProfile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -// ───────────────────────────────────────────────────────────────────────────── -// Design tokens -// ───────────────────────────────────────────────────────────────────────────── - -private val PrimaryBlue = Color(0xFF3688FF) -private val SuccessGreen = Color(0xFF2FBD6B) -private val DarkBg = Color(0xFF080F1C) -private val LightBg = Color(0xFFF7F9FC) -private val BorderLight = Color(0xFFDDE3EC) -private val TextPrimary = Color(0xFF05213F) -private val TextMuted = Color(0xFF5A7085) -private val TokenDarkBg = Color(0xFF0B1120) - -// JWT color coding -private val JwtHeaderColor = Color(0xFFFF7B72) -private val JwtPayloadColor = Color(0xFF79C0FF) -private val JwtSignatureColor = Color(0xFF3FB950) - -// ───────────────────────────────────────────────────────────────────────────── -// Root composable -// ───────────────────────────────────────────────────────────────────────────── - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HomeScreen() { - var screen by remember { mutableStateOf("home") } - val thunder = LocalThunderID.current - - when (screen) { - "home" -> HomeTab(onNavigate = { screen = it }) - "profile" -> ProfileScreen(onBack = { screen = "home" }) - "token" -> TokenDebugScreen(onBack = { screen = "home" }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Home tab -// ───────────────────────────────────────────────────────────────────────────── - -@Composable -private fun HomeTab(onNavigate: (String) -> Unit) { - val thunder = LocalThunderID.current - - val dateLabel = remember { - SimpleDateFormat("EEEE, MMMM d", Locale.ENGLISH).format(Date()).uppercase() - } - val greetingName = remember(thunder.user) { - val given = thunder.user?.get("given_name") as? String - given?.takeIf { it.isNotBlank() } - ?: thunder.user?.email?.substringBefore("@")?.takeIf { it.isNotBlank() } - ?: "there" - } - val greeting = remember(greetingName) { - val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) - val timeOfDay = when { - hour < 12 -> "morning" - hour < 17 -> "afternoon" - else -> "evening" - } - "Good $timeOfDay, $greetingName." - } - - val claims = thunder.user?.claims - val authTime = remember(claims) { claimAsEpochSeconds(claims, "auth_time") } - val exp = remember(claims) { claimAsEpochSeconds(claims, "exp") } - val organisationName = remember(thunder.user) { - runCatching { thunder.client.getConfiguration().organizationHandle } - .getOrNull() - ?.takeIf { it.isNotBlank() } - ?: "Default" - } - - var nowSeconds by remember { mutableStateOf(System.currentTimeMillis() / 1000) } - LaunchedEffect(exp) { - while (exp != null) { - nowSeconds = System.currentTimeMillis() / 1000 - delay(1000) - } - } - - val signedInAtLabel = remember(authTime) { formatSignedInAt(authTime) } - val expiresInLabel = remember(exp, nowSeconds) { formatExpiresIn(exp, nowSeconds) } - - Column( - modifier = Modifier - .fillMaxSize() - .background(LightBg) - .verticalScroll(rememberScrollState()), - ) { - Spacer(Modifier.height(30.dp)) - - // Hero: avatar + (date/session row, greeting row) - Row( - modifier = Modifier.padding(horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UserAvatar(size = 52.dp) - Spacer(Modifier.width(14.dp)) - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = dateLabel, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = TextMuted, - letterSpacing = 1.sp, - ) - Spacer(Modifier.width(6.dp)) - Box( - modifier = Modifier - .size(3.dp) - .background(BorderLight, CircleShape), - ) - Spacer(Modifier.width(6.dp)) - Box( - modifier = Modifier - .size(6.dp) - .background(SuccessGreen, CircleShape), - ) - Spacer(Modifier.width(5.dp)) - Text( - text = "Session active", - fontSize = 12.sp, - color = SuccessGreen, - fontWeight = FontWeight.Medium, - ) - } - Spacer(Modifier.height(4.dp)) - Text( - text = greeting, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - color = TextPrimary, - ) - } - } - - Spacer(Modifier.height(24.dp)) - - // Stats row - Row( - modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth() - .border(1.dp, BorderLight, RoundedCornerShape(12.dp)) - .padding(vertical = 18.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - StatColumn(value = signedInAtLabel, label = "SIGNED IN AT") - VerticalDivider(modifier = Modifier.height(36.dp), color = BorderLight) - StatColumn(value = expiresInLabel, label = "EXPIRES IN") - VerticalDivider(modifier = Modifier.height(36.dp), color = BorderLight) - StatColumn(value = organisationName, label = "ORGANISATION") - } - - Spacer(Modifier.height(28.dp)) - - // "What's next" section - Text( - text = "WHAT'S NEXT", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = TextMuted, - letterSpacing = 1.5.sp, - modifier = Modifier.padding(horizontal = 24.dp), - ) - - Spacer(Modifier.height(8.dp)) - - val steps = listOf( - NextStep( - number = "01", - title = "Explore use cases", - subtitle = "See what you can build β€” auth flows for web, mobile, APIs, and agents.", - href = "https://thunderid.dev/docs/next/use-cases/overview/", - ), - NextStep( - number = "02", - title = "Learn about flows", - subtitle = "Understand how authorization code, PKCE, client credentials, and device flows work.", - href = "https://thunderid.dev/docs/next/guides/flows/what-are-flows/", - ), - NextStep( - number = "03", - title = "Style your experience", - subtitle = "Customize the login UI, branding, and email templates to match your product.", - href = "https://thunderid.dev/docs/next/guides/design/overview/", - ), - NextStep( - number = "04", - title = "Explore SDK APIs", - subtitle = "Full Android SDK reference β€” composables, state objects, and configuration options.", - href = "https://thunderid.dev/docs/next/sdks/android/overview/", - ), - ) - - steps.forEach { step -> - StepRow(number = step.number, title = step.title, subtitle = step.subtitle, href = step.href) - } - - Spacer(Modifier.height(20.dp)) - - // Action rows - ActionRow(label = "Manage Account", onClick = { onNavigate("profile") }) - ActionRow(label = "Token Debug", onClick = { onNavigate("token") }) - - // Sign out - Box( - modifier = Modifier - .fillMaxWidth() - .border(width = 1.dp, color = BorderLight, shape = RoundedCornerShape(0.dp)) - .padding(horizontal = 24.dp, vertical = 4.dp), - ) { - SignOutButton(modifier = Modifier.fillMaxWidth()) - } - - Spacer(Modifier.height(40.dp)) - } -} - -@Composable -private fun StatColumn(value: String, label: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = value, - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = TextPrimary, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = label, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = TextMuted, - letterSpacing = 0.8.sp, - ) - } -} - -private data class NextStep(val number: String, val title: String, val subtitle: String, val href: String) - -@Composable -private fun StepRow(number: String, title: String, subtitle: String, href: String) { - val uriHandler = LocalUriHandler.current - Row( - modifier = Modifier - .fillMaxWidth() - .border(width = 0.dp, color = Color.Transparent) - .clickable(onClick = { uriHandler.openUri(href) }) - .padding(horizontal = 24.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Row(verticalAlignment = Alignment.Top) { - Text( - text = number, - fontSize = 9.sp, - fontFamily = FontFamily.Monospace, - color = PrimaryBlue, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 2.dp), - ) - Spacer(Modifier.width(14.dp)) - Column { - Text(text = title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = TextPrimary) - Spacer(Modifier.height(2.dp)) - Text(text = subtitle, fontSize = 13.sp, color = TextMuted) - } - } - Text(text = "β€Ί", fontSize = 18.sp, color = TextMuted, modifier = Modifier.padding(start = 8.dp)) - } - Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(BorderLight), - ) -} - -@Composable -private fun ActionRow(label: String, onClick: () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .border(width = 1.dp, color = BorderLight, shape = RoundedCornerShape(0.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - fontSize = 15.sp, - color = TextPrimary, - fontWeight = FontWeight.Medium, - ) - Text(text = "β€Ί", fontSize = 20.sp, color = TextMuted) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Profile screen -// ───────────────────────────────────────────────────────────────────────────── - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProfileScreen(onBack: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .background(LightBg) - .verticalScroll(rememberScrollState()), - ) { - Spacer(Modifier.height(30.dp)) - - // Back button and page title on the same line, title aligned to the trailing edge. - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.clickable(onClick = onBack), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "β€Ή", fontSize = 20.sp, color = PrimaryBlue) - Spacer(Modifier.width(4.dp)) - Text(text = "Home", fontSize = 15.sp, color = PrimaryBlue, fontWeight = FontWeight.Medium) - } - - Text( - text = "Manage Account", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = TextPrimary, - ) - } - - Spacer(Modifier.height(12.dp)) - - // The styled UserProfile component owns its own data loading, editing and card - // design end to end β€” the sample just drops it in, no BaseUserProfile customization. - UserProfile(modifier = Modifier.fillMaxWidth()) - - Spacer(Modifier.height(24.dp)) - - // "Security" groups ChangeCredential the same way UserProfile's own "Profile info" header - // groups its card β€” ChangeCredential itself stays header-less so it composes into either - // a labelled group like this or on its own. - Text( - text = "SECURITY", - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.4.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 20.dp), - ) - - ChangeCredential(modifier = Modifier.fillMaxWidth()) - - Spacer(Modifier.height(40.dp)) - } -} - -@Composable -private fun SectionHeader(title: String) { - Text( - text = title, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = TextMuted, - letterSpacing = 1.5.sp, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) -} - -@Composable -private fun DetailCard(content: @Composable () -> Unit) { - Column( - modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth() - .border(1.dp, BorderLight, RoundedCornerShape(12.dp)) - .background(Color.White, RoundedCornerShape(12.dp)) - .clip(RoundedCornerShape(12.dp)), - ) { - content() - } -} - -@Composable -private fun DetailRow(label: String, content: @Composable () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - fontSize = 13.sp, - color = TextMuted, - modifier = Modifier.width(100.dp), - ) - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { - content() - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Token Debug screen -// ───────────────────────────────────────────────────────────────────────────── - -@Composable -private fun TokenDebugScreen(onBack: () -> Unit) { - val thunder = LocalThunderID.current - var token by remember { mutableStateOf("") } - val clipboardManager = LocalClipboardManager.current - - LaunchedEffect(Unit) { - try { - token = thunder.client.getAccessToken() - } catch (_: Exception) {} - } - - val decoded = remember(token) { if (token.isNotEmpty()) decodeJwtPayload(token) else emptyMap() } - val rawJson = decoded["raw"] as? String ?: "" - val parts = (decoded["parts"] as? List<*>)?.map { it.toString() } ?: emptyList() - - val issuer = remember(rawJson) { - Regex("\"iss\"\\s*:\\s*\"([^\"]+)\"").find(rawJson)?.groupValues?.get(1) ?: "β€”" - } - val scopes = remember(rawJson) { - Regex("\"scope\"\\s*:\\s*\"([^\"]+)\"").find(rawJson)?.groupValues?.get(1) ?: "β€”" - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(LightBg) - .verticalScroll(rememberScrollState()), - ) { - Spacer(Modifier.height(56.dp)) - - // Back button - Row( - modifier = Modifier - .padding(horizontal = 24.dp) - .clickable(onClick = onBack), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "β€Ή", fontSize = 20.sp, color = PrimaryBlue) - Spacer(Modifier.width(4.dp)) - Text(text = "Home", fontSize = 15.sp, color = PrimaryBlue, fontWeight = FontWeight.Medium) - } - - Spacer(Modifier.height(20.dp)) - - Column(modifier = Modifier.padding(horizontal = 24.dp)) { - Text(text = "Token debug", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = TextPrimary) - Spacer(Modifier.height(2.dp)) - Text(text = "Access token and claims", fontSize = 13.sp, color = TextMuted) - } - - Spacer(Modifier.height(16.dp)) - - // Access token display - Column(modifier = Modifier.padding(horizontal = 24.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "ACCESS TOKEN", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = TextMuted, letterSpacing = 1.sp) - OutlinedButton( - onClick = { if (token.isNotEmpty()) clipboardManager.setText(AnnotatedString(token)) }, - modifier = Modifier.height(28.dp), - shape = RoundedCornerShape(6.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, BorderLight), - colors = ButtonDefaults.outlinedButtonColors(contentColor = TextMuted), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp, vertical = 0.dp), - ) { - Text(text = "Copy", fontSize = 11.sp) - } - } - Spacer(Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .background(TokenDarkBg, RoundedCornerShape(10.dp)) - .padding(14.dp), - ) { - if (token.isNotEmpty() && parts.size == 3) { - val annotated = buildAnnotatedString { - withStyle(SpanStyle(color = JwtHeaderColor)) { append(parts[0]) } - withStyle(SpanStyle(color = Color(0xFF8B8B8B))) { append(".") } - withStyle(SpanStyle(color = JwtPayloadColor)) { append(parts[1]) } - withStyle(SpanStyle(color = Color(0xFF8B8B8B))) { append(".") } - withStyle(SpanStyle(color = JwtSignatureColor)) { append(parts[2]) } - } - Text( - text = annotated, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - lineHeight = 16.sp, - ) - } else { - Text( - text = if (token.isEmpty()) "Loading…" else token, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = Color(0xFF79C0FF), - lineHeight = 16.sp, - ) - } - } - } - - Spacer(Modifier.height(20.dp)) - - // JWT Payload - Column(modifier = Modifier.padding(horizontal = 24.dp)) { - Text(text = "JWT PAYLOAD", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = TextMuted, letterSpacing = 1.sp) - Spacer(Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .background(TokenDarkBg, RoundedCornerShape(10.dp)) - .padding(14.dp), - ) { - Text( - text = if (rawJson.isNotEmpty()) prettyPrintJson(rawJson) else "β€”", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = JwtPayloadColor, - lineHeight = 18.sp, - ) - } - } - - Spacer(Modifier.height(20.dp)) - - // Issuer and Scopes - SectionHeader(title = "TOKEN DETAILS") - DetailCard { - DetailRow(label = "Issuer") { - Text( - text = issuer, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = TextMuted, - textAlign = TextAlign.End, - ) - } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(BorderLight)) - DetailRow(label = "Scopes") { - Text( - text = scopes, - fontSize = 12.sp, - color = TextMuted, - textAlign = TextAlign.End, - ) - } - } - - Spacer(Modifier.height(40.dp)) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -private fun decodeJwtPayload(token: String): Map { - return try { - val parts = token.split(".") - if (parts.size != 3) return emptyMap() - val padded = parts[1].let { it + "=".repeat((4 - it.length % 4) % 4) } - val json = String(android.util.Base64.decode(padded, android.util.Base64.URL_SAFE)) - mapOf("raw" to json, "parts" to parts) - } catch (e: Exception) { - emptyMap() - } -} - -private fun prettyPrintJson(json: String): String { - return try { - val sb = StringBuilder() - var indent = 0 - var inString = false - for (ch in json) { - when { - ch == '"' && !inString -> { inString = true; sb.append(ch) } - ch == '"' && inString -> { inString = false; sb.append(ch) } - inString -> sb.append(ch) - ch == '{' || ch == '[' -> { - sb.append(ch) - sb.append('\n') - indent++ - repeat(indent) { sb.append(" ") } - } - ch == '}' || ch == ']' -> { - sb.append('\n') - indent-- - repeat(indent) { sb.append(" ") } - sb.append(ch) - } - ch == ',' -> { - sb.append(ch) - sb.append('\n') - repeat(indent) { sb.append(" ") } - } - ch == ':' -> sb.append(": ") - ch != ' ' && ch != '\n' && ch != '\r' && ch != '\t' -> sb.append(ch) - } - } - sb.toString() - } catch (_: Exception) { - json - } -} - -private fun claimAsEpochSeconds(claims: Map?, key: String): Long? { - return when (val value = claims?.get(key)) { - is Number -> value.toLong() - is String -> value.toLongOrNull() - else -> null - } -} - -private fun formatSignedInAt(authTimeSeconds: Long?): String { - if (authTimeSeconds == null) return "β€”" - val formatter = SimpleDateFormat("h:mm a", Locale.getDefault()) - return formatter.format(Date(authTimeSeconds * 1000)) -} - -private fun formatExpiresIn(expSeconds: Long?, nowSeconds: Long): String { - if (expSeconds == null) return "β€”" - val remaining = expSeconds - nowSeconds - if (remaining <= 0) return "Expired" - val minutes = remaining / 60 - val seconds = remaining % 60 - return if (remaining < 3600) { - "${minutes}m ${seconds}s" - } else { - val hours = remaining / 3600 - val remMinutes = (remaining % 3600) / 60 - "${hours}h ${remMinutes}m" - } -} - diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt deleted file mode 100644 index a70378b..0000000 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -package dev.thunderid.quickstart - -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.ui.graphics.Color -import dev.thunderid.android.EncryptedStorageAdapter -import dev.thunderid.android.ThunderIDConfig -import dev.thunderid.android.auth.FederatedAuthSession -import dev.thunderid.compose.ThunderIDProvider - -// Matches the ThunderID blue used throughout AuthScreen/HomeScreen β€” the SDK's form -// fields and bottom sheet read `MaterialTheme.colorScheme.primary`, so leaving this at -// Material3's baseline (or an unrelated placeholder brand color) makes them look -// mismatched/pink against the rest of the sample. -private val ThunderIDPrimary = Color(0xFF3688FF) - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val attestationEnabled = BuildConfig.THUNDERID_ATTESTATION_ENABLED - val integrityTokenProvider = - PlayIntegrityTokenProvider(applicationContext, BuildConfig.THUNDERID_CLOUD_PROJECT_NUMBER) - - val config = ThunderIDConfig( - baseUrl = BuildConfig.THUNDERID_BASE_URL, - scopes = listOf("openid", "profile", "email"), - applicationId = BuildConfig.THUNDERID_APPLICATION_ID.takeIf { it.isNotBlank() }, - attestationEnabled = attestationEnabled, - attestationTokenProvider = if (attestationEnabled) integrityTokenProvider::requestToken else null, - storage = EncryptedStorageAdapter(this), - allowInsecureConnections = BuildConfig.DEBUG, - ) - - val colorScheme = lightColorScheme( - primary = ThunderIDPrimary, - secondary = Color(0xFF345F91), - surfaceVariant = Color(0xFFF2F2F2), - onSurfaceVariant = Color(0xFF49454F), - outlineVariant = Color(0x1F1D1B20), - ) - - setContent { - MaterialTheme(colorScheme = colorScheme) { - ThunderIDProvider(config = config) { - QuickstartApp(applicationId = BuildConfig.THUNDERID_APPLICATION_ID) - } - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - intent.data?.let(FederatedAuthSession::onRedirect) - } - - override fun onResume() { - super.onResume() - // A callback arrives in onNewIntent before this runs, so a sign-in still pending here means - // the user dismissed the browser. - FederatedAuthSession.cancelIfPending() - } -} diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt deleted file mode 100644 index c060f83..0000000 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/PlayIntegrityTokenProvider.kt +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -package dev.thunderid.quickstart - -import android.content.Context -import com.google.android.gms.tasks.Task -import com.google.android.play.core.integrity.IntegrityManagerFactory -import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest -import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider -import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -/** - * Mints Google Play Integrity Standard API tokens for [dev.thunderid.android.ThunderIDConfig.attestationTokenProvider]. - * The warm-up ([StandardIntegrityTokenProvider]) is cached and reused across sign-in attempts. - */ -class PlayIntegrityTokenProvider( - private val context: Context, - private val cloudProjectNumber: Long, -) { - private var tokenProvider: StandardIntegrityTokenProvider? = null - - suspend fun requestToken(): String { - val provider = tokenProvider ?: prepareTokenProvider().also { tokenProvider = it } - return provider.request(StandardIntegrityTokenRequest.builder().build()).await().token() - } - - private suspend fun prepareTokenProvider(): StandardIntegrityTokenProvider = - IntegrityManagerFactory.createStandard(context) - .prepareIntegrityToken( - PrepareIntegrityTokenRequest.builder() - .setCloudProjectNumber(cloudProjectNumber) - .build(), - ).await() -} - -private suspend fun Task.await(): T = - suspendCancellableCoroutine { cont -> - addOnSuccessListener { cont.resume(it) } - addOnFailureListener { cont.resumeWithException(it) } - } diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt deleted file mode 100644 index 2bd9683..0000000 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -package dev.thunderid.quickstart - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import dev.thunderid.compose.LocalThunderID - -@Composable -fun QuickstartApp(applicationId: String) { - val thunder = LocalThunderID.current - when { - thunder.error != null && !thunder.isInitialized -> ErrorScreen(thunder.error!!) - !thunder.isInitialized || thunder.isLoading -> LoadingScreen() - thunder.error != null -> ErrorScreen(thunder.error!!) - thunder.isSignedIn -> HomeScreen() - else -> AuthScreen(applicationId = applicationId) - } -} - -@Composable -private fun LoadingScreen() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator() - Spacer(Modifier.height(12.dp)) - Text("Starting ThunderID…", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } -} - -@Composable -private fun ErrorScreen(message: String) { - Box( - Modifier - .fillMaxSize() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Configuration error: $message\n\nCheck your .env values.", - textAlign = TextAlign.Center, - ) - } -} diff --git a/samples/quickstart/thunderid-config/thunderid-config.yaml b/samples/quickstart/thunderid-config/thunderid-config.yaml deleted file mode 100644 index f167a70..0000000 --- a/samples/quickstart/thunderid-config/thunderid-config.yaml +++ /dev/null @@ -1,34 +0,0 @@ ---- -resource_type: application -# Application used exclusively by the Android Quickstart E2E suite. It has no OAuth profile: the -# mobile SDKs authenticate app-natively via the Flow Execution API, so the sample app needs only -# the base URL and this application ID. -# -# The ID is fixed so the sample's Config.plist and the Maestro flows can be committed against a -# known value instead of discovering it at run time. -id: 019e5b10-1001-7a2b-9c3d-4e5f60718293 -ouHandle: default -name: Mobile Quickstart E2E App -description: Application for the ThunderID mobile SDK quickstart E2E suites -type: mobile -# A mobile application must normally prove its binary identity via platform attestation before it -# may initiate a flow directly, otherwise /flow/execute fails with FES-1016. Apple App Attest is -# unavailable in the iOS Simulator and Play Integrity requires real Play Services, so neither can -# be satisfied on the emulated devices this suite drives. devMode skips that check. -# -# Test-only. Never enable this on a real tenant: it removes the guarantee that the client -# initiating a flow is the genuine, unmodified app. -# -# NOTE: POST /import currently drops this block silently (it reports success, then stores -# attestation: null), so run-e2e.sh re-applies it over PUT /applications/{id}. Once the -# import path preserves it, that extra step can go. -attestation: - devMode: true -authFlowHandle: default-flow -# The suite registers a fresh user through the UI rather than relying on a seeded account, so -# self-service registration has to be enabled for this application. -registrationFlowHandle: default-flow -isRegistrationFlowEnabled: true -signOutFlowHandle: default-flow -allowedUserTypes: - - Person diff --git a/tests/e2e/README.md b/tests/e2e/README.md index a36aa3c..2f445a9 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -4,6 +4,22 @@ Maestro flows that drive the Quickstart sample through real authentication again ThunderID server on an Android emulator. The flows live in [`flows/`](./flows); the scripts alongside them get a server into the right state to run against. +The sample itself lives in the samples repository at +[`quickstarts/android`](https://github.com/thunder-id/samples/tree/main/quickstarts/android). +[`scripts/prepare-sample.sh`](./scripts/prepare-sample.sh) (or `prepare-sample.ps1`) fetches it +into `.samples/` and adds an `includeBuild` to its `settings.gradle.kts` that substitutes this SDK +checkout for the published `com.github.thunder-id:android-sdks` dependency, so the suite always +tests the code in this repository. The PR builder builds the sample the same way. + +| Variable | Effect | +|---|---| +| `THUNDERID_SAMPLES_DIR` | Copy the sample from an existing local samples checkout instead of cloning | +| `THUNDERID_SAMPLES_REPO` | Samples repository to clone (default `https://github.com/thunder-id/samples.git`) | +| `THUNDERID_SAMPLES_REF` | Branch, tag or commit to clone (default `main`) | + +Use `THUNDERID_SAMPLES_DIR` or `THUNDERID_SAMPLES_REF` to test an SDK change together with an +unmerged change to the sample. + ## Running locally ```bash @@ -36,8 +52,8 @@ restarted, and provisioning re-applies cleanly over an existing application and | Test user | `e2e_mobile_user` / `TestPassword@123` | The application is declared in -[`samples/quickstart/thunderid-config/thunderid-config.yaml`](../../samples/quickstart/thunderid-config/thunderid-config.yaml), -alongside the sample it configures. The sign-up flow +[`quickstarts/android/thunderid-config/thunderid-config.yaml`](https://github.com/thunder-id/samples/blob/main/quickstarts/android/thunderid-config/thunderid-config.yaml) +in the samples repository, alongside the sample it configures. The sign-up flow registers an additional throwaway user per run, named `e2e_signup_`. ## Things worth knowing diff --git a/tests/e2e/run-e2e.ps1 b/tests/e2e/run-e2e.ps1 index 50af152..3fb8adb 100644 --- a/tests/e2e/run-e2e.ps1 +++ b/tests/e2e/run-e2e.ps1 @@ -36,6 +36,9 @@ E2E_USERNAME Test user to create (default e2e_mobile_user) E2E_PASSWORD Test user password (default TestPassword@123) INSTALL_DIR Where to unpack the distribution (default .\.thunderid-server) + THUNDERID_SAMPLES_DIR Local samples checkout to take the sample from (default: clone it) + THUNDERID_SAMPLES_REPO Samples repository to clone (default https://github.com/thunder-id/samples.git) + THUNDERID_SAMPLES_REF Samples branch, tag or commit to clone (default main) Requires PowerShell 7 or later. The server presents a self-signed certificate on localhost, which every request here opts out of validating with -SkipCertificateCheck; that switch does @@ -73,9 +76,6 @@ $E2eUser = Get-EnvOrDefault 'E2E_USERNAME' 'e2e_mobile_user' $E2ePass = Get-EnvOrDefault 'E2E_PASSWORD' 'TestPassword@123' $InstallDir = Get-EnvOrDefault 'INSTALL_DIR' (Join-Path $ScriptDir '.thunderid-server') -# The sample owns its own application config, not the test script. -$ConfigFile = Join-Path $ScriptDir '../../samples/quickstart/thunderid-config/thunderid-config.yaml' - # Must match the `id` in $ConfigFile and the application ID the sample is built with. $AppId = '019e5b10-1001-7a2b-9c3d-4e5f60718293' @@ -301,8 +301,6 @@ function Invoke-Provision { # Build and install the sample # ------------------------------------------------------------------------------------------- function Build-Sample { - $sampleDir = Join-Path $ScriptDir '..\..\samples\quickstart' | Resolve-Path - Write-Host '==> Configuring the sample' # 10.0.2.2 is the emulator's alias for the host loopback, where the server is listening. # The debug build sets allowInsecureConnections, which is what lets the app accept the @@ -312,10 +310,10 @@ function Build-Sample { "THUNDERID_APPLICATION_ID=$AppId" 'THUNDERID_ATTESTATION_ENABLED=false' 'THUNDERID_CLOUD_PROJECT_NUMBER=' - ) | Set-Content -Path (Join-Path $sampleDir 'config.properties') -Encoding utf8 + ) | Set-Content -Path (Join-Path $SampleDir 'config.properties') -Encoding utf8 Write-Host '==> Building and installing the sample' - Push-Location $sampleDir + Push-Location $SampleDir try { & .\gradlew.bat installDebug if ($LASTEXITCODE -ne 0) { throw "gradlew installDebug failed with exit code $LASTEXITCODE." } @@ -348,6 +346,14 @@ function Invoke-Flows { } } +# The sample lives in the ThunderID samples repository. Fetch it and wire it to this SDK +# checkout (see scripts\prepare-sample.ps1 for THUNDERID_SAMPLES_DIR/_REPO/_REF). +if (-not $SkipServer -or -not $SkipBuild) { + $SampleDir = & (Join-Path $ScriptDir 'scripts\prepare-sample.ps1') | Select-Object -Last 1 + # The sample owns its own application config, not the test script. + $ConfigFile = Join-Path $SampleDir 'thunderid-config/thunderid-config.yaml' +} + if (-not $SkipServer) { Start-ThunderIDServer Invoke-Provision diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index f999956..d379557 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -23,6 +23,9 @@ # E2E_PASSWORD Test user password (default TestPassword@123) # INSTALL_DIR Where to unpack the distribution (default ./.thunderid-server) # GITHUB_TOKEN Optional. Authenticates the latest-release lookup against the API rate limit +# THUNDERID_SAMPLES_DIR Local samples checkout to take the sample from (default: clone it) +# THUNDERID_SAMPLES_REPO Samples repository to clone (default https://github.com/thunder-id/samples.git) +# THUNDERID_SAMPLES_REF Samples branch, tag or commit to clone (default main) # # Why not `npx thunderid`? That wrapper renders an interactive TUI and aborts with # "bubbletea: could not open TTY" when stdout is not a terminal, which is always the case on a CI @@ -55,9 +58,6 @@ E2E_USER="${E2E_USERNAME:-e2e_mobile_user}" E2E_PASS="${E2E_PASSWORD:-TestPassword@123}" INSTALL_DIR="${INSTALL_DIR:-$SCRIPT_DIR/.thunderid-server}" -# The sample owns its own application config, not the test script. -CONFIG_FILE="$SCRIPT_DIR/../../samples/quickstart/thunderid-config/thunderid-config.yaml" - # Must match the `id` in $CONFIG_FILE and the application ID the sample is built with. APP_ID="019e5b10-1001-7a2b-9c3d-4e5f60718293" @@ -70,7 +70,7 @@ for arg in "$@"; do --skip-server) do_server=false ;; --skip-build) do_build=false ;; --skip-test) do_test=false ;; - -h | --help) sed -n '2,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h | --help) sed -n '2,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; *) maestro_args+=("$arg") ;; esac done @@ -79,6 +79,16 @@ for tool in curl jq openssl; do command -v "$tool" >/dev/null 2>&1 || { echo "ERROR: $tool is required but not installed." >&2; exit 1; } done +# The sample lives in the ThunderID samples repository. Fetch it and wire it to this SDK checkout +# (see scripts/prepare-sample.sh for THUNDERID_SAMPLES_DIR/_REPO/_REF). +SAMPLE_DIR="" +if $do_server || $do_build; then + SAMPLE_DIR="$("$SCRIPT_DIR/scripts/prepare-sample.sh")" +fi + +# The sample owns its own application config, not the test script. +CONFIG_FILE="$SAMPLE_DIR/thunderid-config/thunderid-config.yaml" + # --------------------------------------------------------------------------------------------- # Start # --------------------------------------------------------------------------------------------- @@ -337,7 +347,7 @@ provision() { # Build and install the sample # --------------------------------------------------------------------------------------------- build_sample() { - local sample_dir="$SCRIPT_DIR/../../samples/quickstart" + local sample_dir="$SAMPLE_DIR" echo "==> Configuring the sample" # 10.0.2.2 is the emulator's alias for the host loopback, where the server is listening. diff --git a/tests/e2e/scripts/prepare-sample.ps1 b/tests/e2e/scripts/prepare-sample.ps1 new file mode 100644 index 0000000..a295650 --- /dev/null +++ b/tests/e2e/scripts/prepare-sample.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Fetch the Android Quickstart sample and point it at this SDK checkout. + +.DESCRIPTION + Copies or clones the Android Quickstart sample from the ThunderID samples repository into + tests\e2e\.samples and points it at this SDK checkout instead of the published release, so + the E2E suite exercises the code under test. The PowerShell counterpart of prepare-sample.sh. + Writes the prepared sample directory to the pipeline. + + Every run starts from a fresh copy, so re-running is safe. + +.NOTES + Environment variables, all optional: + THUNDERID_SAMPLES_DIR Existing local samples checkout to copy the sample from (no clone) + THUNDERID_SAMPLES_REPO Samples repository to clone (default https://github.com/thunder-id/samples.git) + THUNDERID_SAMPLES_REF Branch, tag or commit to clone (default main) +#> + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path +$SamplesDir = Join-Path $ScriptDir '..\.samples' +$SamplePath = 'quickstarts/android' +$SamplesRepo = if ($env:THUNDERID_SAMPLES_REPO) { $env:THUNDERID_SAMPLES_REPO } else { 'https://github.com/thunder-id/samples.git' } +$SamplesRef = if ($env:THUNDERID_SAMPLES_REF) { $env:THUNDERID_SAMPLES_REF } else { 'main' } + +# The published artifact the sample depends on, substituted with this checkout's root project. +$SdkModule = 'com.github.thunder-id:android-sdks' + +if (Test-Path $SamplesDir) { Remove-Item -Recurse -Force $SamplesDir } +New-Item -ItemType Directory -Force -Path $SamplesDir | Out-Null + +if ($env:THUNDERID_SAMPLES_DIR) { + $source = Join-Path $env:THUNDERID_SAMPLES_DIR $SamplePath + Write-Host "==> Copying $SamplePath from $env:THUNDERID_SAMPLES_DIR" + if (-not (Test-Path $source)) { throw "$source does not exist." } + $target = Join-Path $SamplesDir $SamplePath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null + Copy-Item -Recurse -Path $source -Destination $target + # Drop local build state and configuration a working checkout may carry. + foreach ($item in 'build', '.gradle', 'local.properties', 'config.properties') { + $path = Join-Path $target $item + if (Test-Path $path) { Remove-Item -Recurse -Force $path } + } +} else { + Write-Host "==> Fetching $SamplePath from $SamplesRepo at $SamplesRef" + # init + fetch rather than `clone --branch`, so a commit SHA works as the ref too. + & git -C $SamplesDir init -q + & git -C $SamplesDir remote add origin $SamplesRepo + & git -C $SamplesDir fetch -q --depth 1 origin $SamplesRef + if ($LASTEXITCODE -ne 0) { throw "Fetching $SamplesRef from $SamplesRepo failed." } + & git -C $SamplesDir checkout -q FETCH_HEAD + if ($LASTEXITCODE -ne 0) { throw 'Checking out the samples repository failed.' } +} + +$SampleDir = (Resolve-Path (Join-Path $SamplesDir $SamplePath)).Path + +if (-not (Select-String -Path (Join-Path $SampleDir 'build.gradle.kts') -SimpleMatch "`"${SdkModule}:" -Quiet)) { + throw "$SampleDir\build.gradle.kts does not depend on $SdkModule." +} + +Write-Host "==> Pointing the sample at the local SDK in $RepoRoot" +# Gradle accepts forward slashes on Windows, and they need no escaping in a Kotlin string. +$gradleRoot = $RepoRoot -replace '\\', '/' +@" + +// Added by tests/e2e/scripts/prepare-sample.ps1: build against the local SDK checkout. +includeBuild("$gradleRoot") { + name = "android" + dependencySubstitution { + substitute(module("$SdkModule")).using(project(":")) + } +} +"@ | Add-Content -Path (Join-Path $SampleDir 'settings.gradle.kts') -Encoding utf8 + +$SampleDir diff --git a/tests/e2e/scripts/prepare-sample.sh b/tests/e2e/scripts/prepare-sample.sh new file mode 100755 index 0000000..be579f4 --- /dev/null +++ b/tests/e2e/scripts/prepare-sample.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Fetch the Android Quickstart sample from the ThunderID samples repository into +# tests/e2e/.samples and point it at this SDK checkout instead of the published release, so the +# E2E suite and the PR builder exercise the code under test. +# +# Usage: +# ./prepare-sample.sh Prints the prepared sample directory on stdout +# +# Environment: +# THUNDERID_SAMPLES_DIR Existing local samples checkout to copy the sample from (no clone) +# THUNDERID_SAMPLES_REPO Samples repository to clone (default https://github.com/thunder-id/samples.git) +# THUNDERID_SAMPLES_REF Branch, tag or commit to clone (default main) +# +# Every run starts from a fresh copy, so re-running is safe. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SAMPLES_DIR="$SCRIPT_DIR/../.samples" +SAMPLE_PATH="quickstarts/android" +SAMPLES_REPO="${THUNDERID_SAMPLES_REPO:-https://github.com/thunder-id/samples.git}" +SAMPLES_REF="${THUNDERID_SAMPLES_REF:-main}" + +# The published artifact the sample depends on, substituted with this checkout's root project. +SDK_MODULE="com.github.thunder-id:android-sdks" + +rm -rf "$SAMPLES_DIR" +mkdir -p "$SAMPLES_DIR" + +if [ -n "${THUNDERID_SAMPLES_DIR:-}" ]; then + echo "==> Copying $SAMPLE_PATH from $THUNDERID_SAMPLES_DIR" >&2 + [ -d "$THUNDERID_SAMPLES_DIR/$SAMPLE_PATH" ] || { + echo "ERROR: $THUNDERID_SAMPLES_DIR/$SAMPLE_PATH does not exist." >&2 + exit 1 + } + mkdir -p "$SAMPLES_DIR/$(dirname "$SAMPLE_PATH")" + cp -R "$THUNDERID_SAMPLES_DIR/$SAMPLE_PATH" "$SAMPLES_DIR/$SAMPLE_PATH" + # Drop local build state and configuration a working checkout may carry. + rm -rf "$SAMPLES_DIR/$SAMPLE_PATH"/{build,.gradle,local.properties,config.properties} +else + echo "==> Fetching $SAMPLE_PATH from $SAMPLES_REPO at $SAMPLES_REF" >&2 + # init + fetch rather than `clone --branch`, so a commit SHA works as the ref too. + git -C "$SAMPLES_DIR" init -q + git -C "$SAMPLES_DIR" remote add origin "$SAMPLES_REPO" + git -C "$SAMPLES_DIR" fetch -q --depth 1 origin "$SAMPLES_REF" + git -C "$SAMPLES_DIR" checkout -q FETCH_HEAD +fi + +[ -d "$SAMPLES_DIR/$SAMPLE_PATH" ] || { + echo "ERROR: $SAMPLE_PATH does not exist in $SAMPLES_REPO at $SAMPLES_REF." >&2 + exit 1 +} +SAMPLE_DIR="$(cd "$SAMPLES_DIR/$SAMPLE_PATH" && pwd)" + +grep -qF "\"$SDK_MODULE:" "$SAMPLE_DIR/build.gradle.kts" || { + echo "ERROR: $SAMPLE_DIR/build.gradle.kts does not depend on $SDK_MODULE." >&2 + exit 1 +} + +echo "==> Pointing the sample at the local SDK in $REPO_ROOT" >&2 +cat >> "$SAMPLE_DIR/settings.gradle.kts" <