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) }
}
}
}
