Akedly

Android / Kotlin Shield SDK

The Akedly Shield SDK for Android provides coroutine-based Proof-of-Work solving and Turnstile token retrieval for Akedly V1.2. Includes a sync variant for Java interop.

Installation

Option 1: JitPack (recommended)

Add JitPack to your settings.gradle.kts (or settings.gradle):

dependencyResolutionManagement {
    repositories {
        mavenCentral()
        google()
        maven { url = uri("https://jitpack.io") }
    }
}

Then add the dependency to your app's build.gradle.kts:

dependencies {
    implementation("com.github.Akedly-Org:akedly-shield-kotlin:1.1.0")
}

Replace 1.1.0 with main-SNAPSHOT to track the latest commit, or use a specific tag or commit hash for a pinned build.

Option 2: Local module

Clone the repository alongside your project and include it as a local Gradle module:

git clone https://github.com/Akedly-Org/akedly-shield-kotlin.git

In settings.gradle.kts:

include(":akedly-shield")
project(":akedly-shield").projectDir = file("../akedly-shield-kotlin")

Then in your app's build.gradle.kts:

dependencies {
    implementation(project(":akedly-shield"))
}

Quick Start

Use solvePow within a coroutine scope. It runs on Dispatchers.Default and yields every 10,000 iterations. For Turnstile, use AkedlyTurnstile which creates an invisible WebView. PoW and Turnstile run on-device; only non-sensitive proofs travel to your backend.

Quick Start

KOTLIN
// Express proxy — deploy this on your server, not inside the Android app.
import express from 'express';

const app = express();
app.use(express.json());
// Optional — only needed if you want per-end-user-IP rate limiting.
// Drop this line and the x-end-user-ip header below if you don't.
// Makes req.ip the real client IP behind a reverse proxy (1 = trust first hop).
app.set('trust proxy', 1);

app.get('/auth/akedly/challenge', async (_req, res) => {
  const r = await fetch(
    `https://api.akedly.io/api/v1.2/transactions/challenge` +
    `?APIKey=${process.env.AKEDLY_API_KEY}` +
    `&pipelineID=${process.env.AKEDLY_PIPELINE_ID}`
  );
  res.status(r.status).json(await r.json());
});

app.post('/auth/akedly/send', async (req, res) => {
  const { phoneNumber, powSolution, turnstileToken } = req.body;
  const r = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-end-user-ip': req.ip,
    },
    body: JSON.stringify({
      APIKey: process.env.AKEDLY_API_KEY,
      pipelineID: process.env.AKEDLY_PIPELINE_ID,
      verificationAddress: { phoneNumber },
      powSolution,
      turnstileToken,
    }),
  });
  res.status(r.status).json(await r.json());
});

app.post('/auth/akedly/verify', async (req, res) => {
  const { transactionReqID, otp } = req.body;
  const r = await fetch('https://api.akedly.io/api/v1.2/transactions/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ transactionReqID, otp }),
  });
  res.status(r.status).json(await r.json());
});

API Methods

suspend fun solvePow(challenge: String, difficulty: Int): Int

Coroutine-based PoW solver. Yields every 10,000 iterations to prevent blocking. Use within any coroutine scope.

  • Name
    challenge
    Type
    String
    Description

    64-character hex string from the server.

  • Name
    difficulty
    Type
    Int
    Description

    Number of leading hex zeros required.

Returns Int (the nonce).

fun solvePowSync(challenge: String, difficulty: Int): Int

Synchronous blocking solver for Java interop or background thread execution.

  • Name
    challenge
    Type
    String
    Description

    64-character hex string from the server.

  • Name
    difficulty
    Type
    Int
    Description

    Number of leading hex zeros required.

Returns Int (the nonce). Blocks until found.

AkedlyTurnstile(context, bridgeDomain?)

Creates an invisible WebView to retrieve Cloudflare Turnstile tokens.

  • Name
    context
    Type
    Context
    Description

    Android application or activity context.

  • Name
    bridgeDomain
    Type
    String?
    Description

    Bridge page domain. Defaults to turnstile.akedly.io.

  • Name
    getToken(siteKey: String)
    Type
    suspend method
    Description

    Loads the Turnstile bridge page and returns the token. Must be called from the Main dispatcher.


Passkeys

AkedlyPasskey is a stateless launcher — it never calls /challenge, /send, or /verify; your backend mints the token, you launch the ceremony, and the result returns to your app. The hosted V1.2 passkey ceremony runs at auth.akedly.io/pk in the system browser / a Custom Tab on the akedly.io origin — so platform passkeys via Credential Manager (fingerprint / face / device PIN) work — and returns to your app over a deep-link custom scheme. No WebView, no Digital Asset Links.

Full endpoint contract: V1.2 Passkeys.

Enroll after a verify

A successful OTP /verify returns an additive enrollmentToken. Pass it to AkedlyPasskey.launch to offer enrollment right after sign-in — enrollment is proven on the next successful sign-in.

Enroll a passkey

