package io.umbrellax.verify import android.Manifest import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient import java.util.Locale import java.util.UUID import java.util.concurrent.ConcurrentHashMap import org.json.JSONArray import org.json.JSONObject interface TokenExpirationHandler { fun onTokenExpired(): String? } data class UmbrellaXVerifyEvidence( val id: String, val status: String, val value: String ) data class UmbrellaXVerifyResult( val status: String, val verificationId: String?, val coreVerificationId: String?, val coreStatus: String?, val coreDecision: String?, val riskScore: Double?, val reasons: List, val faceSimilarity: Double?, val evidence: List, val raw: JSONObject ) data class SNSInitConfig( val email: String? = null, val phone: String? = null ) class UmbrellaXVerifySDK private constructor( private val activity: Activity, private val accessToken: String, private val baseUrl: String, private val locale: Locale?, private var initConfig: SNSInitConfig?, private var autoCloseOnApproveSeconds: Int?, private val tokenExpirationHandler: (() -> String)?, private val callbacks: CallbackBundle ) { private var callbackId: String? = null fun launch() { val callbackId = UUID.randomUUID().toString() this.callbackId = callbackId callbackStore[callbackId] = callbacks.copy(tokenExpirationHandler = tokenExpirationHandler) val intent = Intent(activity, UmbrellaXVerifyActivity::class.java) .putExtra(EXTRA_ACCESS_TOKEN, accessToken) .putExtra(EXTRA_BASE_URL, baseUrl) .putExtra(EXTRA_LOCALE, locale?.toLanguageTag()) .putExtra(EXTRA_EMAIL, initConfig?.email) .putExtra(EXTRA_PHONE, initConfig?.phone) .putExtra(EXTRA_AUTO_CLOSE_SECONDS, autoCloseOnApproveSeconds ?: -1) .putExtra(EXTRA_CALLBACK_ID, callbackId) activity.startActivity(intent) } fun dismiss() { callbackId?.let { UmbrellaXVerifyActivity.dismissCurrent(it) } } fun withAutoCloseOnApprove(seconds: Int) = apply { autoCloseOnApproveSeconds = seconds.coerceAtLeast(0) } class Builder(private val activity: Activity) { private var accessToken: String = "" private var baseUrl: String = "https://verify.umbrellax.io" private var locale: Locale? = null private var initConfig: SNSInitConfig? = null private var autoCloseOnApproveSeconds: Int? = null private var tokenExpirationHandler: (() -> String)? = null private var callbacks = CallbackBundle() fun withAccessToken(accessToken: String, tokenExpirationHandler: (() -> String)? = null) = apply { this.accessToken = accessToken this.tokenExpirationHandler = tokenExpirationHandler } fun withBaseUrl(baseUrl: String) = apply { this.baseUrl = baseUrl.trimEnd('/') } fun withLocale(locale: Locale) = apply { this.locale = locale } fun withConf(conf: SNSInitConfig) = apply { this.initConfig = conf } fun withAutoCloseOnApprove(seconds: Int) = apply { this.autoCloseOnApproveSeconds = seconds.coerceAtLeast(0) } fun withHandlers( onStatusDidChange: ((String) -> Unit)? = null, onComplete: ((UmbrellaXVerifyResult) -> Unit)? = null, onError: ((String) -> Unit)? = null, onEvent: ((JSONObject) -> Unit)? = null ) = apply { callbacks = CallbackBundle( onStatusDidChange = onStatusDidChange, onComplete = onComplete, onError = onError, onEvent = onEvent ) } fun build(): UmbrellaXVerifySDK { require(accessToken.isNotBlank()) { "accessToken is required" } return UmbrellaXVerifySDK( activity = activity, accessToken = accessToken, baseUrl = baseUrl, locale = locale, initConfig = initConfig, autoCloseOnApproveSeconds = autoCloseOnApproveSeconds, tokenExpirationHandler = tokenExpirationHandler, callbacks = callbacks ) } } internal data class CallbackBundle( val onStatusDidChange: ((String) -> Unit)? = null, val onComplete: ((UmbrellaXVerifyResult) -> Unit)? = null, val onError: ((String) -> Unit)? = null, val onEvent: ((JSONObject) -> Unit)? = null, val tokenExpirationHandler: (() -> String)? = null ) companion object { internal const val EXTRA_ACCESS_TOKEN = "io.umbrellax.verify.ACCESS_TOKEN" internal const val EXTRA_BASE_URL = "io.umbrellax.verify.BASE_URL" internal const val EXTRA_LOCALE = "io.umbrellax.verify.LOCALE" internal const val EXTRA_EMAIL = "io.umbrellax.verify.EMAIL" internal const val EXTRA_PHONE = "io.umbrellax.verify.PHONE" internal const val EXTRA_AUTO_CLOSE_SECONDS = "io.umbrellax.verify.AUTO_CLOSE_SECONDS" internal const val EXTRA_CALLBACK_ID = "io.umbrellax.verify.CALLBACK_ID" internal val callbackStore = ConcurrentHashMap() } } object SNSMobileSDK { class Builder(private val activity: Activity) { private val delegate = UmbrellaXVerifySDK.Builder(activity) fun withAccessToken(accessToken: String, onTokenExpiration: TokenExpirationHandler? = null) = apply { delegate.withAccessToken(accessToken) { onTokenExpiration?.onTokenExpired().orEmpty() } } fun withBaseUrl(baseUrl: String) = apply { delegate.withBaseUrl(baseUrl) } fun withLocale(locale: Locale) = apply { delegate.withLocale(locale) } fun withConf(conf: SNSInitConfig) = apply { delegate.withConf(conf) } fun withAutoCloseOnApprove(seconds: Int) = apply { delegate.withAutoCloseOnApprove(seconds) } fun withHandlers( onStatusDidChange: ((String) -> Unit)? = null, onComplete: ((UmbrellaXVerifyResult) -> Unit)? = null, onError: ((String) -> Unit)? = null, onEvent: ((JSONObject) -> Unit)? = null ) = apply { delegate.withHandlers(onStatusDidChange, onComplete, onError, onEvent) } fun build(): SDK = SDK(delegate.build()) } class SDK internal constructor(private val delegate: UmbrellaXVerifySDK) { fun launch() = delegate.launch() fun dismiss() = delegate.dismiss() fun withAutoCloseOnApprove(seconds: Int) = apply { delegate.withAutoCloseOnApprove(seconds) } } } class UmbrellaXVerifyActivity : Activity() { private lateinit var webView: WebView private var callbackId: String = "" private var callbacks: UmbrellaXVerifySDK.CallbackBundle? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) callbackId = intent.getStringExtra(UmbrellaXVerifySDK.EXTRA_CALLBACK_ID).orEmpty() callbacks = UmbrellaXVerifySDK.callbackStore[callbackId] currentActivities[callbackId] = this webView = WebView(this) setContentView(webView) configureWebView() requestCameraAndMicrophone() loadFlow() } override fun onDestroy() { UmbrellaXVerifySDK.callbackStore.remove(callbackId) currentActivities.remove(callbackId) webView.removeJavascriptInterface("FacesAndroid") webView.destroy() super.onDestroy() } private fun configureWebView() { webView.settings.javaScriptEnabled = true webView.settings.domStorageEnabled = true webView.settings.mediaPlaybackRequiresUserGesture = false webView.webViewClient = WebViewClient() webView.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { runOnUiThread { request.grant(request.resources) } } } webView.addJavascriptInterface(FacesBridge(), "FacesAndroid") } private fun requestCameraAndMicrophone() { val permissions = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) val missing = permissions.filter { checkSelfPermissionCompat(it) != PackageManager.PERMISSION_GRANTED } if (missing.isNotEmpty()) { requestPermissions(missing.toTypedArray(), 5171) } } private fun checkSelfPermissionCompat(permission: String): Int { return if (android.os.Build.VERSION.SDK_INT >= 23) { checkSelfPermission(permission) } else { PackageManager.PERMISSION_GRANTED } } private fun loadFlow(accessToken: String? = null) { val token = accessToken ?: intent.getStringExtra(UmbrellaXVerifySDK.EXTRA_ACCESS_TOKEN).orEmpty() val baseUrl = intent.getStringExtra(UmbrellaXVerifySDK.EXTRA_BASE_URL) ?: "https://verify.umbrellax.io" val locale = intent.getStringExtra(UmbrellaXVerifySDK.EXTRA_LOCALE) val email = intent.getStringExtra(UmbrellaXVerifySDK.EXTRA_EMAIL) val phone = intent.getStringExtra(UmbrellaXVerifySDK.EXTRA_PHONE) val uri = Uri.parse(baseUrl.trimEnd('/')).buildUpon() .appendEncodedPath("sdk/sumsub") .appendQueryParameter("token", token) .apply { if (!locale.isNullOrBlank()) appendQueryParameter("locale", locale) if (!email.isNullOrBlank()) appendQueryParameter("email", email) if (!phone.isNullOrBlank()) appendQueryParameter("phone", phone) } .build() webView.loadUrl(uri.toString()) } private fun readNullableString(payload: JSONObject, key: String): String? { return payload.optString(key).ifBlank { null } } private fun readNullableDouble(payload: JSONObject, key: String): Double? { return if (payload.has(key) && !payload.isNull(key)) payload.optDouble(key) else null } private fun readStringList(values: JSONArray?): List { if (values == null) return emptyList() return (0 until values.length()).mapNotNull { index -> values.optString(index).ifBlank { null } } } private fun readEvidenceList(values: JSONArray?): List { if (values == null) return emptyList() return (0 until values.length()).mapNotNull { index -> val item = values.optJSONObject(index) ?: return@mapNotNull null UmbrellaXVerifyEvidence( id = item.optString("id"), status = item.optString("status", "review"), value = item.optString("value") ) } } private fun handle(payload: JSONObject) { callbacks?.onEvent?.invoke(payload) when (payload.optString("type")) { "status" -> callbacks?.onStatusDidChange?.invoke(payload.optString("status")) "complete" -> callbacks?.onComplete?.invoke( UmbrellaXVerifyResult( status = payload.optString("status", "manual_review"), verificationId = readNullableString(payload, "verificationId"), coreVerificationId = readNullableString(payload, "coreVerificationId"), coreStatus = readNullableString(payload, "coreStatus"), coreDecision = readNullableString(payload, "coreDecision"), riskScore = readNullableDouble(payload, "riskScore"), reasons = readStringList(payload.optJSONArray("reasons")), faceSimilarity = readNullableDouble(payload, "faceSimilarity"), evidence = readEvidenceList(payload.optJSONArray("evidence")), raw = payload ) ).also { if (payload.optString("status") == "accepted") { val delaySeconds = intent.getIntExtra(UmbrellaXVerifySDK.EXTRA_AUTO_CLOSE_SECONDS, -1) if (delaySeconds >= 0) { Handler(Looper.getMainLooper()).postDelayed({ finish() }, delaySeconds * 1000L) } } } "error" -> callbacks?.onError?.invoke(payload.optString("error", "verify_error")) "tokenExpired" -> callbacks?.tokenExpirationHandler?.invoke()?.let { loadFlow(it) } } } inner class FacesBridge { @JavascriptInterface fun postMessage(message: String) { runOnUiThread { try { handle(JSONObject(message)) } catch (error: Throwable) { callbacks?.onError?.invoke(error.message ?: "invalid_bridge_message") } } } } companion object { private val currentActivities = ConcurrentHashMap() fun dismissCurrent(callbackId: String) { currentActivities[callbackId]?.finish() } } }