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.
Distributed via GitHub
The Android SDK is not published to Maven Central yet. Install it from GitHub using JitPack, or clone the repository as a local module.
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"))
}
Not on Maven Central
The Android SDK is distributed as a GitHub repository only. The previously documented com.akedly:shield Maven coordinate is not published -- use JitPack or a local module instead.
Quick Start
Never ship your API key to an Android app
APKs and AABs can be unpacked — anything compiled in is public. Keep APIKey and pipelineID on your backend and expose a thin proxy (/auth/akedly/challenge, /auth/akedly/send, /auth/akedly/verify) for Android to call. The Backend tab below shows the minimal Node.js server; the Kotlin tab shows the matching client.
Optional: per-end-user-IP rate limiting
Per-IP rate limiting is opt-in. If you want it, forward the end user's IP via the x-end-user-ip header — the IP that hit your backend from the Android device, not your server's IP. The Express example below uses req.ip, which only returns the real client IP after app.set('trust proxy', 1) is configured for your reverse proxy (Cloudflare, AWS ALB, Nginx, Fly.io). Do not read the device's public IP from within the Android app — clients cannot reliably know their own public IP and can lie. Skip this entirely if you don't need per-IP limiting. See the V1.2 API reference for Next.js, Flask, and PHP extraction patterns.
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
// 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.
Java Interop
solvePowSync is designed for Java code that cannot use coroutines. Always call on a background thread.
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
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
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
<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
trueonly on a completed, successful ceremony.
- Name
resultToken- Type
- String?
- Description
The signed proof — present (non-null) whenever
verifiedistrue. Forward it to your backend and verify it offline (below).
- Name
reason- Type
- String?
- Description
nullwhen verified; otherwise"no_proof"(claimed or recovered success without aresultToken) |"failed"— your cue to reconcile the transaction before falling back to OTP.
Abandonment produces NO redirect — there is no "closed" on Android
The launch is a fire-and-forget browser intent: if the user abandons the ceremony (backs out
of the browser, closes the tab, cancels the passkey sheet and leaves), no redirect fires and
no AkedlyPasskeyResult is ever produced. The /pk page has no cancel redirect of its own.
Detect abandonment from your own lifecycle — you resumed the app without your redirect
Activity having received a deep link — and treat it as a cancel / OTP fallback.
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
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
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()
}
}