KOTLIN
import com.akedly.shield.AkedlyPasskey

// verify.data.enrollmentToken came back from your /verify proxy. For the enrollment RESULT to
// carry a resultToken, that /verify call must also have passed
//   "returnTarget": { "url": "myapp://akedly-passkey" }
// Without it the passkey is still created, but the redirect is token-stripped and the SDK
// reports verified:false / no_proof — treat the enroll result as advisory in that case.
val enrollmentToken = verify.getJSONObject("data").optString("enrollmentToken")
if (enrollmentToken.isNotEmpty()) {
    // Launch the hosted ceremony; the result returns to your redirect Activity (below).
    AkedlyPasskey.launch(context, enrollmentToken, callbackScheme = "myapp")
}

Authenticate a returning user

Your backend clears the gate and starts the ceremony with POST /transactions/passkey/auth-options, which returns a ceremonyToken and requestID. A 404 with code: "NO_PASSKEY" is the availability check — fall straight through to OTP. A 403 with code: "PASSKEY_DISABLED" takes the same OTP fallback, but means passkeys are off for this account or this pipeline (the response does not say which) — see V1.2 Passkeys. That call runs the same PoW/Turnstile Shield chain as /send. New pipelines default PoW to enabled and Turnstile to disabled: pass powSolution when PoW is required and turnstileToken when Turnstile is enabled, and omit each control when it is not required or its explicit dev-mode bypass is enabled. Launch the ceremony with that token:

Authenticate with a passkey

KOTLIN
import com.akedly.shield.AkedlyPasskey

// Your backend → POST /transactions/passkey/auth-options
//   { APIKey, pipelineID, verificationAddress: { phoneNumber }, powSolution?, turnstileToken?,
//     returnTarget: { url: "myapp://akedly-passkey" } }   // <-- REQUIRED for the resultToken
//   → { data: { ceremonyToken, requestID } }
val auth = myBackend.startPasskeyAuth(phone) ?: return fallbackToOtp() // 404 NO_PASSKEY
myAuthStore.expectedPasskeyRequestID = auth.requestID

AkedlyPasskey.launch(context, auth.ceremonyToken, callbackScheme = "myapp")
// for QA against a local ceremony origin:
// AkedlyPasskey.launch(context, auth.ceremonyToken, "myapp", ceremonyOrigin = "https://localhost:5174")

AkedlyPasskey.launch throws an AkedlyPasskeyException when no browser (or other ACTION_VIEW handler) can open the ceremony URL — catch it and fall back to OTP.

Receive the result

Register a tiny redirect Activity for your scheme in the manifest, then parse the incoming deep link with AkedlyPasskey.parseResult(intent.data) — it returns an AkedlyPasskeyResult.

Receive the deep link

ANDROID
<activity android:name=".PasskeyRedirectActivity" android:exported="true" android:launchMode="singleTask">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" android:host="akedly-passkey" />
  </intent-filter>
</activity>

AkedlyPasskeyResult exposes:

  • Name
    verified
    Type
    Boolean
    Description

    true only on a completed, successful ceremony.

  • Name
    resultToken
    Type
    String?
    Description

    The signed proof — present (non-null) whenever verified is true. Forward it to your backend and verify it offline (below).

  • Name
    reason
    Type
    String?
    Description

    null when verified; otherwise "no_proof" (claimed or recovered success without a resultToken) | "failed" — your cue to reconcile the transaction before falling back to OTP.

Fall back to OTP

Every deep link that arrives with a non-verified outcome carries a reason. For authentication "no_proof", reconcile /result or the correlated backend callback before starting OTP because verification may already be settled. Treat "failed" and the no-redirect abandonment case as OTP fallbacks.

val data = intent?.data ?: run { finish(); return } // null when launched without a deep link
val result = AkedlyPasskey.parseResult(data)
if (!result.verified) {
    if (result.reason == "no_proof") {
        reconcilePasskeyResult(result.transactionId)
    } else {
        fallbackToOtp()
    }
}

Verify the result

A verified ceremony returns a resultToken; forward it to your backend and verify it there before creating a session. Never embed your API key in the Android app. Use the hub verifier for token format, HMAC validation, callback behavior, and replay rules.

Without the SDK

AkedlyPasskey is a thin wrapper. The ceremony is just a URL you open in a Custom Tab / browser; the result comes back on your deep-link scheme — parse the query yourself.

https://auth.akedly.io/pk?token=&returnUrl=myapp://akedly-passkey
   -> redirects to: myapp://akedly-passkey?verified=true&transactionId=…
      (resultToken ONLY if your backend signed returnTarget into the ceremony token)

AkedlyPasskey.buildUrl(...) and AkedlyPasskey.parseResult(uri) / parseResultFromQuery(query) are public if you want them without the launcher.

The returnUrl query param selects where the ceremony redirects; it does not authorize the proof. A query-supplied target is always untrusted, so that redirect carries no resultToken and the SDK reports verified: false / no_proof even on a fully successful ceremony. To receive the proof your backend must sign a returnTarget into the ceremony token via /auth-options (or /verify when enrolling) — then verify the returned resultToken offline as above.


