Kotlin implementation



Usage


import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.AttributeSet
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import org.json.JSONObject

private enum class Stage {
    UNOPENED,
    INTRO,
    SELECT_CARRIER,
    CREDS_FORM,
    IDV_OPTIONS,
    IDV_SUBMITION,
    SUCCESS,
    DRIVERS
}

private enum class Status {
    NONE,
    STARTED,
    AUTHENTICATED,
    SUCCESS
}

data class PullData(val pullId: String?, val metadataJson: String?)

data class ExitError(val errorCode: String)

data class EmbeddedLinkConfig(
    val publicAlias: String,
    val reconnectToken: String? = null,
    val consentToken: String? = null,
    val hideCloseButton: Boolean = false,
    val disableSuccessRedirect: Boolean = false,
    val pullMetaData: Map<String, Any>? = null
)

class EmbeddedLinkView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    interface Listener {
        fun onAuthenticationSuccess(data: PullData) {}
        fun onExit(error: ExitError?, data: PullData?) {}
        fun onSelectCustomInsuranceProvider(providerName: String) {}
    }

    private val stageErrors = mapOf(
        Stage.INTRO to "INSUFFICIENT_CREDENTIALS",
        Stage.SELECT_CARRIER to "INSUFFICIENT_CREDENTIALS",
        Stage.CREDS_FORM to "INSUFFICIENT_CREDENTIALS",
        Stage.IDV_OPTIONS to "IDENTITY_VERIFICATION_OPTIONS",
        Stage.IDV_SUBMITION to "IDENTITY_VERIFICATION"
    )

    private data class PullState(
        var stage: Stage = Stage.INTRO,
        var status: Status = Status.NONE,
        var currentPullData: PullData? = null,
        var refreshToken: String? = null
    )

    private val webView = WebView(context.applicationContext)
    private var pullState = PullState()
    private var config: EmbeddedLinkConfig? = null
    private var listener: Listener? = null

    init {
        layoutParams = LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        )
        setupWebView()
    }

    fun load(config: EmbeddedLinkConfig, listener: Listener? = null) {
        this.config = config
        this.listener = listener
        pullState = PullState()
        webView.loadUrl(generateSrc(config, pullState.refreshToken))
    }

    fun requestClose() {
        sendToPage(JSONObject().put("canopy_event", "confirm_close"))
    }

    private fun setupWebView() {
        addView(webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
        webView.webChromeClient = WebChromeClient()
        webView.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(
                view: WebView?,
                request: WebResourceRequest?
            ): Boolean {
                val target = request?.url?.toString().orEmpty()
                if (target.startsWith("about:blank")) {
                    return true
                }
                if (!target.startsWith("https://app.usecanopy.com")) {
                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(target)).apply {
                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                    }
                    context.startActivity(intent)
                    return true
                }
                return false
            }

            override fun onPageFinished(view: WebView?, url: String?) {
                injectBridge()
            }
        }
        with(webView.settings) {
            javaScriptEnabled = true
            domStorageEnabled = true
            cacheMode = WebSettings.LOAD_DEFAULT
            userAgentString = "${userAgentString} CCAndroidSDK/1.0.0"
        }
        webView.addJavascriptInterface(CanopyBridge(), "CanopyNative")
    }

    private fun injectBridge() {
        val script = """
            (function() {
                window.addEventListener('message', function(event) {
                    if (event.data && (event.data.type || event.data.canopy_event)) {
                        try {
                            CanopyNative.onMessage(JSON.stringify(event.data));
                        } catch (e) {}
                    }
                });
            })();
        """.trimIndent()
        webView.evaluateJavascript(script, null)
    }

    private fun generateSrc(config: EmbeddedLinkConfig, refreshToken: String?): String {
        val path = StringBuilder("/c/${config.publicAlias}")
        val query = StringBuilder("?modal=true")
        when {
            config.reconnectToken != null -> {
                path.append("/reconnect")
                query.append("&reconnectToken=${config.reconnectToken}")
            }
            refreshToken != null -> query.append("&refreshToken=$refreshToken")
            config.consentToken != null -> query.append("&consentToken=${config.consentToken}")
        }
        if (config.hideCloseButton) query.append("&hide_close_button=true")
        if (config.disableSuccessRedirect) query.append("&disable_success_redirect=true")
        query.append("&sdk=android")
        return "https://app.usecanopy.com$path$query"
    }

    private fun handleMessage(message: String) {
        val payload = try {
            JSONObject(message)
        } catch (_: Exception) {
            return
        }
        when (payload.optString("canopy_event")) {
            "stage_update" -> {
                pullState.stage = parseStage(payload.optString("stage"))
            }
            "status_update" -> handleStatusUpdate(payload)
            "ready" -> sendMetadataIfNeeded()
            "selected_other_carrier" -> {
                val provider = payload.optString("insurer_name")
                if (provider.isNotEmpty()) {
                    listener?.onSelectCustomInsuranceProvider(provider)
                }
            }
            "refresh_token" -> pullState.refreshToken = payload.optString("refresh_token", null)
            "close" -> handleClose()
        }
    }

    private fun handleStatusUpdate(payload: JSONObject) {
        val status = parseStatus(payload.optString("status"))
        pullState.status = status
        val pull = payload.optJSONObject("pull")
        val pullData = PullData(
            pullId = pull?.optString("pull_id"),
            metadataJson = pull?.optJSONObject("metadata")?.toString()
        )
        when (pullState.status) {
            Status.STARTED -> pullState.currentPullData = pullData
            Status.AUTHENTICATED -> {
                pullState.currentPullData = pullData
                listener?.onAuthenticationSuccess(pullData)
            }
            Status.SUCCESS -> {
                pullState.currentPullData = pullData
                listener?.onExit(null, pullData)
            }
            else -> Unit
        }
    }

    private fun handleClose() {
        val errorCode = if (
            pullState.status != Status.AUTHENTICATED &&
            pullState.status != Status.SUCCESS
        ) {
            stageErrors[pullState.stage]
        } else null
        listener?.onExit(errorCode?.let { ExitError(it) }, pullState.currentPullData)

        val currentConfig = config
        if (currentConfig != null) {
            webView.loadUrl(generateSrc(currentConfig, pullState.refreshToken))
        }
        pullState = pullState.copy(
            stage = Stage.INTRO,
            status = Status.NONE,
            currentPullData = null
        )
    }

    private fun sendMetadataIfNeeded() {
        val metadata = config?.pullMetaData ?: return
        val metadataJson = JSONObject(metadata)
        sendToPage(
            JSONObject()
                .put("canopy_event", "metadata")
                .put("metadata", metadataJson)
        )
    }

    private fun sendToPage(payload: JSONObject) {
        val escaped = JSONObject.quote(payload.toString())
        val script =
            "window.dispatchEvent(new MessageEvent('message', { data: $escaped }));"
        webView.evaluateJavascript(script, null)
    }

    private fun parseStage(value: String?): Stage =
        runCatching { Stage.valueOf(value.orEmpty()) }.getOrDefault(Stage.INTRO)

    private fun parseStatus(value: String?): Status =
        runCatching { Status.valueOf(value.orEmpty()) }.getOrDefault(Status.NONE)

    inner class CanopyBridge {
        @JavascriptInterface
        fun onMessage(messageJson: String) {
            webView.post { handleMessage(messageJson) }
        }
    }
}