Skip to main content

Usage

The flow presents a camera screen with a text the user reads aloud. The on-device recognizer verifies the reading live while a single continuous video of the session is recorded. On success the video is secured internally and onSuccess() fires; you then call SpeechVerifier.upload(...) to send the evidence to the Amani backend.

Quick Start

import ai.amani.speechverifier.SpeechVerifier
import ai.amani.speechverifier.observable.SpeechVerifierObserver
import ai.amani.speechverifier.observable.OnFailureSpeechVerifier

val fragment = SpeechVerifier.Builder()
.documentType("XXX_ST_0")
.speechText("Onaylıyorum")
.observe(object : SpeechVerifierObserver {
override fun onPreparing() { /* show a loader while the profile is fetched */ }
override fun onReady() { /* hide the loader; the screen is live */ }
override fun onSuccess() { /* all steps passed; recording secured */ }
override fun onFailure(reason: OnFailureSpeechVerifier, currentAttempt: Int) { }
override fun onError(error: Error) { }
})
.build()

// Show the returned Fragment in your container, e.g.:
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.commit()

speechText(...) or verificationSteps(...), and observe(...), are mandatory. build() throws an Exception if a required option is missing or invalid.

Verification Steps

For anything beyond a single passphrase, use verificationSteps(...) — an ordered list mixing spoken passphrases and knowledge-based identity questions. The list order is the order the user experiences, and a step repeated N times runs N times (each run re-randomises its own pick). All steps share one continuous camera/video session; the whole flow succeeds only after the last step.

import ai.amani.speechverifier.model.VerificationStep
import ai.amani.speechverifier.model.IdentityQuestionType

SpeechVerifier.Builder()
.documentType("XXX_ST_0")
.session(serverURL = "https://<your-server>", token = profileScopedJwt)
.verificationSteps(
listOf(
// One phrase is picked at random from the pool per attempt.
VerificationStep.SpokenText.of("Onaylıyorum", "Onay veriyorum"),
// A knowledge-based question drawn from the customer's profile.
VerificationStep.IdentityQuestion.of(
IdentityQuestionType.ID_NUMBER,
IdentityQuestionType.MOTHER_NAME
),
)
)
.observe(myObserver)
.build()

Identity Question Types

TypeMeaning
ID_NUMBERThe whole national ID number, read digit by digit
MOTHER_NAMEMother's given name
FATHER_NAMEFather's given name
DOCUMENT_NUMBERThe document/serial number, read digit by digit

Providing Identity Answers

IdentityQuestion steps need a profile source. Choose one:

  • session(serverURL, token) — the answers are fetched from the customer's backend profile (GET /api/v2/profile/{id}/documents). Requires a profile-scoped JWT (carrying the profile_id claim).
  • identityAnswers(...) — supply the answers directly up-front; no network round-trip is made for the profile (and onPreparing() is not fired). Pass only the ones your questions use; at least one non-blank value is required.
SpeechVerifier.Builder()
.verificationSteps(
listOf(
VerificationStep.SpokenText.of("Onaylıyorum"),
VerificationStep.IdentityQuestion.of(
IdentityQuestionType.ID_NUMBER,
IdentityQuestionType.MOTHER_NAME
),
)
)
// Answers provided up-front → no request for customer detail is made.
.identityAnswers(idNumber = "12345678901", motherName = "AYŞE")
.observe(myObserver)
.build()

session(...) is still required to call SpeechVerifier.upload(...), even when identityAnswers(...) is used.

Builder Options

All options are optional unless noted; only speechText/verificationSteps and observe are required.

MethodDescriptionDefault
documentType(String)Document type used for the upload.XXX_ST_0
session(serverURL, token)Backend session for upload and identity-question fetch. Token must be a profile-scoped JWT. Held in memory only, never logged.
speechText(String)Single passphrase to read (simple API; becomes one SpokenText step).
verificationSteps(List)Ordered mix of SpokenText / IdentityQuestion steps (overrides speechText).
identityAnswers(idNumber, motherName, fatherName, documentNumber)Supply identity answers directly instead of fetching them.
verificationExemptWords(List)Words shown on screen but excluded from verification (brand/foreign terms).empty
autoDetectForeignWords(Boolean)Auto-exclude words that don't follow Turkish orthography (q/w/x, acronyms…).true
detectTurkishNegation(Boolean)Fail the step if the user adds a Turkish negation (e.g. onaylamıyorum).true
rejectWords(List)Words/phrases that fail the step immediately if spoken (e.g. hayır, iptal).empty
ignoreTurkishDiacritics(Boolean)Fold Turkish letters to ASCII (ş→s, ç→c, ğ→g, ı/İ→i, ö→o, ü→u) before matching.false
timeoutMillis(Long)Time window per step before it fails and the retry screen is shown.60000
continuationPrompts(ContinuationPrompts)Override the on-screen prompts of the identity questions (defaults are Turkish).Turkish
bottomSheetCornerRadius(Float)Top corner radius (dp) of the bottom sheet behind the texts.0f
userInterfaceColors(...)Override screen colours using @ColorRes resource ids.module defaults
userInterfaceColorInts(...)Override screen colours using raw @ColorInt ARGB/hex values.module defaults
userInterfaceTexts(...)Override on-screen status texts (instruction, listening, verifying, verified, failed, recognizerNotAvailable, retry).Turkish
onUiStateChanged((SpeechVerifierUiState) -> Unit)Listener for high-level UI states (see below).
observe(SpeechVerifierObserver)Required. Flow result callbacks.

Observing the Flow

object : SpeechVerifierObserver {
override fun onPreparing() {} // profile fetch started (loader)
override fun onReady() {} // screen live; hide the loader
override fun onSuccess() {} // all steps passed; video secured
override fun onFailure(reason: OnFailureSpeechVerifier, currentAttempt: Int) {}
override fun onError(error: Error) {} // non-recoverable error
}

All callbacks fire on the main thread. onPreparing / onReady are only relevant when identity questions require a profile fetch.

Failure Reasons (OnFailureSpeechVerifier)

ReasonCodeMeaning
SPEECH_NOT_MATCHED9000The completed utterance did not match the displayed text.
SPEECH_NOT_DETECTED9001No usable speech was detected before the recognizer timed out.
VERIFICATION_TIMEOUT9002The time window elapsed without a successful match.
SPEECH_NEGATION_DETECTED9003A negation / reject word was spoken (semantic inversion).

UI State (SpeechVerifierUiState)

Delivered to onUiStateChanged { ... } (main thread):

StateMeaning
CAPTURE_STARTEDCamera opened and video recording began.
LISTENING_STARTEDThe recognizer started listening to the microphone.
SPEECH_VERIFIEDThe spoken text matched; recording is being finalised.

Uploading the Evidence

After onSuccess(), upload the recorded video. The recording is tracked internally — you do not pass any path.

import ai.amani.speechverifier.observable.SpeechVerifierUploadObserver
import ai.amani.speechverifier.model.SpeechVerifierUploadResult
import ai.amani.speechverifier.model.SpeechVerifierUploadError

SpeechVerifier.upload(
context = this,
observer = object : SpeechVerifierUploadObserver {
override fun onResult(result: SpeechVerifierUploadResult) {
// result.stepStatus e.g. "APPROVED", "PENDING_REVIEW", "REJECTED"
// result.documentId (nullable)
}
override fun onError(error: SpeechVerifierUploadError, message: String) { }
}
// timeoutMs = 60_000 by default — how long to wait for the backend's evaluation
)

upload(...) requires session(serverURL, token) to have been configured; the token must be a profile-scoped JWT. Both callbacks fire on the main thread.

Upload Errors (SpeechVerifierUploadError)

ErrorCodeMeaning
SESSION_MISSING9100session(...) was never provided, or no secured recording exists.
UPLOAD_FAILED9101The document POST failed (network error or non-2xx response).
SSE_AUTH_FAILED9102The event stream rejected the credentials (JWT invalid/expired or missing profile scope).
RESULT_TIMEOUT9103Upload succeeded but no step status arrived within the timeout.
PARSE_ERROR9104The backend answered with an uninterpretable payload.

Complete Example

A full, copy-paste-ready host Activity that requests the runtime permissions, shows the Speech Verifier fragment with a multi-step flow and custom options, observes every callback, and uploads the evidence on success.

import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat

import ai.amani.speechverifier.SpeechVerifier
import ai.amani.speechverifier.model.ContinuationPrompts
import ai.amani.speechverifier.model.IdentityQuestionType
import ai.amani.speechverifier.model.SpeechVerifierUiState
import ai.amani.speechverifier.model.SpeechVerifierUploadError
import ai.amani.speechverifier.model.SpeechVerifierUploadResult
import ai.amani.speechverifier.model.VerificationStep
import ai.amani.speechverifier.observable.OnFailureSpeechVerifier
import ai.amani.speechverifier.observable.SpeechVerifierObserver
import ai.amani.speechverifier.observable.SpeechVerifierUploadObserver

class SpeechVerifierActivity : AppCompatActivity() {

private companion object {
const val TAG = "SpeechVerifier"
// Use a PROFILE-scoped JWT (must carry the profile_id claim).
const val SERVER_URL = "https://<your-server>"
const val PROFILE_TOKEN = "<profile-scoped-jwt>"
}

// Runtime permission launcher for CAMERA + RECORD_AUDIO.
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
if (result.values.all { it }) startSpeechVerifier()
else Toast.makeText(this, "Camera & microphone are required", Toast.LENGTH_LONG).show()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_speech_verifier) // must contain @+id/container

val needed = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
val allGranted = needed.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
if (allGranted) startSpeechVerifier() else permissionLauncher.launch(needed)
}

private fun startSpeechVerifier() {
val fragment = SpeechVerifier.Builder()
.documentType("XXX_ST_0")
// Required for upload AND for IdentityQuestion steps (profile fetch).
.session(serverURL = SERVER_URL, token = PROFILE_TOKEN)
.verificationSteps(
listOf(
VerificationStep.SpokenText.of("Onaylıyorum", "Onay veriyorum"),
VerificationStep.IdentityQuestion.of(
IdentityQuestionType.ID_NUMBER,
IdentityQuestionType.MOTHER_NAME
),
)
)
.matchThresholdPercent(80) // 100 (default) = exact match
.autoDetectForeignWords(true)
.detectTurkishNegation(true)
.rejectWords(listOf("hayır", "iptal", "reddediyorum"))
.ignoreTurkishDiacritics(false)
.timeoutMillis(60_000)
.bottomSheetCornerRadius(28f)
.continuationPrompts(
ContinuationPrompts(
instruction = "Lütfen aşağıdaki soruyu sesli yanıtlayın",
idNumber = "Kimlik numaranızın tüm hanelerini tek tek söyleyin",
motherName = "Annenizin adını söyleyin",
)
)
// Optional colour overrides (raw ARGB/hex values, e.g. from a remote config):
.userInterfaceColorInts(
speechTextColor = 0xFF111111.toInt(),
micActiveColor = 0xFF5BC792.toInt(),
retryButtonBackgroundColor = 0xFFEA3365.toInt(),
)
// Optional status-text overrides:
.userInterfaceTexts(
listening = "Dinleniyor…",
verifying = "Doğrulanıyor…",
verified = "Doğrulandı",
)
.onUiStateChanged { state ->
when (state) {
SpeechVerifierUiState.CAPTURE_STARTED -> Log.d(TAG, "Recording started")
SpeechVerifierUiState.LISTENING_STARTED -> Log.d(TAG, "Listening…")
SpeechVerifierUiState.SPEECH_VERIFIED -> Log.d(TAG, "Matched; finalising")
}
}
.observe(object : SpeechVerifierObserver {
override fun onPreparing() {
Log.d(TAG, "Preparing (fetching customer detail)…") // show a loader
}

override fun onReady() {
Log.d(TAG, "Ready — screen is live") // hide the loader
}

override fun onSuccess() {
Log.d(TAG, "All steps passed — uploading evidence")
uploadEvidence()
}

override fun onFailure(reason: OnFailureSpeechVerifier, currentAttempt: Int) {
Log.d(TAG, "Failure: $reason (attempt $currentAttempt)")
}

override fun onError(error: Error) {
Log.e(TAG, "Error: ${error.message}")
}
})
.build()

supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.commit()
}

private fun uploadEvidence() {
SpeechVerifier.upload(
context = this,
observer = object : SpeechVerifierUploadObserver {
override fun onResult(result: SpeechVerifierUploadResult) {
Log.d(TAG, "Upload result: ${result.stepStatus} doc=${result.documentId}")
}

override fun onError(error: SpeechVerifierUploadError, message: String) {
Log.e(TAG, "Upload error: $error$message")
}
}
// timeoutMs = 60_000 by default
)
}
}

Notes

  • The recorded video is tracked internally — onSuccess() returns no data and upload(...) needs no path.
  • Session credentials (token) and identity answers are held in memory only; they never enter a Bundle or the logs.
  • On a wrong reading the take is dropped and a retry screen is shown; retrying restarts the flow from the first step with a fresh recording.
  • Diagnostic logs (recognized text, similarity) are emitted only in debug builds; the released AAR is silent.