Skip to main content
The example below demonstrates a LearnInkWebViewDialogFragment for native Android. It calls your own backend to fetch a sign-in token — your backend is responsible for calling the LearnInk Identify API securely. Before using this, replace the following constants in the companion object:
  • YOUR_BACKEND_AUTH_URL — your backend endpoint that calls the LearnInk Identify API and returns a token
  • ORG_ID — your LearnInk organisation ID
Dependencies required:
implementation("com.squareup.okhttp3:okhttp:4.12.0")
@file:Suppress("SetJavaScriptEnabled")

package com.example.learninkwebview

import android.annotation.SuppressLint
import android.app.Dialog
import android.os.Bundle
import android.view.*
import android.webkit.*
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit

class LearnInkWebViewDialogFragment : DialogFragment() {

    companion object {
        private const val ARG_PATH = "arg_path"
        private const val ARG_USER_ID = "arg_user_id"

        // Configuration constants — replace these
        private const val YOUR_BACKEND_AUTH_URL = "https://your-api.com/auth/learnink"
        private const val WEBVIEW_BASE_URL = "https://m.learn.ink"
        private const val ORG_ID = "acme"
        private const val REQUEST_TIMEOUT_MS = 5000L

        private const val MSG_CLOSE = "CLOSE"
        private const val MSG_SESSION_EXPIRED = "SESSION_EXPIRED"

        fun newInstance(path: String, userId: String) =
            LearnInkWebViewDialogFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PATH, path)
                    putString(ARG_USER_ID, userId)
                }
            }
    }

    private lateinit var webView: WebView
    private lateinit var loadingOverlay: View

    private val path: String by lazy { requireArguments().getString(ARG_PATH).orEmpty() }
    private val userId: String by lazy { requireArguments().getString(ARG_USER_ID).orEmpty() }

    private val httpClient: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .callTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
            .connectTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
            .readTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
            .writeTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
            .build()
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val root = FrameLayout(requireContext())

        webView = WebView(requireContext())
        loadingOverlay = createLoadingOverlay()

        root.addView(webView, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
        root.addView(loadingOverlay, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))

        initWebView()
        authenticateAndLoad()

        return Dialog(requireContext()).apply {
            requestWindowFeature(Window.FEATURE_NO_TITLE)
            setContentView(root)
            window?.setLayout(MATCH_PARENT, MATCH_PARENT)
        }
    }

    private fun createLoadingOverlay(): View {
        val overlay = FrameLayout(requireContext()).apply {
            setBackgroundColor(0xFFFFFFFF.toInt())
            isClickable = true
            visibility = View.VISIBLE
        }
        val spinner = ProgressBar(requireContext()).apply { isIndeterminate = true }
        overlay.addView(spinner, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER))
        return overlay
    }

    @SuppressLint("AddJavascriptInterface")
    private fun initWebView() {
        CookieManager.getInstance().apply {
            setAcceptCookie(true)
            setAcceptThirdPartyCookies(webView, true)
        }

        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            mediaPlaybackRequiresUserGesture = false
            loadsImagesAutomatically = true
            useWideViewPort = true
            loadWithOverviewMode = true
        }

        webView.addJavascriptInterface(WebAppBridge(), "LearnInkBridge")

        webView.webViewClient = object : WebViewClient() {
            override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) =
                setLoading(true)

            override fun onPageFinished(view: WebView, url: String) =
                setLoading(false)

            override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
                if (request.isForMainFrame) {
                    setLoading(false)
                    Toast.makeText(requireContext(), "Failed to load page", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun setLoading(isLoading: Boolean) {
        loadingOverlay.visibility = if (isLoading) View.VISIBLE else View.GONE
    }

    private fun buildWebViewUrl(token: String?): String {
        val params = mutableMapOf("webview" to "true")
        if (!token.isNullOrBlank()) params["token"] = token
        val query = params.entries.joinToString("&") { (k, v) ->
            "${k.encodeUrlComponent()}=${v.encodeUrlComponent()}"
        }
        return "$WEBVIEW_BASE_URL/$ORG_ID/$path?$query"
    }

    private fun authenticateAndLoad() {
        setLoading(true)
        lifecycleScope.launch {
            val url = try {
                buildWebViewUrl(fetchAuthToken(userId))
            } catch (e: Exception) {
                // Fall back to loading without a token if the user has an existing session
                buildWebViewUrl(null)
            }
            webView.loadUrl(url)
        }
    }

    private fun handleSessionExpired() {
        setLoading(true)
        lifecycleScope.launch {
            try {
                webView.loadUrl(buildWebViewUrl(fetchAuthToken(userId)))
            } catch (e: Exception) {
                setLoading(false)
                AlertDialog.Builder(requireContext())
                    .setTitle("Session Refresh Failed")
                    .setMessage("Unable to refresh your session. Please close and try again.")
                    .setPositiveButton("Close") { _, _ -> dismissAllowingStateLoss() }
                    .setNegativeButton("Retry") { _, _ -> handleSessionExpired() }
                    .show()
            }
        }
    }

    private suspend fun fetchAuthToken(userId: String): String = withContext(Dispatchers.IO) {
        val body = JSONObject().put("id", userId).toString()
            .toRequestBody("application/json".toMediaType())

        val request = Request.Builder()
            .url(YOUR_BACKEND_AUTH_URL)
            .post(body)
            .addHeader("Content-Type", "application/json")
            // Add your own auth headers here if needed:
            // .addHeader("Authorization", "Bearer ${yourUserSessionToken}")
            .build()

        httpClient.newCall(request).execute().use { resp ->
            val respBody = resp.body?.string().orEmpty()
            if (!resp.isSuccessful) {
                val message = runCatching { JSONObject(respBody).optString("message") }.getOrNull()
                throw RuntimeException(message ?: "Failed to authenticate")
            }
            val token = JSONObject(respBody).optString("token")
            if (token.isBlank()) throw RuntimeException("No token returned from backend")
            token
        }
    }

    inner class WebAppBridge {
        @JavascriptInterface
        fun postMessage(raw: String) {
            lifecycleScope.launch(Dispatchers.Main) {
                try {
                    when (JSONObject(raw).optString("type")) {
                        MSG_CLOSE -> dismissAllowingStateLoss()
                        MSG_SESSION_EXPIRED -> handleSessionExpired()
                    }
                } catch (_: Exception) {}
            }
        }
    }

    private fun String.encodeUrlComponent(): String =
        java.net.URLEncoder.encode(this, Charsets.UTF_8.name())

    override fun onDestroyView() {
        super.onDestroyView()
        try {
            webView.apply {
                loadUrl("about:blank")
                stopLoading()
                webChromeClient = null
                webViewClient = null
                removeJavascriptInterface("LearnInkBridge")
                destroy()
            }
        } catch (_: Exception) {}
    }
}