diff --git a/deploy/deploy.sh b/deploy/deploy.sh
index a6c7e0d2..68d583ef 100755
--- a/deploy/deploy.sh
+++ b/deploy/deploy.sh
@@ -31,8 +31,7 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then
N=$(echo $DEVELOPMENT_TEAM | wc -w)
if [[ $N -lt 1 ]] ; then
echo Cannot find a DEVELOPMENT_TEAM
- echo "Please edit your .env file and fill in your DEVELOPMENT_TEAM"
- exit
+ echo "Please edit your .env file and fill in your DEVELOPMENT_TEAM, if you want to build the iOS examples."
fi
if [[ $N -gt 1 ]] ; then
echo You have more than one Team ID
@@ -49,7 +48,7 @@ fi
# stop and remove any running containers as they may need to be restarted
echo "### removing any running containers"
-docker compose stop
+docker compose stop || { echo "$(tput bold)Please start a docker machine.$(tput sgr0)"; exit; }
docker compose rm
# copy sources so they can be copied into docker images
@@ -125,6 +124,10 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then
-e "s#^API_BASE_URI[= ].*#API_BASE_URI = $HOST-8080.$REGION#" \
-e "s#^RP_ID[= ].*#RP_ID = $hostname#" \
../examples/clients/mobile/iOS/PawsKey/Constants.xcconfig
+ sed -i '' \
+ -e "s#^API_BASE_URI[=].*#API_BASE_URI=$HOST-8080.$REGION#" \
+ -e "s#^RELYING_PARTY_ID[=].*#RELYING_PARTY_ID=$hostname#" \
+ ../examples/clients/mobile/android/PawsKey/gradle.properties
echo "### editing iOS BankApp sources"
sed -i '' \
@@ -132,7 +135,6 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then
-e "s#^BANK_API_DOMAIN[= ].*#BANK_API_DOMAIN = $HOST-8082.$REGION#" \
../examples/clients/mobile/iOS/PKBank/Constants.xcconfig
-
# TODO: instead of editing source files, make endpoints configurable
sed -i '' "s#http://host.docker.internal#https://$hostname#;s#http://localhost#https://$hostname#" keycloak/source/src/main/java/com/yubicolabs/PasskeyAuthenticator/PasskeyAuthenticator.java
sed -i '' "s#http://host.docker.internal#https://$hostname#;s#http://localhost#https://$hostname#" keycloak/source/src/main/java/com/yubicolabs/PasskeyAuthenticator/PasskeyRegistrationAuthenticator.java
@@ -154,11 +156,50 @@ if [ "$DEPLOYMENT_ENVIRONMENT" == "devtunnel" ]; then
echo
fi
- echo "### starting devtunnel. Type ^C to stop the tunnel and take down all containers"
- devtunnel host $TUNNELID > /dev/null
+ if (echo $DEPLOYMENT_CLIENTS | tr ',' '\n' | grep -Fqx android); then
+ echo "### building android examples"
+
+ if pushd ../examples/clients/mobile/android/PawsKey/ > /dev/null; then
+ if [[ $(command -v adb) ]]; then
+ ADB="adb"
+ elif [[ -e ${ANDROID_HOME}/platform-tools/adb ]]; then
+ ADB="${ANDROID_HOME}/platform-tools/adb"
+ else
+ ADB=""
+ fi
+
+ if [[ -n ${ADB} ]] && [[ $(${ADB} devices | wc -l) -gt 2 ]]; then
+ echo found a connected phone, installing app
+ ./gradlew installDebug
+
+ echo your android application is deployed to
+ echo
+ echo "$(tput bold) a connected phone $(tput sgr0)"
+ echo
+
+ ${ADB} -d shell am start -n io.yubicolabs.pawskey/io.yubicolabs.pawskey.MainActivity || { echo "Android app could not be launched. See above for details."; }
+
+ else
+ echo no phone found, building app without installing
+ ./gradlew assembleDebug
+
+ echo your android application ia deployed here:
+ echo
+ echo "$(tput bold) ../examples/clients/mobile/android/PawsKey/app/build/outputs/apk/debug/app-debug.apk $(tput sgr0)"
+ echo
+ fi
+
+ popd > /dev/null
+ else
+ echo android example folder not found.
+ fi
+ fi
+
+ echo "### starting devtunnel. Type ^C to stop the tunnel and take down all containers"
+ devtunnel host $TUNNELID > /dev/null
- docker compose down
- exit
+ docker compose down
+ exit
fi
# default is deploy on localhost
diff --git a/docs/.gitignore b/docs/.gitignore
index b2d6de30..4d860457 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -18,3 +18,4 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+yarn.lock
diff --git a/docs/docs/mobile-clients/_category_.json b/docs/docs/mobile-clients/_category_.json
new file mode 100644
index 00000000..a094ecde
--- /dev/null
+++ b/docs/docs/mobile-clients/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Mobile clients",
+ "position": 6,
+ "link": {
+ "type": "generated-index",
+ "description": "This section contains mobile client examples."
+ }
+}
diff --git a/docs/docs/mobile-clients/android/_category_.json b/docs/docs/mobile-clients/android/_category_.json
new file mode 100644
index 00000000..64793d7a
--- /dev/null
+++ b/docs/docs/mobile-clients/android/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Android",
+ "position": 1,
+ "link": {
+ "type": "generated-index",
+ "description": "Find a detailed description on how to build several android apps to use WebAuthn."
+ }
+}
diff --git a/docs/docs/mobile-clients/android/advanced/_category_.json b/docs/docs/mobile-clients/android/advanced/_category_.json
new file mode 100644
index 00000000..df28cf05
--- /dev/null
+++ b/docs/docs/mobile-clients/android/advanced/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Advanced",
+ "position": 100,
+ "link": {
+ "type": "generated-index",
+ "description": "Advanced topics not covered elsewhere."
+ }
+}
diff --git a/docs/docs/mobile-clients/android/advanced/getting-started.md b/docs/docs/mobile-clients/android/advanced/getting-started.md
new file mode 100644
index 00000000..ad1f3f36
--- /dev/null
+++ b/docs/docs/mobile-clients/android/advanced/getting-started.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 1
+---
+
+# Complex Use Cases
+
+:::tip
+Add content here
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/android/advanced/platform-credential-manager.md b/docs/docs/mobile-clients/android/advanced/platform-credential-manager.md
new file mode 100644
index 00000000..bf06aa0d
--- /dev/null
+++ b/docs/docs/mobile-clients/android/advanced/platform-credential-manager.md
@@ -0,0 +1,13 @@
+---
+sidebar_position: 2
+---
+
+# Credential Manager on Android
+
+## Signing / Uploading Key Fingerprinting
+
+:::tip
+Automate deploy script to include apk fingerprinting
+
+:::tip
+Add why credential manager on google play services
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/android/app-authenticate-user.md b/docs/docs/mobile-clients/android/app-authenticate-user.md
new file mode 100644
index 00000000..bb2e35e5
--- /dev/null
+++ b/docs/docs/mobile-clients/android/app-authenticate-user.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 4
+---
+
+# Authentication
+
+:::tip
+How to authenticate, ui, api, sdk
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/android/app-polishing.md b/docs/docs/mobile-clients/android/app-polishing.md
new file mode 100644
index 00000000..8e6be87a
--- /dev/null
+++ b/docs/docs/mobile-clients/android/app-polishing.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 5
+---
+
+# Polishing
+
+:::tip
+Think about simple gotchas to be added before starting production of this sample.
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/android/app-register-user.md b/docs/docs/mobile-clients/android/app-register-user.md
new file mode 100644
index 00000000..565d279e
--- /dev/null
+++ b/docs/docs/mobile-clients/android/app-register-user.md
@@ -0,0 +1,25 @@
+---
+sidebar_position: 3
+---
+
+# Registration
+
+In this section we will explain how our app can use register users using a yubikey and the relying party. In the
+following
+graphic we see the 'authenticator' (i.e. the Yubikey creating credentials), the client (i.e. out android app mediating)
+and the relying party called 'application'.
+
+
+
+So in order to register the user, we need the Android application to implement the communication to the relying party
+next.
+
+## Register with Relying Party
+
+## Create Credentials on Authenticator
+
+## Provide Public Key to Relying Party
diff --git a/docs/docs/mobile-clients/android/app-to-rp.md b/docs/docs/mobile-clients/android/app-to-rp.md
new file mode 100644
index 00000000..97aec8f0
--- /dev/null
+++ b/docs/docs/mobile-clients/android/app-to-rp.md
@@ -0,0 +1,180 @@
+---
+sidebar_position: 2
+---
+
+# Connecting to the Relying Party
+
+We will cover the backend part, called relying party, only briefly and for a more indepth discussion, please follow
+the [Relying Party](/passkey-workshop/docs/category/relying-party) section.
+
+We'll use the Relying Party as an API to register a new user using their Yubikey. The idea is that we build an Android
+App that communicates via REST to the Relying Party and the Yubikey to register a new user.
+
+In the later section [banking app example](advanced/getting-started.md) we will use that basis for a more complex
+banking example, complete with several activities, user flows and more.
+
+In order to build the connection, we need to understand how to build the Relying Party backend service, make it run, and
+connect the Android App to it.
+
+
+
+## Deploying the Relying Party
+
+Summarizing from [Relying Party](/passkey-workshop/docs/category/relying-party) what we need to start the backend, is to
+execute the `./deploy.sh` (or `./deploy.ps1` on windows) script from the `deploy` folder.
+
+To call the deploy script, you need `docker` and a `virtual machine`. Either can be fulfilled by either
+installing [docker desktop](https://docs.docker.com/desktop/)
+or [docker with compose](https://docs.docker.com/compose/install/) and [podman](https://podman.io/docs/installation).
+Please see the appropriate documentation, since this will not be covered here.
+
+:::tip Running Virtual Machines
+Please remember to run a virtual machine for docker, i.e. `podman machine start` if you receive errors like
+```Cannot connect to the Docker daemon at unix:///var/run/docker.sock.```
+:::
+
+Running the deploy script will start the server on the localhost using http. Feel free to explore the web frontend
+running on your machine here [http://localhost:3000](http://localhost:3000). This will be the part we are reimplementing
+in Android, so feel free to click around, register and get familiar.
+
+## Android and Secure HTTP Traffic
+
+Since [Android 9 (API Level 27)](https://developer.android.com/privacy-and-security/security-config#CleartextTrafficPermitted)
+all internet traffic needs to be secured from eavesdropping using secure transport. This means we cannot use our local
+server running on `localhost` to connect to our Android App. So we need to secure our traffic.
+
+The solution is to use a service like [devtunnel](https://learn.microsoft.com/en-gb/azure/developer/dev-tunnels/) to
+expose a local web service to the internet.
+
+### Installation of devtunnel
+
+Please install devtunnel like so:
+
+* `brew install devtunnel` (mac) or
+* `curl -sL https://aka.ms/DevTunnelCliInstall | bash` (linux) or
+* `winget install Microsoft.devtunnel` (windows)
+
+Once installed, we need to tell the deploy script to use devtunnel. Please copy the [default.enf](/deploy/default.env)
+default configuration file to [.env](/deploy/.env) file in the deploy folder. Now that we have copied the default
+configuration file, please update this line
+
+```
+DEPLOYMENT_ENVIRONMENT=localhost
+```
+
+to set the deployment environment to be the devtunnel:
+
+```
+DEPLOYMENT_ENVIRONMENT=devtunnel
+```
+
+:::tip
+Please remember to login to devtunnel: `devtunnel user login`. (Using 'other' for the microsoft login, you can use
+GitHub and will not need to create a new microsoft account.)
+:::
+
+To start the deployment to the devtunnel, please execute the `./deploy.sh` (mac or linux) or `./deploy.ps1`(windows).
+
+## Validating deployment
+
+If everything went smoothly, you should see an output similar to
+
+```shell
+[...]
+your demo application will be deployed here:
+
+ https://XXXXXXXXXXXXXX-3000.euw.devtunnels.ms/test_panel
+
+your bank application will be deployed here:
+
+ https://XXXXXXXXXXXXXX-3002.euw.devtunnels.ms/
+
+### starting devtunnel. Type ^C to stop the tunnel and take down all containers
+```
+
+To verify, let us browse to the status page of the just deployed webservice. Therefore, you need to take the url the
+devtunnel is running on and update it. From the example above, please take the
+``` https://XXXXXXXXXXXXXX-3000.euw.devtunnels.ms/test_panel``` url and replace the `3000` with the port the service is
+running on, `8080` in our case.
+
+You should be able to visit the relying party running on ```https://XXXXXXXXXXXXXX-8080.euw.devtunnels.ms/``` (please
+replace the X with your endpoint) and be greeted with a similar image then this screenshot:
+
+
+
+The security check is to make sure that we know what we are doing, and since the url is under our control it is fine to
+accept the connection and hit `continue`.
+
+If everything is running as planned, you will see a message like this
+
+```json
+{
+ "status": "ok"
+}
+```
+
+This means our local server is available through the devtunnel, and works as expected. You can now also browse through
+the other components, securely transmitting data from your local machine through the tunnel to the local browser.
+Now that we know how to [deploy and run our backend](relying-party.md), let us connect our
+[Android app](https://github.com/YubicoLabs/passkey-workshop/tree/main/examples/clients/mobile/android) to it.
+
+## Deploy script
+
+The deploy script sets up the backend relying party to be deployed on docker and then exposed through `devtunnel` from
+your local machine to the broad internet. Using TLS it is able to allow a connection from your phone securely to your
+laptop.
+
+Once this connection is established, the script finds the Android examples and modifies the configuration
+in [gradle.properties](../../../../examples/clients/mobile/android/PawsKey/gradle.properties) to reflect the newly
+created tunnel. This way if the tunnel changes, a call to the deploy script will also update that configuration. You
+might want to change the configuration manually to contain the endpoint your relying party is running on.
+
+Additionally the deploy script also updates the relying party id, as in the id used to identify which credentials to
+select. You will also need to update that configuration, if you update the endpoint.
+
+Once the configuration is updated, the script builds the android examples using `gradle`. With the source, which we will
+go through in detail soon, we try to reflect current best practices and will not emphasize common ways of working.
+Please follow linked literature if you want to get up to speed in everything from MVVM[1], Jetpack Compose[2] and DI
+using Hilt[3].
+
+## Dependencies
+
+Additionally, the mentioned libraries and architectures the android examples depend on retrofit and kotlinx
+serialization: Those are used in order to communicate with the relying
+parties: [RelyingPartyService.kt](../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt)
+implements the Retrofit Service to communicate with the relying
+party. [PassKeyService.kt](../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt)
+is used for communicating with the passkeys, may it be from the platform perspective or the Yubico SDK point of view.
+
+All of this is tied together by the [MainViewModel.kt][5], that abstracts from the service implementations to expose the
+view relevant details. Finally, the [MainActivity.kt][4] displays relevant details to the user. And gets called when she
+wants to sign in / log in or interact otherwise with the app.
+
+## Hello Relying Party Server
+
+Let us follow the flow of the example app to communicate with the relying party with the example of requesting a status
+from it's `/v1/status` endpoint. Once the user starts the app, the [MainActivity][4] initializes and creates the
+[MainViewModel][5]. Once the [MainViewModel][5] is created, it asks its [Repository for RelyingParty][6] to fetch it's
+status. Fetching the status has two effects: A) we are making sure the network is working. And B) that we are connecting
+to a well known backend, before we are starting more complex interactions.
+
+
+[1]: https://developer.android.com/topic/architecture
+
+[2]: https://developer.android.com/compose
+
+[3]: https://developer.android.com/training/dependency-injection/hilt-android
+
+[4]: ../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt
+
+[5]: ../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt
+
+[6]: ../../../../examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/android/getting-started.md b/docs/docs/mobile-clients/android/getting-started.md
new file mode 100644
index 00000000..8e0f9469
--- /dev/null
+++ b/docs/docs/mobile-clients/android/getting-started.md
@@ -0,0 +1,64 @@
+---
+sidebar_position: 1
+---
+
+# Getting Started Guide
+
+This section will get you started with a running the Android Pawskey example, as soon as possible. It requires you to
+have
+
+1. a running docker installation (including a running docker virtual machine),
+2. a devtunnel setup and being logged in there and
+3. the ability to build Android apps through `gradle` (i.e. the `ANDROID_HOME` set to your SDK installation home.)
+
+If you are unsure about either installation or you want to understand the setup steps needed in detail, we recommend
+reading the [getting started section](getting-started.md) first.
+
+## Set Configuration to Android
+
+Assuming everything is setup, please checkout this passkeys repository from `github.com/yubicolabs/passkey-workshop` on
+your machine and browse to the root of that repository in your `terminal` of choice.
+
+Once arrived there, we need to change into the deploy folder like so:
+
+```shell
+cd deploy
+```
+
+Since we would like to build the Android example apps, we need to update the default configuration. Please copy the
+default environment configuration file `default.env` to a new file called `.env`:
+
+```shell
+cp default.env .env
+```
+
+To update the configuration you need to open the newly created `.env` file and add `android` to the `DEPLOYMENT_CLIENTS`
+and make sure that we deploy the web components to the `devtunnel` environment. Please assigning `devtunnel` to the
+`DEPLOYMENT_ENVIRONMENT` configuration.
+
+Once done, the `.env` file should contain these changes, when compared to the `default.env`:
+
+```diff
+-DEPLOYMENT_ENVIRONMENT=localhost
++DEPLOYMENT_ENVIRONMENT=devtunnel
+
+-DEPLOYMENT_CLIENTS=bank,demo
++DEPLOYMENT_CLIENTS=bank,demo,android
+```
+
+## Deploy *Everything*
+
+With the configuration changes done, a call to the main deploy script, like shown below, should do the trick. Please
+connect a phone in order to automatically deploy the simple Android example App to that phone. Additionally, the script
+will automatically connect your the docker services on your local machine via a tunnel through to a public, tls secured
+domain we can access from the Android example app.
+
+```shell
+./deploy.sh
+```
+
+## Troubleshooting
+
+We build the deploy script in such a way that it reports errors and missing setup on your machine in an easy to correct
+manner. If you still feel stuck somewhere, or want a deeper understanding, please feel free to follow the more indepth
+steps starting with the [next section](app-to-rp.md) and the sections following.
diff --git a/docs/docs/mobile-client/_category_.json b/docs/docs/mobile-clients/ios/_category_.json
similarity index 85%
rename from docs/docs/mobile-client/_category_.json
rename to docs/docs/mobile-clients/ios/_category_.json
index 476808a7..a4897dc7 100644
--- a/docs/docs/mobile-client/_category_.json
+++ b/docs/docs/mobile-clients/ios/_category_.json
@@ -1,6 +1,6 @@
{
- "label": "Mobile client",
- "position": 6,
+ "label": "iOS",
+ "position": 2,
"link": {
"type": "generated-index",
"description": "This section will cover the mobile iOS client passkey application. We are going to discuss the different user flows, implementation examples, and best practices for creating the application."
diff --git a/docs/docs/mobile-clients/ios/advanced/_category_.json b/docs/docs/mobile-clients/ios/advanced/_category_.json
new file mode 100644
index 00000000..df28cf05
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/advanced/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Advanced",
+ "position": 100,
+ "link": {
+ "type": "generated-index",
+ "description": "Advanced topics not covered elsewhere."
+ }
+}
diff --git a/docs/docs/mobile-clients/ios/advanced/complex-use-cases.md b/docs/docs/mobile-clients/ios/advanced/complex-use-cases.md
new file mode 100644
index 00000000..3e7ee660
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/advanced/complex-use-cases.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 1
+---
+
+# Complex Use Cases
+
+:::tip
+Add content here.
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/ios/app-authenticate-user.md b/docs/docs/mobile-clients/ios/app-authenticate-user.md
new file mode 100644
index 00000000..0cd02c60
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/app-authenticate-user.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 4
+---
+
+# Authentication
+
+:::tip
+{{How to authenticate a user using yubikeys}}
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/ios/app-polishing.md b/docs/docs/mobile-clients/ios/app-polishing.md
new file mode 100644
index 00000000..8e6be87a
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/app-polishing.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 5
+---
+
+# Polishing
+
+:::tip
+Think about simple gotchas to be added before starting production of this sample.
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/ios/app-register-user.md b/docs/docs/mobile-clients/ios/app-register-user.md
new file mode 100644
index 00000000..8e63867a
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/app-register-user.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 3
+---
+
+# Registration
+
+:::tip
+{{ How to register on iOS? }}
diff --git a/docs/docs/mobile-clients/ios/app-to-rp.md b/docs/docs/mobile-clients/ios/app-to-rp.md
new file mode 100644
index 00000000..382fc2f4
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/app-to-rp.md
@@ -0,0 +1,9 @@
+---
+sidebar_position: 2
+---
+
+# Connecting to the Relying Party
+
+:::tip
+{{ How to check the rp is running }}
+{{ How to connect iOS to RP?}}
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/ios/getting-started.md b/docs/docs/mobile-clients/ios/getting-started.md
new file mode 100644
index 00000000..ac2dee44
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/getting-started.md
@@ -0,0 +1,8 @@
+---
+sidebar_position: 1
+---
+
+# Getting Started
+
+:::tip
+{{ use deploy.sh }}
\ No newline at end of file
diff --git a/docs/docs/mobile-clients/ios/legacy/_category_.json b/docs/docs/mobile-clients/ios/legacy/_category_.json
new file mode 100644
index 00000000..4061866b
--- /dev/null
+++ b/docs/docs/mobile-clients/ios/legacy/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "~Legacy~",
+ "position": 100,
+ "link": {
+ "type": "generated-index",
+ "description": "The legacy documentation can be found here."
+ }
+}
diff --git a/docs/docs/mobile-client/getting-started.md b/docs/docs/mobile-clients/ios/legacy/getting-started.md
similarity index 90%
rename from docs/docs/mobile-client/getting-started.md
rename to docs/docs/mobile-clients/ios/legacy/getting-started.md
index fca68189..028571bf 100644
--- a/docs/docs/mobile-client/getting-started.md
+++ b/docs/docs/mobile-clients/ios/legacy/getting-started.md
@@ -8,7 +8,7 @@ Instructions for making the local environment available for testing from your mo
## Prerequisites
-1. Ensure you have completed the [deploy project](../deploy) section in the passkey workshop by navigating to the relying party API documentation running locally in your environment: [http://localhost:8080](http://localhost:8080)
+1. Ensure you have completed the [deploy project](../../../deploy) section in the passkey workshop by navigating to the relying party API documentation running locally in your environment: [http://localhost:8080](http://localhost:8080)
2. An Apple Developer account with permission to create Associated Domains Entitlements
3. Install Xcode 14.3 or newer
4. Have a physical iPhone running iOS 16.4 or newer
@@ -16,7 +16,7 @@ Instructions for making the local environment available for testing from your mo
## PawsKey iOS app
-Once you have [cloned](../deploy#clone-the-repository) the passkey workshop, navigate into the Pawskey iOS project folder
+Once you have [cloned](../../deploy#clone-the-repository) the passkey workshop, navigate into the Pawskey iOS project folder
```javascript
cd passkey-workshop/clients/mobile/iOS/Pawskey
diff --git a/docs/static/img/mobile/android/relying-party-architecture.jpg b/docs/static/img/mobile/android/relying-party-architecture.jpg
new file mode 100644
index 00000000..b52aa5c8
Binary files /dev/null and b/docs/static/img/mobile/android/relying-party-architecture.jpg differ
diff --git a/docs/static/img/mobile/android/relying-party-status-hint.png b/docs/static/img/mobile/android/relying-party-status-hint.png
new file mode 100644
index 00000000..cb6a17f6
Binary files /dev/null and b/docs/static/img/mobile/android/relying-party-status-hint.png differ
diff --git a/examples/clients/mobile/android/PawsKey/.gitignore b/examples/clients/mobile/android/PawsKey/.gitignore
new file mode 100644
index 00000000..aa724b77
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/examples/clients/mobile/android/PawsKey/app/.gitignore b/examples/clients/mobile/android/PawsKey/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/build.gradle.kts b/examples/clients/mobile/android/PawsKey/app/build.gradle.kts
new file mode 100644
index 00000000..d6ce1aaa
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/build.gradle.kts
@@ -0,0 +1,104 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.dagger.hilt)
+ alias(libs.plugins.jetbrains.compose.compiler)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.kotlinx.serialization)
+ alias(libs.plugins.ksp)
+}
+
+fun getPropOrFail(name: String): String {
+ val property = providers.gradleProperty(name)
+ return if (property.isPresent && !property.get().startsWith("replace-with-your-")) {
+ "\"${property.get()}\""
+ } else {
+ throw GradleException("Gradle property '$name' invalid. Did you update it in '/gradle.properties'?")
+ }
+}
+
+android {
+ namespace = "io.yubicolabs.pawskey"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "io.yubicolabs.pawskey"
+ minSdk = 31
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+
+ all {
+ buildConfigField("String", "API_BASE_URI", getPropOrFail("API_BASE_URI"))
+ buildConfigField("String", "RELYING_PARTY_ID", getPropOrFail("RELYING_PARTY_ID"))
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion =
+ libs.versions.androidx.compose.compiler
+ .get()
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.yubico.yubikit)
+ implementation(libs.yubico.yubikit.fido)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.dagger.hilt)
+ implementation(libs.kotlinx.serialization)
+ implementation(libs.log4j)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.kotlinx.serialization)
+
+ ksp(libs.dagger.hilt.compiler)
+
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.junit)
+
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+
+ debugImplementation(libs.androidx.ui.test.manifest)
+ debugImplementation(libs.androidx.ui.tooling)
+}
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/proguard-rules.pro b/examples/clients/mobile/android/PawsKey/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/AndroidManifest.xml b/examples/clients/mobile/android/PawsKey/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..6e1e05aa
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/assets/logback.xml b/examples/clients/mobile/android/PawsKey/app/src/main/assets/logback.xml
new file mode 100644
index 00000000..fd590840
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/assets/logback.xml
@@ -0,0 +1,18 @@
+
+
+
+ %logger{12}
+
+
+ [%-20thread] %msg
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ActivityProvider.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ActivityProvider.kt
new file mode 100644
index 00000000..72ac5a46
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ActivityProvider.kt
@@ -0,0 +1,18 @@
+package io.yubicolabs.pawskey
+
+/**
+ * Provide the last used Activity.
+ */
+interface ActivityProvider {
+ fun getActivity(): MainActivity
+}
+
+/**
+ * Implementation for Pawskeys
+ */
+class PawskeyActivityProvider : ActivityProvider {
+ override fun getActivity(): MainActivity {
+ val lastActivity = PawskeyApplication.instance.mainActivity // TODO: 👀👀👀👀
+ return lastActivity
+ }
+}
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ApiModule.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ApiModule.kt
new file mode 100644
index 00000000..ce25a175
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ApiModule.kt
@@ -0,0 +1,101 @@
+package io.yubicolabs.pawskey
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import com.yubico.yubikit.android.YubiKitManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.yubicolabs.pawskey.net.DebugInterceptor
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType
+import okhttp3.OkHttpClient
+import retrofit2.Converter
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+
+@InstallIn(ViewModelComponent::class)
+@Module
+object ApiModule {
+ @Provides
+ fun provideRelyingPartyService(
+ relyingPartyHttpService: RelyingPartyHttpService,
+ ): RelyingPartyService =
+ RelyingPartyService(
+ relyingPartyHttpService,
+ )
+
+ @Provides
+ fun provideRelyingPartyHttpService(
+ okHttpClient: OkHttpClient,
+ jsonConverter: Converter.Factory,
+ ): RelyingPartyHttpService =
+ Retrofit.Builder()
+ .baseUrl("https://${BuildConfig.API_BASE_URI}/")
+ .client(okHttpClient)
+ .addConverterFactory(jsonConverter)
+ .build()
+ .create(RelyingPartyHttpService::class.java)
+
+ @Provides
+ fun provideOkHttpClient(): OkHttpClient {
+ val builder = OkHttpClient()
+ .newBuilder()
+
+ if (BuildConfig.DEBUG) {
+ builder.addInterceptor(
+ DebugInterceptor()
+ )
+ }
+
+ return builder.build()
+ }
+
+ @Provides
+ fun providesJson(): Json = Json {
+ encodeDefaults = true
+ ignoreUnknownKeys = true
+ prettyPrint = BuildConfig.DEBUG
+ }
+
+ @Provides
+ fun providesJsonFactory(json: Json): Converter.Factory = json
+ .asConverterFactory(
+ MediaType.get("application/json")
+ )
+
+ @Provides
+ fun provideClipboard(
+ @ApplicationContext
+ context: Context
+ ): ClipboardProvider = object : ClipboardProvider {
+ override fun setContent(message: String) {
+ val manager = context.getSystemService(ClipboardManager::class.java)
+ manager.setPrimaryClip(
+ ClipData.newPlainText("Message from $tagForLog.", message)
+ )
+ }
+ }
+
+ @Provides
+ fun provideActivityProvider(): ActivityProvider = PawskeyActivityProvider()
+
+ @Provides
+ fun providePasskeyService(
+ yubiKitManager: YubiKitManager,
+ activityProvider: ActivityProvider,
+ ): PassKeyService = PassKeyService(
+ yubico = yubiKitManager,
+ activityProvider = activityProvider
+ )
+
+ @Provides
+ fun provideYubiManager(
+ @ApplicationContext
+ context: Context
+ ): YubiKitManager =
+ YubiKitManager(context)
+}
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Application.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Application.kt
new file mode 100644
index 00000000..f7cd1c45
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Application.kt
@@ -0,0 +1,22 @@
+package io.yubicolabs.pawskey
+
+import android.app.Activity
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class PawskeyApplication : Application() {
+ companion object {
+ private lateinit var application: PawskeyApplication
+
+ val instance: PawskeyApplication
+ get() = application
+ }
+
+ lateinit var mainActivity: MainActivity
+
+ override fun onCreate() {
+ super.onCreate()
+ application = this
+ }
+}
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ClipboardProvider.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ClipboardProvider.kt
new file mode 100644
index 00000000..83d18d44
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ClipboardProvider.kt
@@ -0,0 +1,8 @@
+package io.yubicolabs.pawskey
+
+/**
+ * Abstraction from clipboard
+ */
+interface ClipboardProvider {
+ fun setContent(message: String)
+}
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Extensions.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Extensions.kt
new file mode 100644
index 00000000..58ba3b28
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/Extensions.kt
@@ -0,0 +1,8 @@
+package io.yubicolabs.pawskey
+
+/**
+ * @warn pollutes all classes.
+ * @return name for this class, or a placeholder.
+ */
+inline val Any.tagForLog: String
+ get() = javaClass.simpleName ?: "YubiSample"
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt
new file mode 100644
index 00000000..2d997325
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainActivity.kt
@@ -0,0 +1,428 @@
+package io.yubicolabs.pawskey
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+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.Spacer
+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.lazy.items
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import dagger.hilt.android.AndroidEntryPoint
+import io.yubicolabs.pawskey.Message.AttestationOptionsReceived
+import io.yubicolabs.pawskey.Message.KeysFound
+import io.yubicolabs.pawskey.Message.PublicKeyGenerated
+import io.yubicolabs.pawskey.Message.ServerOkay
+import io.yubicolabs.pawskey.UserInteraction.AttestationError
+import io.yubicolabs.pawskey.UserInteraction.ConnectKey
+import io.yubicolabs.pawskey.UserInteraction.CreationError
+import io.yubicolabs.pawskey.UserInteraction.EnterPin
+import io.yubicolabs.pawskey.UserInteraction.UserNameWrong
+import io.yubicolabs.pawskey.UserInteraction.WaitForRP
+import io.yubicolabs.pawskey.ui.theme.PawsKeyTheme
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ private val vm: MainViewModel by viewModels()
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val log: Logger = LoggerFactory.getLogger(MainActivity::class.java)
+ log.info("hello world")
+
+
+ /// TODO Don't look to closely: NFC Needs the main activity.
+ PawskeyApplication.instance.mainActivity = this
+
+ enableEdgeToEdge()
+ setContent {
+ PawsKeyTheme {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(id = R.string.app_name)
+ )
+ }
+ )
+ }
+ ) { innerPadding ->
+ val state by vm.uiState.collectAsState()
+ Box {
+ PawskeyUi(
+ modifier = Modifier.padding(innerPadding),
+ uiState = state,
+ checkServer = vm::checkApiStatus,
+ register = vm::registerUser,
+ copyToClipboard = { message ->
+ vm.copyToClipboard(message.toString())
+ },
+ delete = { message ->
+ vm.deleteMessage(message)
+ }
+ )
+
+ state.userInteractionNeeded?.let { interaction ->
+ UserInteractionModal(
+ interaction,
+ setPin = { vm.pin = it },
+ cancelled = vm::interactionCancelled,
+ copy = { vm.copyToClipboard(it) },
+ cancelAllInteractions = vm::cancelAllInteractions
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+fun UserInteractionModal(
+ interaction: UserInteraction = EnterPin,
+ setPin: (pin: String) -> Unit = {},
+ cancelled: () -> Unit = {},
+ copy: (message: String) -> Unit = {},
+ cancelAllInteractions: () -> Unit = {},
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ when (interaction) {
+ is EnterPin -> EnterPinModal(
+ success = setPin,
+ cancelled = cancelled
+ )
+
+ is WaitForRP -> WaitForRPModal(
+ cancelled = cancelled
+ )
+
+ is UserNameWrong -> ErrorMessage(
+ message = stringResource(id = R.string.error_wrong_user_name),
+ confirm = cancelled,
+ dismiss = cancelled,
+ copy = copy,
+ )
+
+ is AttestationError -> ErrorMessage(
+ message = stringResource(id = R.string.error_no_attestatation),
+ confirm = cancelled,
+ dismiss = cancelled,
+ copy = copy
+ )
+
+ is CreationError -> ErrorMessage(
+ message = stringResource(id = R.string.error_not_created),
+ confirm = cancelled,
+ dismiss = cancelled,
+ copy = copy
+ )
+
+ is ConnectKey -> ConnectKeyMessage(cancelled = cancelAllInteractions)
+ }
+
+ }
+}
+
+@Composable
+@Preview
+private fun ConnectKeyMessage(
+ cancelled: () -> Unit = {}
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Card(
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ modifier = Modifier.padding(bottom = 16.dp),
+ style = MaterialTheme.typography.headlineMedium,
+ text = "Please connect a key."
+ )
+ CircularProgressIndicator()
+ Row {
+ Spacer(modifier = Modifier.weight(1.0f))
+ Button(onClick = cancelled) {
+ Text(text = stringResource(id = android.R.string.cancel))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun WaitForRPModal(
+ cancelled: () -> Unit = {}
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Card(
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ modifier = Modifier.padding(bottom = 16.dp),
+ style = MaterialTheme.typography.headlineMedium,
+ text = "Waiting for Relying Party."
+ )
+ CircularProgressIndicator()
+ Row {
+ Spacer(modifier = Modifier.weight(1.0f))
+ Button(onClick = cancelled) {
+ Text(text = stringResource(id = android.R.string.cancel))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun ErrorMessage(
+ message: String = "Something went wrong.",
+ confirm: () -> Unit = {},
+ dismiss: () -> Unit = {},
+ copy: (message: String) -> Unit = {},
+) {
+ AlertDialog(
+ onDismissRequest = dismiss,
+ confirmButton = {
+ Button(onClick = confirm) {
+ Text(text = stringResource(id = android.R.string.ok))
+ }
+ },
+ title = { Text(stringResource(id = R.string.dialog_title_error)) },
+ text = { Text(message) },
+ dismissButton = {
+ IconButton(onClick = { copy(message) }) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_content_copy_24),
+ contentDescription = null
+ )
+ }
+ },
+ )
+}
+
+@Composable
+@Preview
+private fun EnterPinModal(
+ cancelled: () -> Unit = {},
+ success: (pin: String) -> Unit = {}
+) {
+ // TODO Alert Dialog?
+ // TODO CUSTOM VM?
+ var pin: String by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = cancelled,
+ confirmButton = {
+ Button(onClick = { success(pin) }) {
+ Text(text = stringResource(id = android.R.string.ok))
+ }
+ },
+ title = { Text(stringResource(id = R.string.dialog_title_pin_needed)) },
+ text = {
+ Column {
+ Text(
+ modifier = Modifier.padding(vertical = 5.dp),
+ text = stringResource(R.string.pin_dialog_need)
+ )
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = pin,
+ label = { Text("Pin") },
+ onValueChange = { pin = it }
+ )
+ }
+ },
+ dismissButton = {
+ Button(onClick = cancelled) {
+ Text(text = stringResource(id = android.R.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+@Preview
+fun PawskeyUi(
+ modifier: Modifier = Modifier,
+ uiState: UiState = UiState(
+ userMessages = listOf(
+ ServerOkay(),
+ PublicKeyGenerated("id", "type"),
+ AttestationOptionsReceived("id", "key")
+ )
+ ),
+ checkServer: () -> Unit = {},
+ register: (userName: String, pin: String) -> Unit = { _, _ ->
+ },
+ copyToClipboard: (message: Message) -> Unit = {},
+ delete: (message: Message) -> Unit = {},
+) {
+ var userName: String by remember { mutableStateOf("Android Test User") }
+ var pin: String by remember { mutableStateOf("123456") }
+
+ Column(
+ modifier = modifier
+ ) {
+ Card {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ TextField(
+ value = userName,
+ label = {
+ Text(
+ stringResource(id = R.string.user_name_label)
+ )
+ },
+ onValueChange = { userName = it }
+ )
+ TextField(
+ value = pin,
+ label = {
+ Text(
+ stringResource(id = R.string.pin_label)
+ )
+ },
+ onValueChange = { pin = it }
+ )
+ Row {
+ Button(onClick = checkServer) {
+ Text(
+ text = "Status"
+ )
+ }
+ Button(onClick = { register(userName, pin) }) {
+ Text(
+ text = "Register"
+ )
+ }
+ }
+ }
+ }
+
+ Card {
+ LazyColumn {
+ items(uiState.userMessages.reversed()) { message ->
+ UserMessage(
+ message,
+ { copyToClipboard(message) },
+ { delete(message) }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun UserMessage(
+ message: Message = ServerOkay(),
+ copyToClipboard: () -> Unit = {},
+ delete: () -> Unit = {},
+) {
+ Row(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier.weight(1.0f),
+ style = MaterialTheme.typography.bodyLarge,
+ text = message.toString()
+ )
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .clickable { delete() },
+ painter = painterResource(id = R.drawable.baseline_outline_delete_24),
+ contentDescription = null
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ modifier = Modifier
+ .size(20.dp)
+ .clickable { copyToClipboard() },
+ painter = painterResource(id = R.drawable.baseline_content_copy_24),
+ contentDescription = null
+ )
+ }
+}
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt
new file mode 100644
index 00000000..30507385
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/MainViewModel.kt
@@ -0,0 +1,354 @@
+@file:OptIn(ExperimentalUuidApi::class)
+
+package io.yubicolabs.pawskey
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.yubico.yubikit.fido.client.BasicWebAuthnClient
+import com.yubico.yubikit.fido.webauthn.AuthenticatorSelectionCriteria
+import com.yubico.yubikit.fido.webauthn.PublicKeyCredential
+import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialCreationOptions
+import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialDescriptor
+import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialParameters
+import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialRpEntity
+import com.yubico.yubikit.fido.webauthn.PublicKeyCredentialUserEntity
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.yubicolabs.pawskey.Message.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import retrofit2.HttpException
+import java.net.ConnectException
+import java.net.SocketTimeoutException
+import javax.inject.Inject
+import kotlin.time.ExperimentalTime
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+sealed class UserInteraction {
+ data object WaitForRP : UserInteraction()
+
+ data object EnterPin : UserInteraction()
+
+ data object UserNameWrong : UserInteraction()
+
+ data object ConnectKey : UserInteraction()
+
+ data object AttestationError : UserInteraction()
+
+ data object CreationError : UserInteraction()
+}
+
+sealed class Message(
+ val uuid: Uuid = Uuid.random()
+) {
+ data class ServerOkay(
+ val additionalInfo: String? = null
+ ) : Message()
+
+ data class ServerNotOkay(
+ val th: Throwable? = null
+ ) : Message()
+
+ data class ServerNotConnected(
+ val th: Throwable
+ ) : Message()
+
+ data class ServerTimeout(
+ val additionalInfo: String? = null
+ ) : Message()
+
+ data class UserNameWrong(
+ val additionalInfo: String? = null
+ ) : Message()
+
+ data class PublicKeyGenerated(
+ val id: String,
+ val type: String,
+ ) : Message()
+
+ data class AttestationOptionsReceived(
+ val id: String,
+ val key: String,
+ ) : Message()
+
+ data class KeysFound(
+ val keys: List
+ ) : Message()
+
+ data class ServerError(
+ val message: String
+ ) : Message()
+
+ data class KeyCreationFailure(
+ val th: Throwable
+ ) : Message()
+}
+
+data class UiState(
+ val userMessages: List = emptyList(),
+ val userInteractionNeeded: UserInteraction? = null,
+)
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val relyingPartyService: RelyingPartyService,
+ private val passKeyService: PassKeyService,
+ private val clipboard: ClipboardProvider,
+ private val json: Json,
+) : ViewModel() {
+
+ private val _uiState: MutableStateFlow = MutableStateFlow(
+ UiState()
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ var pin: String? = null
+
+ var userName: String? = null
+ var attestation: AttestationOptionsResponse? = null
+ var publicKey: PublicKeyCredential? = null
+
+ fun checkApiStatus() {
+ showUserInteraction(
+ UserInteraction.WaitForRP
+ )
+
+ viewModelScope.launch {
+ val message = try {
+ val result = relyingPartyService.getStatus()
+
+ if (result) {
+ ServerOkay()
+ } else {
+ ServerNotOkay()
+ }
+
+ } catch (_: SocketTimeoutException) {
+ ServerTimeout()
+ } catch (exception: HttpException) {
+ ServerError("${exception.code()}: ${exception.message()}")
+ }
+
+ showUserInteraction(null)
+ showUserMessage(message)
+ }
+ }
+
+ fun registerUser(userName: String, pin: String) {
+ if (userName.isEmpty()) {
+ showUserMessage(UserNameWrong())
+ showUserInteraction(UserInteraction.UserNameWrong)
+ } else if (pin.isEmpty()) {
+ showUserInteraction(UserInteraction.EnterPin)
+ } else {
+ this.userName = userName
+ this.pin = pin
+
+ viewModelScope.launch(Dispatchers.IO) {
+ showUserInteraction(UserInteraction.WaitForRP)
+ attestation = getAttestationOptionsFromRP()
+
+ if (attestation == null) {
+ showUserInteraction(UserInteraction.AttestationError)
+ } else {
+ createKey(attestation!!)
+ }
+ }
+ }
+ }
+
+ private fun createKey(attestationOptions: AttestationOptionsResponse) {
+ if (userName.isNullOrEmpty()) {
+ showUserInteraction(UserInteraction.UserNameWrong)
+ } else if (pin.isNullOrEmpty()) {
+ showUserInteraction(UserInteraction.EnterPin)
+ } else {
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.update {
+ it.copy(userInteractionNeeded = UserInteraction.ConnectKey)
+ }
+
+ passKeyService.addWebauthnClientListener(
+ object : PassKeyService.Listener {
+ override fun invoke(webAuthnClient: BasicWebAuthnClient) {
+ showUserInteraction(UserInteraction.WaitForRP)
+
+ createKeyOnDevice(attestationOptions, webAuthnClient)
+ }
+ },
+ updateWithCurrentState = true,
+ )
+ passKeyService.startDeviceDiscovery()
+ }
+ }
+ }
+
+ private fun PassKeyService.Listener.createKeyOnDevice(
+ attestationOptions: AttestationOptionsResponse,
+ webAuthnClient: BasicWebAuthnClient
+ ) {
+ try {
+ Log.i(tagForLog, "Client $this found.")
+
+ val clientDataJson = attestationOptions.toClientDataJsonByteArray()
+ val options = attestationOptions.publicKey.toOptions()
+ val effectiveDomain = attestationOptions.publicKey.rp.id
+
+ publicKey = webAuthnClient.makeCredential(
+ clientDataJson,
+ options,
+ effectiveDomain,
+ pin!!.toCharArray(),
+ null,
+ null,
+ )
+
+ if (publicKey != null) {
+ showUserInteraction(null)
+ showUserMessage(PublicKeyGenerated(publicKey!!.id, publicKey!!.type))
+ } else {
+ showUserInteraction(UserInteraction.CreationError)
+ }
+ } catch (throwable: Throwable) {
+ val message = KeyCreationFailure(throwable)
+ Log.e(tagForLog, message.toString(), throwable)
+
+ attestation = null
+
+ showUserMessage(message)
+ showUserInteraction(null)
+ }
+ }
+
+ private suspend fun getAttestationOptionsFromRP(): AttestationOptionsResponse? {
+ return try {
+ val result = relyingPartyService.getAttestationOptions(
+ userName ?: "Not set"
+ )
+
+ if (result.requestId.isNotBlank()) {
+ val hint = StringBuilder()
+ val user = result.publicKey.user
+ hint.append(json.encodeToString(user))
+
+ showUserMessage(
+ AttestationOptionsReceived(
+ result.requestId,
+ result.publicKey.toString(),
+ )
+ )
+ result
+ } else {
+ showUserMessage(ServerNotOkay())
+ null
+ }
+ } catch (e: ConnectException) {
+ showUserMessage(ServerNotConnected(e))
+ null
+ } catch (_: SocketTimeoutException) {
+ showUserMessage(ServerTimeout())
+ null
+ } catch (exception: HttpException) {
+ showUserMessage(ServerNotOkay(exception))
+ null
+ }
+ }
+
+ @OptIn(ExperimentalTime::class)
+ private fun showUserMessage(
+ message: Message
+ ) {
+ _uiState.update {
+ it.copy(userMessages = it.userMessages + message)
+ }
+ }
+
+ private fun showUserInteraction(
+ interaction: UserInteraction?
+ ) {
+ _uiState.update {
+ it.copy(userInteractionNeeded = interaction)
+ }
+ }
+
+ fun copyToClipboard(message: String) {
+ clipboard.setContent(message)
+ }
+
+ fun deleteMessage(message: Message) {
+ _uiState.update {
+ it.copy(
+ userMessages = it.userMessages.filter {
+ it.uuid != message.uuid
+ }
+ )
+ }
+ }
+
+ fun interactionCancelled() {
+ _uiState.update {
+ it.copy(
+ userInteractionNeeded = null
+ )
+ }
+ }
+
+ fun cancelAllInteractions() {
+ passKeyService.stopDeviceDiscovery()
+
+ _uiState.update {
+ it.copy(
+ userInteractionNeeded = null
+ )
+ }
+ }
+}
+
+private fun AttestationOptionsResponse.toClientDataJsonByteArray(): ByteArray = """
+ {
+ type: "webauthn.create",
+ challenge: "${publicKey.challenge}",
+ origin: "${publicKey.rp.id}"
+ }
+ """.trimIndent().trimIndent().toByteArray()
+
+private fun AttestationOptionsResponse.PublicKey.toOptions(): PublicKeyCredentialCreationOptions =
+ PublicKeyCredentialCreationOptions(
+ PublicKeyCredentialRpEntity(
+ rp.name,
+ rp.id
+ ),
+ PublicKeyCredentialUserEntity(
+ user.name,
+ user.id.toByteArray(),
+ user.displayName
+ ),
+ challenge.toByteArray(),
+ pubKeyCredParams.map {
+ PublicKeyCredentialParameters(
+ it.type,
+ it.alg
+ )
+ },
+ 180000,
+ excludeCredentials.map {
+ PublicKeyCredentialDescriptor(
+ it.type,
+ it.id.toByteArray()
+ )
+ },
+ AuthenticatorSelectionCriteria(
+ authenticatorSelection.authenticatorAttachment,
+ authenticatorSelection.residentKey,
+ authenticatorSelection.userVerification,
+ ),
+ attestation,
+ null
+ )
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt
new file mode 100644
index 00000000..635d4102
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/PassKeyService.kt
@@ -0,0 +1,137 @@
+package io.yubicolabs.pawskey
+
+import android.util.Log
+import com.yubico.yubikit.android.YubiKitManager
+import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
+import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
+import com.yubico.yubikit.android.transport.usb.UsbConfiguration
+import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
+import com.yubico.yubikit.core.YubiKeyDevice
+import com.yubico.yubikit.core.util.Callback
+import com.yubico.yubikit.fido.client.BasicWebAuthnClient
+import com.yubico.yubikit.fido.ctap.Ctap2Session
+import kotlinx.coroutines.delay
+import javax.inject.Inject
+
+
+class PassKeyService @Inject constructor(
+ private val yubico: YubiKitManager,
+ private val activityProvider: ActivityProvider,
+) {
+ interface Listener {
+ operator fun invoke(webAuthnClient: BasicWebAuthnClient)
+ }
+
+ private val usbConfig: UsbConfiguration = UsbConfiguration().handlePermissions(true)
+ private val usbListener: Callback = Callback { device ->
+ onDeviceConnected(device)
+ }
+
+ private val nfcConfig: NfcConfiguration = NfcConfiguration()
+ .disableNfcDiscoverySound(false)
+ .handleUnavailableNfc(true)
+ .timeout(3_000)
+ .skipNdefCheck(false)
+ private val nfcListener: Callback = Callback { device ->
+ onDeviceConnected(device)
+ }
+
+ private val authnClients: MutableList = mutableListOf()
+ private val clientListeners: MutableList = mutableListOf()
+
+ fun startDeviceDiscovery() {
+ yubico.startUsbDiscovery(
+ usbConfig,
+ usbListener
+ )
+
+ // FIXME ACTIVITY INJECTION
+
+ yubico.startNfcDiscovery(
+ nfcConfig,
+ activityProvider.getActivity(),
+ nfcListener
+ )
+ }
+
+ private fun onDeviceConnected(device: YubiKeyDevice) {
+ Log.i(tagForLog, "Connected to device $device.")
+
+ Ctap2Session.create(device) { result ->
+ if (!result.isSuccess) {
+ Log.e(tagForLog, "Couldn't create session for device $device: ${result.error}")
+ } else {
+ val session = result.value
+ val authnClient = BasicWebAuthnClient(session)
+ Log.i(tagForLog, "Created session: $session and authnclient $authnClient. Informing all listeners.")
+
+ authnClients.add(authnClient)
+ informListeners(authnClient)
+ }
+ }
+ }
+
+ /**
+ * Get informed when a new device gets connected and we were able to establish a session for a webauthn client.
+ *
+ * @param listener the callback invoked with every new session created
+ * @param updateWithCurrentState when set to true calls the new listener with the current active clients
+ */
+ fun addWebauthnClientListener(listener: Listener, updateWithCurrentState: Boolean): Boolean {
+ if (updateWithCurrentState) {
+ authnClients.forEach { listener(it) }
+ }
+
+ return clientListeners.add(listener)
+ }
+
+ /**
+ * Remove a listener for webauthn clients once not needed anymore.
+ */
+ fun removeClientListener(listener: Listener) = clientListeners.remove(listener)
+
+ fun forAllWebAuthnClients(block: BasicWebAuthnClient.() -> Unit): Int {
+ authnClients.forEach {
+ it.block()
+ }
+
+ return authnClients.size
+ }
+
+ val connectedKeyCount: Int
+ get() = authnClients.size
+
+ private fun informListeners(webAuthnClient: BasicWebAuthnClient) {
+ clientListeners.forEach { it(webAuthnClient) }
+ }
+
+ data class Key(
+ val id: String,
+ )
+
+ suspend fun findConnectedKeys(): List? {
+ // TODO Replace with sdk / platform impl
+
+ delay(500)
+
+ return listOf(
+ Key("mocked")
+ )
+ }
+
+ fun stopDeviceDiscovery() {
+ yubico.stopUsbDiscovery()
+ yubico.stopNfcDiscovery(activityProvider.getActivity())
+
+ clientListeners.clear()
+ }
+}
+
+
+private val com.yubico.yubikit.core.util.Result.error: Throwable
+ get() = try {
+ this.value
+ IllegalStateException("No error found.")
+ } catch (th: Throwable) {
+ th
+ }
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt
new file mode 100644
index 00000000..99ced127
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/RelyingPartyService.kt
@@ -0,0 +1,132 @@
+package io.yubicolabs.pawskey
+
+import android.util.Log
+import io.yubicolabs.pawskey.AttestationOptionsRequest.AuthenticatorSelection
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import retrofit2.HttpException
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import javax.inject.Inject
+
+@Serializable
+data class Status(
+ @SerialName("status")
+ val status: String
+)
+
+@Serializable
+data class AttestationOptionsRequest(
+ val userName: String,
+ val displayName: String,
+ val authenticatorSelection: AuthenticatorSelection,
+ val attestation: String? = null,
+) {
+ @Serializable
+ data class AuthenticatorSelection(
+ val residentKey: String,
+ val authenticatorAttachment: String? = null,
+ val userVerification: String? = null,
+ )
+}
+
+@Serializable
+data class AttestationOptionsResponse(
+ val requestId: String,
+ val publicKey: PublicKey,
+) {
+ @Serializable
+ data class PublicKey(
+ val rp: RelyingParty,
+ val user: User,
+ val challenge: String,
+ val pubKeyCredParams: List,
+ val timeout: Long,
+ val excludeCredentials: List,
+ val authenticatorSelection: AuthenticatorSelection,
+ val attestation: String
+ ) {
+ @Serializable
+ data class RelyingParty(
+ val name: String,
+ val id: String,
+ )
+
+ @Serializable
+ data class User(
+ val id: String,
+ val name: String,
+ val displayName: String,
+ )
+
+ @Serializable
+ data class CredentialParameter(
+ val type: String,
+ val alg: Int,
+ )
+
+ @Serializable
+ data class ExcludeCredential(
+ val id: String,
+ val type: String,
+ )
+ }
+}
+
+interface RelyingPartyHttpService {
+ @GET("v1/status")
+ suspend fun getStatus(): Response
+
+ @POST("v1/attestation/options")
+ suspend fun getAttestationOptions(
+ @Body options: AttestationOptionsRequest
+ ): Response
+}
+
+class RelyingPartyService @Inject constructor(
+ private val httpService: RelyingPartyHttpService,
+) {
+ /**
+ * Status: Is the server alive and kicking?
+ *
+ * @throws retrofit2.HttpException on error code response (not 2xx http codes)
+ * @throws java.net.SocketTimeoutException on timeout
+ */
+ suspend fun getStatus(): Boolean {
+ val response = httpService.getStatus()
+ val body = response.body()
+
+ if (response.isSuccessful && body != null) {
+ return body.status == "ok"
+ } else {
+ val ex = HttpException(response)
+ Log.e(tagForLog, "Status endpoint returned error. Response was $response.", ex)
+ throw ex
+ }
+ }
+
+ suspend fun getAttestationOptions(
+ userName: String
+ ): AttestationOptionsResponse {
+ val response = httpService.getAttestationOptions(
+ AttestationOptionsRequest(
+ userName = userName,
+ displayName = userName,
+ authenticatorSelection = AuthenticatorSelection(
+ residentKey = "preferred"
+ )
+ )
+ )
+
+ val body = response.body()
+
+ if (response.isSuccessful && body != null) {
+ return body
+ } else {
+ throw HttpException(response)
+ }
+ }
+}
+
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/net/DebugInterceptor.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/net/DebugInterceptor.kt
new file mode 100644
index 00000000..a40260b3
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/net/DebugInterceptor.kt
@@ -0,0 +1,28 @@
+package io.yubicolabs.pawskey.net
+
+import android.util.Log
+import io.yubicolabs.pawskey.tagForLog
+import okhttp3.Interceptor
+import okhttp3.Response
+
+/**
+ * This interceptor logs all incoming traffic.
+ */
+class DebugInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response = try {
+ val request = chain.request().newBuilder().header("accept","*/*").build()
+ Log.d(tagForLog, request.toString())
+ Log.d(tagForLog, "> Headers: ${request.headers().toString().replace("\n", "; ")}")
+
+ val response = chain.proceed(request)
+ val body = response.peekBody(Long.MAX_VALUE)
+ Log.d(tagForLog, response.toString())
+ Log.d(tagForLog, "< Headers: ${response.headers().toString().replace("\n", "; ")}")
+ Log.d(tagForLog, "< Body: ${body.string()}")
+
+ response
+ } catch (th: Throwable) {
+ Log.e(tagForLog, "Interceptor intercepted error", th)
+ throw th
+ }
+}
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Color.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Color.kt
new file mode 100644
index 00000000..a9c1af5d
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Color.kt
@@ -0,0 +1,8 @@
+package io.yubicolabs.pawskey.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val YubiGreen = Color(0xFF8BCC00)
+val YubiBlue = Color(0xff8bccff)
+val YubiGray = Color(0xffabc280)
+val YubiOrange = Color(0xffffcc00)
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Theme.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Theme.kt
new file mode 100644
index 00000000..b9b0910a
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Theme.kt
@@ -0,0 +1,20 @@
+package io.yubicolabs.pawskey.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun PawsKeyTheme(
+ content: @Composable () -> Unit
+) {
+ val context = LocalContext.current
+ val colorScheme = dynamicDarkColorScheme(context)
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Type.kt b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Type.kt
new file mode 100644
index 00000000..7c28fcbe
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/java/io/yubicolabs/pawskey/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package io.yubicolabs.pawskey.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_content_copy_24.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_content_copy_24.xml
new file mode 100644
index 00000000..942aeb96
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_content_copy_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_outline_delete_24.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_outline_delete_24.xml
new file mode 100644
index 00000000..3fb404dc
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/baseline_outline_delete_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_background.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..ca3826a4
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_foreground.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..c4a603d4
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..11216ee9
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..2e4f725b
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..e8b039d8
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..7aab0e36
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..d451fec2
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/values/colors.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f8c6127d
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/values/strings.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..a999c0b0
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/strings.xml
@@ -0,0 +1,18 @@
+
+ PawsKey
+ Connected to %s.
+ Server returned unexpected status.
+ Connecting to server resulted in an error with message \'%s\'.
+ Could not connect to server. Is it running and do you have internet?
+ Received options with id %s. Requesting passkey credentials matching this:\n%s.
+ These keys are found: %s.
+ User Name
+ Error
+ 📋
+ Not a valid response from relying party received.
+ The user name you provided is wrong.
+ Enter Pin
+ A pin is needed
+ Could not create credential.
+ Pin
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/values/themes.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..ebf2fa5d
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/xml/backup_rules.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..fa0f996d
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/main/res/xml/data_extraction_rules.xml b/examples/clients/mobile/android/PawsKey/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/app/src/test/java/io/yubicolabs/pawskey/RelyingPartyServiceTest.kt b/examples/clients/mobile/android/PawsKey/app/src/test/java/io/yubicolabs/pawskey/RelyingPartyServiceTest.kt
new file mode 100644
index 00000000..c7cc8889
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/app/src/test/java/io/yubicolabs/pawskey/RelyingPartyServiceTest.kt
@@ -0,0 +1,113 @@
+package io.yubicolabs.pawskey
+
+import junit.framework.TestCase.assertEquals
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import okhttp3.ResponseBody
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import retrofit2.HttpException
+import retrofit2.Response
+import java.net.SocketTimeoutException
+
+
+class RelyingPartyServiceTest {
+ @Test
+ fun getStatus() = runTest {
+ val httpService = object : RelyingPartyHttpService {
+ override suspend fun getStatus(): Response = Response.success(Status("ok"))
+ override suspend fun getAttestationOptions(options: AttestationOptionsRequest): Response =
+ Response.success(null)
+ }
+
+ val rpService = RelyingPartyService(httpService)
+ assertEquals(true, rpService.getStatus())
+ }
+
+ @Test
+ fun getStatusIfErrorOnServer() = runTest {
+ val httpService = object : RelyingPartyHttpService {
+ override suspend fun getStatus(): Response = throw HttpException(
+ Response.error(
+ 400,
+ ResponseBody.create(null, "TEST SUPPOSED TO FAIL")
+ )
+ )
+
+ override suspend fun getAttestationOptions(options: AttestationOptionsRequest): Response =
+ Response.success(null)
+ }
+
+ val rpService = RelyingPartyService(httpService)
+ assertThrows(HttpException::class.java) {
+ runBlocking {
+ rpService.getStatus()
+ }
+ }
+ }
+
+ @Test
+ fun getTimeoutError() = runTest {
+ val httpService = object : RelyingPartyHttpService {
+ override suspend fun getStatus(): Response = throw SocketTimeoutException()
+ override suspend fun getAttestationOptions(options: AttestationOptionsRequest): Response =
+ Response.success(null)
+ }
+
+ // TODO: Make meaningful
+
+ val rpService = RelyingPartyService(httpService)
+ assertThrows(SocketTimeoutException::class.java) {
+ runBlocking {
+ rpService.getStatus()
+ }
+ }
+ }
+
+ @Test
+ fun getAttestationOptions() = runTest {
+ val httpService = object : RelyingPartyHttpService {
+ override suspend fun getStatus(): Response = Response.success(Status("ok"))
+ override suspend fun getAttestationOptions(options: AttestationOptionsRequest): Response =
+ Response.success(
+ AttestationOptionsResponse(
+ "RID",
+ AttestationOptionsResponse.PublicKey(
+ AttestationOptionsResponse.PublicKey.RelyingParty(
+ "relying party name",
+ "relying party id"
+ ),
+ AttestationOptionsResponse.PublicKey.User(
+ "userId",
+ "userName",
+ "User Name Display",
+ ),
+ "Challenge String",
+ listOf(
+ AttestationOptionsResponse.PublicKey.CredentialParameter(
+ "type",
+ 2134,
+ )
+ ),
+ 1234145,
+ listOf(),
+ AttestationOptionsRequest.AuthenticatorSelection(
+ "residentKey",
+ "authAttach",
+ "userverify",
+ ),
+ "attestation",
+ )
+ )
+ )
+ }
+
+ val rpService = RelyingPartyService(httpService)
+ assertEquals(
+ "attestation",
+ rpService.getAttestationOptions(
+ "userName"
+ ).publicKey.attestation
+ )
+ }
+}
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/build.gradle.kts b/examples/clients/mobile/android/PawsKey/build.gradle.kts
new file mode 100644
index 00000000..0df9299f
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/build.gradle.kts
@@ -0,0 +1,9 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.dagger.hilt) apply false
+ alias(libs.plugins.jetbrains.compose.compiler) apply false
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.kotlinx.serialization) apply false
+ alias(libs.plugins.ksp) apply false
+}
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/gradle.properties b/examples/clients/mobile/android/PawsKey/gradle.properties
new file mode 100644
index 00000000..d7bb5224
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/gradle.properties
@@ -0,0 +1,26 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+
+API_BASE_URI=replace-with-your-api-base-url
+RELYING_PARTY_ID=replace-with-your-relying-party-id
\ No newline at end of file
diff --git a/examples/clients/mobile/android/PawsKey/gradle/libs.versions.toml b/examples/clients/mobile/android/PawsKey/gradle/libs.versions.toml
new file mode 100644
index 00000000..48097d02
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/gradle/libs.versions.toml
@@ -0,0 +1,52 @@
+[versions]
+yubikit = "2.7.0"
+activityCompose = "1.9.3"
+agp = "8.7.3"
+androidx-compose-compiler = "1.5.14" # used in app/build.gradle -> composeOptions
+composeBom = "2024.12.01"
+coreKtx = "1.15.0"
+coroutinesTest = "1.9.0-RC.2"
+dagger-hilt = "2.52"
+espressoCore = "3.6.1"
+junit = "4.13.2"
+junitVersion = "1.2.1"
+kotlin = "2.0.10"
+kotlinx-serialization = "1.7.2"
+kotlinx-serialization-plugin = "2.0.20"
+ksp = "2.0.20-1.0.24"
+lifecycleRuntimeKtx = "2.8.7"
+retrofit = "2.11.0"
+log4j = "3.0.0"
+
+[libraries]
+yubico-yubikit = { group = "com.yubico.yubikit", name = "android", version.ref = "yubikit" }
+yubico-yubikit-fido = { group = "com.yubico.yubikit", name = "fido", version.ref = "yubikit" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+coroutines_test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
+dagger-hilt = { module = "com.google.dagger:hilt-android", version.ref = "dagger-hilt" }
+dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger-hilt" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
+log4j = { group = "com.github.tony19", name = "logback-android", version.ref = "log4j" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" }
+jetbrains-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinx-serialization-plugin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/examples/clients/mobile/android/PawsKey/gradle/wrapper/gradle-wrapper.jar b/examples/clients/mobile/android/PawsKey/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e708b1c0
Binary files /dev/null and b/examples/clients/mobile/android/PawsKey/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/examples/clients/mobile/android/PawsKey/gradle/wrapper/gradle-wrapper.properties b/examples/clients/mobile/android/PawsKey/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..8d730b18
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Aug 27 14:25:41 CEST 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/examples/clients/mobile/android/PawsKey/gradlew b/examples/clients/mobile/android/PawsKey/gradlew
new file mode 100755
index 00000000..4f906e0c
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/examples/clients/mobile/android/PawsKey/gradlew.bat b/examples/clients/mobile/android/PawsKey/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@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
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@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="-Xmx64m" "-Xms64m"
+
+@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 execute
+
+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 execute
+
+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
+
+: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 %*
+
+: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/examples/clients/mobile/android/PawsKey/settings.gradle.kts b/examples/clients/mobile/android/PawsKey/settings.gradle.kts
new file mode 100644
index 00000000..8a80a5e6
--- /dev/null
+++ b/examples/clients/mobile/android/PawsKey/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "PawsKey"
+include(":app")
diff --git a/examples/clients/mobile/android/base_icon.png b/examples/clients/mobile/android/base_icon.png
new file mode 100644
index 00000000..6f728473
Binary files /dev/null and b/examples/clients/mobile/android/base_icon.png differ
diff --git a/examples/clients/mobile/iOS/PKBank/Constants.xcconfig b/examples/clients/mobile/iOS/PKBank/Constants.xcconfig
index 84b5c0be..26366056 100644
--- a/examples/clients/mobile/iOS/PKBank/Constants.xcconfig
+++ b/examples/clients/mobile/iOS/PKBank/Constants.xcconfig
@@ -5,6 +5,6 @@
// Created by Dennis Hills on 12/1/23.
//
-BANK_AUTH_DOMAIN = fun-lake-gjm1dq4-8081.usw2.devtunnels.ms
-BANK_API_DOMAIN = fun-lake-gjm1dq4-8082.usw2.devtunnels.ms
+BANK_AUTH_DOMAIN = amusing-chair-1j2278p-8081.euw.devtunnels.ms
+BANK_API_DOMAIN = amusing-chair-1j2278p-8082.euw.devtunnels.ms
diff --git a/examples/high_assurance/README.md b/examples/high_assurance/README.md
index bb342f94..d432ee80 100644
--- a/examples/high_assurance/README.md
+++ b/examples/high_assurance/README.md
@@ -4,7 +4,7 @@ To test, you need Keycloak and a banking client.
## KeyCloak
-You can start a fresh KeyCloak instance without any specivif configuration:
+You can start a fresh KeyCloak instance without any specific configuration:
docker run -p 8081:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:21.1.2 start-dev
diff --git a/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1Api.java b/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1Api.java
index 0a4c7059..cfb1cbb0 100644
--- a/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1Api.java
+++ b/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1Api.java
@@ -64,7 +64,7 @@ default ResponseEntity aPIStatus(
}
}
});
- return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
+ return new ResponseEntity<>(HttpStatus.OK);
}
diff --git a/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1ApiController.java b/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1ApiController.java
index 3a96f489..965f7d1d 100644
--- a/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1ApiController.java
+++ b/examples/high_assurance/bank_app/src/main/java/com/yubicolabs/bank_app/api/V1ApiController.java
@@ -1,5 +1,6 @@
package com.yubicolabs.bank_app.api;
+import com.yubicolabs.bank_app.models.api.APIStatus;
import com.yubicolabs.bank_app.models.api.CreateAccountRequest;
import com.yubicolabs.bank_app.models.api.Error;
import com.yubicolabs.bank_app.models.api.TransactionCreateRequest;
@@ -118,4 +119,12 @@ private int getAcr() {
return Integer.parseInt((String) jwt.getClaims().get("acr"));
}
+ @Override
+ public ResponseEntity aPIStatus() {
+ final APIStatus status = new APIStatus();
+ status.setStatus("ok");
+
+ return ResponseEntity.status(HttpStatus.OK).body(status);
+ }
+
}
diff --git a/examples/high_assurance/bank_app/src/main/resources/openapi.yaml b/examples/high_assurance/bank_app/src/main/resources/openapi.yaml
index b291e238..fcc7a612 100644
--- a/examples/high_assurance/bank_app/src/main/resources/openapi.yaml
+++ b/examples/high_assurance/bank_app/src/main/resources/openapi.yaml
@@ -152,7 +152,7 @@ paths:
summary: Create a new transaction
x-content-type: application/json
x-accepts: application/json
- components:
+components:
schemas:
APIStatus:
example: