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
| Type | Meaning |
|---|---|
ID_NUMBER | The whole national ID number, read digit by digit |
MOTHER_NAME | Mother's given name |
FATHER_NAME | Father's given name |
DOCUMENT_NUMBER | The 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 theprofile_idclaim).identityAnswers(...)— supply the answers directly up-front; no network round-trip is made for the profile (andonPreparing()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.
| Method | Description | Default |
|---|---|---|
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)
| Reason | Code | Meaning |
|---|---|---|
SPEECH_NOT_MATCHED | 9000 | The completed utterance did not match the displayed text. |
SPEECH_NOT_DETECTED | 9001 | No usable speech was detected before the recognizer timed out. |
VERIFICATION_TIMEOUT | 9002 | The time window elapsed without a successful match. |
SPEECH_NEGATION_DETECTED | 9003 | A negation / reject word was spoken (semantic inversion). |
UI State (SpeechVerifierUiState)
Delivered to onUiStateChanged { ... } (main thread):
| State | Meaning |
|---|---|
CAPTURE_STARTED | Camera opened and video recording began. |
LISTENING_STARTED | The recognizer started listening to the microphone. |
SPEECH_VERIFIED | The 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)
| Error | Code | Meaning |
|---|---|---|
SESSION_MISSING | 9100 | session(...) was never provided, or no secured recording exists. |
UPLOAD_FAILED | 9101 | The document POST failed (network error or non-2xx response). |
SSE_AUTH_FAILED | 9102 | The event stream rejected the credentials (JWT invalid/expired or missing profile scope). |
RESULT_TIMEOUT | 9103 | Upload succeeded but no step status arrived within the timeout. |
PARSE_ERROR | 9104 | The 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 andupload(...)needs no path. - Session credentials (token) and identity answers are held in memory only; they never enter a
Bundleor 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.