Jetpack Compose Example

Compose

KOTLIN
OTPScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.akedly.shield.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.URL

// Calls YOUR backend proxy; see Quick Start for the Node.js server.
const val BACKEND_URL = "https://yourapp.com"

@Composable
fun OTPScreen(
    phoneNumber: String,
    // Supply the same 4, 5, or 6 value configured by your backend.
    otpLength: Int,
    onSuccess: () -> Unit
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    var transactionReqID by remember { mutableStateOf<String?>(null) }
    var otp by remember { mutableStateOf("") }
    var loading by remember { mutableStateOf(false) }
    var error by remember { mutableStateOf<String?>(null) }

    Column(
        modifier = Modifier.fillMaxWidth().padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        if (transactionReqID == null) {
            Text("Send OTP to $phoneNumber")
            Button(
                onClick = {
                    scope.launch {
                        loading = true
                        error = null
                        try {
                            val json = withContext(Dispatchers.IO) {
                                URL("$BACKEND_URL/auth/akedly/challenge").readText()
                            }
                            val data = JSONObject(json).getJSONObject("data")

                            var powSolution: JSONObject? = null
                            if (data.optBoolean("challengeRequired", false)) {
                                val nonce = solvePow(
                                    data.getString("challenge"),
                                    data.getInt("difficulty")
                                )
                                powSolution = JSONObject().apply {
                                    put("challengeToken", data.getString("challengeToken"))
                                    put("nonce", nonce)
                                }
                            }

                            var turnstileToken: String? = null
                            val ts = data.optJSONObject("turnstile")
                            if (ts?.optBoolean("required") == true) {
                                turnstileToken = withContext(Dispatchers.Main) {
                                    AkedlyTurnstile(context).getToken(ts.getString("siteKey"))
                                }
                            }

                            val body = JSONObject().apply {
                                put("phoneNumber", phoneNumber)
                                powSolution?.let { put("powSolution", it) }
                                turnstileToken?.let { put("turnstileToken", it) }
                            }

                            // POST body to "$BACKEND_URL/auth/akedly/send"
                            // transactionReqID = result.data.transactionReqID
                        } catch (e: Exception) {
                            error = e.message
                        }
                        loading = false
                    }
                },
                enabled = !loading
            ) {
                Text(if (loading) "Sending..." else "Send OTP")
            }
        } else {
            OutlinedTextField(
                value = otp,
                onValueChange = { if (it.length <= otpLength) otp = it },
                label = { Text("Enter OTP") }
            )
            Button(
                onClick = {
                    scope.launch {
                        loading = true
                        error = null
                        // POST to "$BACKEND_URL/auth/akedly/verify" with { transactionReqID, otp }
                        // On success: onSuccess()
                        loading = false
                    }
                },
                enabled = !loading && otp.length >= otpLength
            ) {
                Text(if (loading) "Verifying..." else "Verify")
            }
        }

        error?.let {
            Text(it, color = MaterialTheme.colorScheme.error)
        }
    }
}

XML / Activity Example

Activity

KOTLIN
OTPActivity.kt
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.akedly.shield.*
import kotlinx.coroutines.*

// Calls YOUR backend proxy; see Quick Start for the Node.js server.
const val BACKEND_URL = "https://yourapp.com"

class OTPActivity : AppCompatActivity() {
    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    private var transactionReqID: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // setContentView(R.layout.activity_otp)

        // sendButton.setOnClickListener { sendOTP() }
        // verifyButton.setOnClickListener { verifyOTP() }
    }

    private fun sendOTP() {
        scope.launch {
            val json = withContext(Dispatchers.IO) {
                java.net.URL("$BACKEND_URL/auth/akedly/challenge").readText()
            }
            val data = org.json.JSONObject(json).getJSONObject("data")

            var powSolution: JSONObject? = null
            if (data.optBoolean("challengeRequired", false)) {
                val nonce = solvePow(
                    data.getString("challenge"),
                    data.getInt("difficulty")
                )
                powSolution = JSONObject().apply {
                    put("challengeToken", data.getString("challengeToken"))
                    put("nonce", nonce)
                }
            }

            var turnstileToken: String? = null
            val ts = data.optJSONObject("turnstile")
            if (ts?.optBoolean("required") == true) {
                turnstileToken = AkedlyTurnstile(this@OTPActivity)
                    .getToken(ts.getString("siteKey"))
            }

            // POST to "$BACKEND_URL/auth/akedly/send" with { phoneNumber, powSolution?, turnstileToken? }
            // Save transactionReqID from response: result.data.transactionReqID
        }
    }

    private fun verifyOTP() {
        val otp = "" // Get from EditText
        scope.launch {
            // POST to "$BACKEND_URL/auth/akedly/verify" with { transactionReqID, otp }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }
}

Was this page helpful?