Skip to main content

Using scorm-again with Kotlin Multiplatform for Offline Learning

This guide demonstrates how to implement SCORM content in a Kotlin Multiplatform (KMP) application with offline support using scorm-again. KMP allows you to share business logic across Android and iOS while using platform-native WebView implementations.

Prerequisites

  • Android Studio with Kotlin Multiplatform plugin
  • Xcode for iOS development
  • Basic knowledge of Kotlin Multiplatform development
  • Understanding of SCORM packages

Project Setup

1. Configure Version Catalog

Create or update your gradle/libs.versions.toml with the required dependencies:

[versions]
kotlin = "2.0.21"
agp = "8.7.0"
ktor = "3.0.0"
coroutines = "1.9.0"
serialization = "1.7.3"
sqldelight = "2.0.2"

[libraries]
# Coroutines
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

# Serialization
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }

# Ktor Client
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

# SQLDelight
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }

2. Configure Shared Module

Update your shared/build.gradle.kts:

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.sqldelight)
}

kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()

sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.coroutines.core)
implementation(libs.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines)
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.android)
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native)
}
}
val iosX64Main by getting { dependsOn(iosMain) }
val iosArm64Main by getting { dependsOn(iosMain) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain) }
}
}

android {
namespace = "com.example.scormplayer.shared"
compileSdk = 35

defaultConfig {
minSdk = 24
}
}

sqldelight {
databases {
create("ScormDatabase") {
packageName.set("com.example.scormplayer.db")
schemaOutputDirectory.set(file("src/commonMain/sqldelight/schema"))
}
}
}

3. Project Structure

/shared
/src
/commonMain
/kotlin
/connectivity # Network monitoring abstractions
/db # SQLDelight database access
/scorm # SCORM data models and sync logic
/web # WebView abstractions
/sqldelight # SQL schema files
/androidMain
/kotlin
/connectivity # Android ConnectivityManager implementation
/db # Android SQLite driver
/web # Android WebView implementation
/iosMain
/kotlin
/connectivity # iOS NWPathMonitor implementation
/db # iOS Native SQLite driver
/web # iOS WKWebView implementation
/androidApp
/src/main
/assets
/scorm-again
/api
scorm-again.js
/iosApp
/Resources
/scorm-again
/api
scorm-again.js

Implementation

1. Network Connectivity Monitoring (Shared)

Create a shared abstraction for network connectivity:

// shared/src/commonMain/kotlin/connectivity/ConnectivityObserver.kt
package connectivity

import kotlinx.coroutines.flow.Flow

enum class NetworkStatus {
Available,
Unavailable,
Losing,
Lost
}

interface ConnectivityObserver {
val status: Flow<NetworkStatus>
fun isCurrentlyAvailable(): Boolean
}

expect class ConnectivityObserverFactory {
fun create(): ConnectivityObserver
}

Android Implementation

// shared/src/androidMain/kotlin/connectivity/ConnectivityObserver.android.kt
package connectivity

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged

class AndroidConnectivityObserver(
private val context: Context
) : ConnectivityObserver {

private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

override val status: Flow<NetworkStatus> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available)
}

override fun onLosing(network: Network, maxMsToLive: Int) {
trySend(NetworkStatus.Losing)
}

override fun onLost(network: Network) {
trySend(NetworkStatus.Lost)
}

override fun onUnavailable() {
trySend(NetworkStatus.Unavailable)
}
}

val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

connectivityManager.registerNetworkCallback(request, callback)

// Send initial state
trySend(if (isCurrentlyAvailable()) NetworkStatus.Available else NetworkStatus.Unavailable)

awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()

override fun isCurrentlyAvailable(): Boolean {
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
return capabilities != null && (
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
)
}
}

actual class ConnectivityObserverFactory(
private val context: Context
) {
actual fun create(): ConnectivityObserver = AndroidConnectivityObserver(context)
}

iOS Implementation

// shared/src/iosMain/kotlin/connectivity/ConnectivityObserver.ios.kt
package connectivity

import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import platform.Network.nw_path_get_status
import platform.Network.nw_path_monitor_cancel
import platform.Network.nw_path_monitor_create
import platform.Network.nw_path_monitor_set_queue
import platform.Network.nw_path_monitor_set_update_handler
import platform.Network.nw_path_monitor_start
import platform.Network.nw_path_status_satisfied
import platform.darwin.dispatch_get_main_queue

class IOSConnectivityObserver : ConnectivityObserver {

private var currentStatus: NetworkStatus = NetworkStatus.Available

override val status: Flow<NetworkStatus> = callbackFlow {
val monitor = nw_path_monitor_create()
nw_path_monitor_set_queue(monitor, dispatch_get_main_queue())

nw_path_monitor_set_update_handler(monitor) { path ->
val reachable = nw_path_get_status(path) == nw_path_status_satisfied
currentStatus = if (reachable) NetworkStatus.Available else NetworkStatus.Unavailable
trySend(currentStatus)
}

nw_path_monitor_start(monitor)

awaitClose {
nw_path_monitor_cancel(monitor)
}
}.distinctUntilChanged()

override fun isCurrentlyAvailable(): Boolean = currentStatus == NetworkStatus.Available
}

actual class ConnectivityObserverFactory {
actual fun create(): ConnectivityObserver = IOSConnectivityObserver()
}

2. Local Storage with SQLDelight

Create the SQL schema for storing SCORM data:

-- shared/src/commonMain/sqldelight/com/example/scormplayer/db/ScormData.sq
CREATE TABLE scorm_data (
course_id TEXT NOT NULL,
element_key TEXT NOT NULL,
element_value TEXT NOT NULL,
timestamp INTEGER NOT NULL,
synced INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (course_id, element_key)
);

selectAllForCourse:
SELECT * FROM scorm_data WHERE course_id = ?;

selectUnsynced:
SELECT * FROM scorm_data WHERE synced = 0;

insertOrReplace:
INSERT OR REPLACE INTO scorm_data (course_id, element_key, element_value, timestamp, synced)
VALUES (?, ?, ?, ?, ?);

markAsSynced:
UPDATE scorm_data SET synced = 1 WHERE course_id = ? AND element_key = ?;

deleteForCourse:
DELETE FROM scorm_data WHERE course_id = ?;

deleteAll:
DELETE FROM scorm_data;

Create the database driver factory:

// shared/src/commonMain/kotlin/db/DatabaseDriverFactory.kt
package db

import app.cash.sqldelight.db.SqlDriver

expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// shared/src/androidMain/kotlin/db/DatabaseDriverFactory.android.kt
package db

import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.example.scormplayer.db.ScormDatabase

actual class DatabaseDriverFactory(
private val context: Context
) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(ScormDatabase.Schema, context, "scorm.db")
}
// shared/src/iosMain/kotlin/db/DatabaseDriverFactory.ios.kt
package db

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.example.scormplayer.db.ScormDatabase

actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(ScormDatabase.Schema, "scorm.db")
}

Create a repository for SCORM data:

// shared/src/commonMain/kotlin/db/ScormDataRepository.kt
package db

import com.example.scormplayer.db.ScormDatabase
import com.example.scormplayer.db.Scorm_data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext

class ScormDataRepository(
driverFactory: DatabaseDriverFactory
) {
private val database = ScormDatabase(driverFactory.createDriver())
private val queries = database.scormDataQueries

suspend fun saveData(
courseId: String,
key: String,
value: String,
synced: Boolean = false
) = withContext(Dispatchers.IO) {
queries.insertOrReplace(
course_id = courseId,
element_key = key,
element_value = value,
timestamp = System.currentTimeMillis(),
synced = if (synced) 1L else 0L
)
}

suspend fun getDataForCourse(courseId: String): List<Scorm_data> =
withContext(Dispatchers.IO) {
queries.selectAllForCourse(courseId).executeAsList()
}

suspend fun getUnsyncedData(): List<Scorm_data> =
withContext(Dispatchers.IO) {
queries.selectUnsynced().executeAsList()
}

suspend fun markAsSynced(courseId: String, key: String) =
withContext(Dispatchers.IO) {
queries.markAsSynced(courseId, key)
}

suspend fun deleteForCourse(courseId: String) =
withContext(Dispatchers.IO) {
queries.deleteForCourse(courseId)
}
}

3. WebView Abstraction (Shared)

Create a shared interface for WebView operations:

// shared/src/commonMain/kotlin/web/WebViewController.kt
package web

interface WebViewController {
fun loadUrl(url: String)
fun evaluateJavaScript(script: String, callback: (String?) -> Unit = {})
fun addJavaScriptInterface(name: String, handler: (String) -> Unit)
}

interface WebViewDelegate {
fun onPageFinished(url: String)
fun onError(error: String)
}

expect class WebViewFactory {
fun create(delegate: WebViewDelegate): WebViewController
}

Android WebView Implementation

// shared/src/androidMain/kotlin/web/WebViewController.android.kt
package web

import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient

class AndroidWebViewController(
private val context: Context,
private val delegate: WebViewDelegate
) : WebViewController {

val webView: WebView = WebView(context).apply {
setupWebView()
}

private val jsHandlers = mutableMapOf<String, (String) -> Unit>()

@SuppressLint("SetJavaScriptEnabled")
private fun WebView.setupWebView() {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowFileAccessFromFileURLs = true
allowUniversalAccessFromFileURLs = true
}

webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
url?.let { delegate.onPageFinished(it) }
}

override fun onReceivedError(
view: WebView?,
errorCode: Int,
description: String?,
failingUrl: String?
) {
delegate.onError(description ?: "Unknown error")
}
}
}

override fun loadUrl(url: String) {
webView.loadUrl(url)
}

override fun evaluateJavaScript(script: String, callback: (String?) -> Unit) {
webView.evaluateJavascript(script) { result ->
callback(result)
}
}

override fun addJavaScriptInterface(name: String, handler: (String) -> Unit) {
jsHandlers[name] = handler

val jsInterface = object {
@JavascriptInterface
fun postMessage(message: String) {
handler(message)
}
}

webView.addJavascriptInterface(jsInterface, name)
}
}

actual class WebViewFactory(
private val context: Context
) {
actual fun create(delegate: WebViewDelegate): WebViewController =
AndroidWebViewController(context, delegate)
}

iOS WKWebView Implementation

// shared/src/iosMain/kotlin/web/WebViewController.ios.kt
package web

import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSURL
import platform.Foundation.NSURLRequest
import platform.WebKit.WKNavigation
import platform.WebKit.WKNavigationDelegateProtocol
import platform.WebKit.WKScriptMessage
import platform.WebKit.WKScriptMessageHandlerProtocol
import platform.WebKit.WKUserContentController
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import platform.darwin.NSObject

@OptIn(ExperimentalForeignApi::class)
class IOSWebViewController(
private val delegate: WebViewDelegate
) : WebViewController {

private val configuration = WKWebViewConfiguration().apply {
userContentController = WKUserContentController()
preferences.javaScriptEnabled = true
}

val webView: WKWebView = WKWebView(
frame = platform.CoreGraphics.CGRectZero.readValue(),
configuration = configuration
).apply {
navigationDelegate = NavigationDelegate(delegate)
}

private val messageHandlers = mutableMapOf<String, MessageHandler>()

override fun loadUrl(url: String) {
val nsUrl = NSURL.URLWithString(url) ?: return
val request = NSURLRequest.requestWithURL(nsUrl)
webView.loadRequest(request)
}

override fun evaluateJavaScript(script: String, callback: (String?) -> Unit) {
webView.evaluateJavaScript(script) { result, error ->
if (error != null) {
callback(null)
} else {
callback(result?.toString())
}
}
}

override fun addJavaScriptInterface(name: String, handler: (String) -> Unit) {
val messageHandler = MessageHandler(handler)
messageHandlers[name] = messageHandler
configuration.userContentController.addScriptMessageHandler(messageHandler, name)
}

private class NavigationDelegate(
private val delegate: WebViewDelegate
) : NSObject(), WKNavigationDelegateProtocol {
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
webView.URL?.absoluteString?.let { delegate.onPageFinished(it) }
}

override fun webView(
webView: WKWebView,
didFailNavigation: WKNavigation?,
withError: platform.Foundation.NSError
) {
delegate.onError(withError.localizedDescription)
}
}

private class MessageHandler(
private val handler: (String) -> Unit
) : NSObject(), WKScriptMessageHandlerProtocol {
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage
) {
val body = didReceiveScriptMessage.body
handler(body.toString())
}
}
}

actual class WebViewFactory {
actual fun create(delegate: WebViewDelegate): WebViewController =
IOSWebViewController(delegate)
}

4. SCORM Player Manager (Shared)

Create a shared manager that coordinates all SCORM functionality:

// shared/src/commonMain/kotlin/scorm/ScormPlayerManager.kt
package scorm

import connectivity.ConnectivityObserver
import connectivity.NetworkStatus
import db.ScormDataRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import web.WebViewController
import web.WebViewDelegate

@Serializable
data class ScormMessage(
val type: String,
val message: String? = null,
val level: Int? = null,
val status: String? = null,
val key: String? = null,
val value: String? = null
)

class ScormPlayerManager(
private val webViewController: WebViewController,
private val connectivityObserver: ConnectivityObserver,
private val repository: ScormDataRepository,
private val courseId: String,
private val apiPath: String,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main)
) {
private var isOnline: Boolean = true
private var onSyncComplete: ((Boolean) -> Unit)? = null
private var onLogMessage: ((String, Int) -> Unit)? = null

private val json = Json { ignoreUnknownKeys = true }

init {
observeConnectivity()
setupJavaScriptBridge()
}

private fun observeConnectivity() {
scope.launch {
connectivityObserver.status.collectLatest { status ->
isOnline = status == NetworkStatus.Available

// Update the scorm-again API with current online status
updateOnlineStatus(isOnline)

// Try to sync when coming back online
if (isOnline) {
syncOfflineData()
}
}
}
}

private fun setupJavaScriptBridge() {
webViewController.addJavaScriptInterface("ScormBridge") { message ->
handleScormMessage(message)
}
}

private fun handleScormMessage(messageJson: String) {
try {
val message = json.decodeFromString<ScormMessage>(messageJson)

when (message.type) {
"log" -> {
onLogMessage?.invoke(message.message ?: "", message.level ?: 0)
}
"sync" -> {
val success = message.status == "success"
onSyncComplete?.invoke(success)
}
"setValue" -> {
// Store data locally for offline support
message.key?.let { key ->
message.value?.let { value ->
scope.launch {
repository.saveData(courseId, key, value, synced = isOnline)
}
}
}
}
}
} catch (e: Exception) {
onLogMessage?.invoke("Error parsing SCORM message: ${e.message}", 1)
}
}

fun injectScormAgain() {
val js = """
var scormAgainScript = document.createElement('script');
scormAgainScript.src = '$apiPath';
scormAgainScript.onload = function() {
window.API = new window.ScormAgain.SCORM2004API({
enableOfflineSupport: true,
courseId: '$courseId',
autocommit: true,
autocommitSeconds: 60,
syncOnInitialize: true,
syncOnTerminate: true,
logLevel: 4,
onLogMessage: function(message, level) {
window.ScormBridge.postMessage(JSON.stringify({
type: 'log',
message: message,
level: level
}));
}
});

window.API.on('OfflineDataSynced', function() {
window.ScormBridge.postMessage(JSON.stringify({
type: 'sync',
status: 'success'
}));
});

// TODO: This will be replaced with the new event API pattern in a future update
window.API._offlineStorageService.isDeviceOnline = function() {
return $isOnline;
};
};
document.head.appendChild(scormAgainScript);
""".trimIndent()

webViewController.evaluateJavaScript(js)
}

private fun updateOnlineStatus(online: Boolean) {
val js = """
if (window.API && window.API._offlineStorageService) {
window.API._offlineStorageService.isDeviceOnline = function() {
return $online;
};
}
""".trimIndent()

webViewController.evaluateJavaScript(js)
}

fun syncOfflineData() {
if (!isOnline) return

val js = """
if (window.API && window.API._offlineStorageService) {
window.API._offlineStorageService.syncOfflineData().then(function(success) {
window.ScormBridge.postMessage(JSON.stringify({
type: 'sync',
status: success ? 'success' : 'failed'
}));
});
}
""".trimIndent()

webViewController.evaluateJavaScript(js)
}

fun setOnSyncComplete(callback: (Boolean) -> Unit) {
onSyncComplete = callback
}

fun setOnLogMessage(callback: (String, Int) -> Unit) {
onLogMessage = callback
}

fun isCurrentlyOnline(): Boolean = isOnline
}

5. File Management

Create platform-specific file management utilities:

// shared/src/commonMain/kotlin/storage/FileManager.kt
package storage

expect class FileManager {
fun getScormApiPath(): String
fun getCourseDirectory(courseId: String): String
fun getCourseUrl(courseId: String): String
fun isCourseAvailable(courseId: String): Boolean
}
// shared/src/androidMain/kotlin/storage/FileManager.android.kt
package storage

import android.content.Context
import android.os.Environment
import java.io.File
import java.io.FileOutputStream

actual class FileManager(
private val context: Context
) {
companion object {
private const val SCORM_API_ASSET = "scorm-again/api/scorm-again.js"
private const val EXTERNAL_SCORM_DIR = "ScormContent"
}

private val baseDir: File
get() {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
EXTERNAL_SCORM_DIR
)
if (!dir.exists()) dir.mkdirs()
return dir
}

actual fun getScormApiPath(): String {
val apiDir = File(baseDir, "api")
if (!apiDir.exists()) apiDir.mkdirs()

val apiFile = File(apiDir, "scorm-again.js")
if (!apiFile.exists()) {
context.assets.open(SCORM_API_ASSET).use { input ->
FileOutputStream(apiFile).use { output ->
input.copyTo(output)
}
}
}

return "file://${apiFile.absolutePath}"
}

actual fun getCourseDirectory(courseId: String): String {
val courseDir = File(baseDir, "courses/$courseId")
if (!courseDir.exists()) courseDir.mkdirs()
return courseDir.absolutePath
}

actual fun getCourseUrl(courseId: String): String {
return "file://${getCourseDirectory(courseId)}/index.html"
}

actual fun isCourseAvailable(courseId: String): Boolean {
val indexFile = File(getCourseDirectory(courseId), "index.html")
return indexFile.exists()
}
}
// shared/src/iosMain/kotlin/storage/FileManager.ios.kt
package storage

import platform.Foundation.NSBundle
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask

actual class FileManager {

private val fileManager = NSFileManager.defaultManager

private val documentsDirectory: String
get() {
val paths = NSFileManager.defaultManager.URLsForDirectory(
NSDocumentDirectory,
NSUserDomainMask
)
return (paths.firstOrNull() as? NSURL)?.path ?: ""
}

private val scormBaseDir: String
get() {
val dir = "$documentsDirectory/ScormContent"
if (!fileManager.fileExistsAtPath(dir)) {
fileManager.createDirectoryAtPath(dir, true, null, null)
}
return dir
}

actual fun getScormApiPath(): String {
// Copy from bundle to documents if needed
val apiDir = "$scormBaseDir/api"
if (!fileManager.fileExistsAtPath(apiDir)) {
fileManager.createDirectoryAtPath(apiDir, true, null, null)
}

val apiFile = "$apiDir/scorm-again.js"
if (!fileManager.fileExistsAtPath(apiFile)) {
val bundlePath = NSBundle.mainBundle.pathForResource(
"scorm-again/api/scorm-again",
"js"
)
bundlePath?.let {
fileManager.copyItemAtPath(it, apiFile, null)
}
}

return "file://$apiFile"
}

actual fun getCourseDirectory(courseId: String): String {
val courseDir = "$scormBaseDir/courses/$courseId"
if (!fileManager.fileExistsAtPath(courseDir)) {
fileManager.createDirectoryAtPath(courseDir, true, null, null)
}
return courseDir
}

actual fun getCourseUrl(courseId: String): String {
return "file://${getCourseDirectory(courseId)}/index.html"
}

actual fun isCourseAvailable(courseId: String): Boolean {
val indexPath = "${getCourseDirectory(courseId)}/index.html"
return fileManager.fileExistsAtPath(indexPath)
}
}

Platform-Specific Usage

Android App

// androidApp/src/main/java/.../ScormPlayerActivity.kt
package com.example.scormplayer.android

import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import connectivity.ConnectivityObserverFactory
import db.DatabaseDriverFactory
import db.ScormDataRepository
import scorm.ScormPlayerManager
import storage.FileManager
import web.WebViewDelegate
import web.WebViewFactory

class ScormPlayerActivity : ComponentActivity() {

private lateinit var scormManager: ScormPlayerManager
private val courseId = "course1"

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Initialize dependencies
val fileManager = FileManager(this)
val connectivityFactory = ConnectivityObserverFactory(this)
val dbFactory = DatabaseDriverFactory(this)
val repository = ScormDataRepository(dbFactory)
val webViewFactory = WebViewFactory(this)

// Create WebView with delegate
val webViewController = webViewFactory.create(object : WebViewDelegate {
override fun onPageFinished(url: String) {
scormManager.injectScormAgain()
}

override fun onError(error: String) {
Toast.makeText(this@ScormPlayerActivity, error, Toast.LENGTH_LONG).show()
}
})

// Set content view with the WebView
val androidWebView = (webViewController as? web.AndroidWebViewController)?.webView
setContentView(androidWebView)

// Initialize SCORM manager
scormManager = ScormPlayerManager(
webViewController = webViewController,
connectivityObserver = connectivityFactory.create(),
repository = repository,
courseId = courseId,
apiPath = fileManager.getScormApiPath(),
scope = lifecycleScope
)

// Set up callbacks
scormManager.setOnSyncComplete { success ->
val message = if (success) "Data synced successfully" else "Sync failed"
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

scormManager.setOnLogMessage { message, level ->
if (level >= 3) { // Error or warning
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}

// Load course
if (fileManager.isCourseAvailable(courseId)) {
webViewController.loadUrl(fileManager.getCourseUrl(courseId))
} else {
Toast.makeText(this, "Course not found", Toast.LENGTH_LONG).show()
}
}
}

iOS App (SwiftUI)

// iosApp/iosApp/ScormPlayerView.swift
import SwiftUI
import WebKit
import shared

struct ScormPlayerView: View {
@StateObject private var viewModel = ScormPlayerViewModel()

var body: some View {
VStack {
HStack {
Text("SCORM Player")
.font(.headline)
Spacer()
Image(systemName: viewModel.isOnline ? "wifi" : "wifi.slash")
Button(action: { viewModel.syncData() }) {
Image(systemName: "arrow.triangle.2.circlepath")
}
.disabled(!viewModel.isOnline)
}
.padding()

WebViewWrapper(webView: viewModel.webView)
}
.onAppear {
viewModel.loadCourse()
}
.alert("Sync Status", isPresented: $viewModel.showSyncAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(viewModel.syncMessage)
}
}
}

class ScormPlayerViewModel: ObservableObject {
@Published var isOnline = true
@Published var showSyncAlert = false
@Published var syncMessage = ""

let courseId = "course1"

private let fileManager = FileManager_()
private let connectivityFactory = ConnectivityObserverFactory()
private let dbFactory = DatabaseDriverFactory()
private var scormManager: ScormPlayerManager?

private(set) var webView: WKWebView

init() {
let repository = ScormDataRepository(driverFactory: dbFactory)

let delegate = WebViewDelegateImpl()
let webViewController = WebViewFactory().create(delegate: delegate)
self.webView = (webViewController as! IOSWebViewController).webView

self.scormManager = ScormPlayerManager(
webViewController: webViewController,
connectivityObserver: connectivityFactory.create(),
repository: repository,
courseId: courseId,
apiPath: fileManager.getScormApiPath(),
scope: MainScope()
)

delegate.onPageFinished = { [weak self] _ in
self?.scormManager?.injectScormAgain()
}

scormManager?.setOnSyncComplete { [weak self] success in
DispatchQueue.main.async {
self?.syncMessage = success ? "Data synced successfully" : "Sync failed"
self?.showSyncAlert = true
}
}
}

func loadCourse() {
if fileManager.isCourseAvailable(courseId: courseId) {
let url = fileManager.getCourseUrl(courseId: courseId)
if let nsUrl = URL(string: url) {
webView.load(URLRequest(url: nsUrl))
}
}
}

func syncData() {
scormManager?.syncOfflineData()
}
}

class WebViewDelegateImpl: WebViewDelegate {
var onPageFinished: ((String) -> Void)?

func onPageFinished(url: String) {
onPageFinished?(url)
}

func onError(error: String) {
print("WebView error: \(error)")
}
}

struct WebViewWrapper: UIViewRepresentable {
let webView: WKWebView

func makeUIView(context: Context) -> WKWebView {
return webView
}

func updateUIView(_ uiView: WKWebView, context: Context) {}
}

For best compatibility with SCORM packages, serve content through a local HTTP server rather than using file:// URLs directly. This avoids CORS issues and ensures all JavaScript APIs work correctly.

Android Local Server

Use NanoHTTPD or AndroidAsync:

// Add to build.gradle
implementation("org.nanohttpd:nanohttpd:2.3.1")
class LocalContentServer(
port: Int,
private val rootDir: File
) : NanoHTTPD(port) {

override fun serve(session: IHTTPSession): Response {
val uri = session.uri.removePrefix("/")
val file = File(rootDir, uri)

return if (file.exists() && file.isFile) {
val mimeType = getMimeType(file.name)
newFixedLengthResponse(
Response.Status.OK,
mimeType,
FileInputStream(file),
file.length()
)
} else {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
}
}

private fun getMimeType(fileName: String): String = when {
fileName.endsWith(".html") -> "text/html"
fileName.endsWith(".js") -> "application/javascript"
fileName.endsWith(".css") -> "text/css"
fileName.endsWith(".json") -> "application/json"
else -> "application/octet-stream"
}
}

iOS Local Server

Use GCDWebServer via CocoaPods or Swift Package Manager:

import GCDWebServer

class LocalContentServer {
private let server = GCDWebServer()

func start(rootPath: String) -> Int {
server.addGETHandler(
forBasePath: "/",
directoryPath: rootPath,
indexFilename: "index.html",
cacheAge: 0,
allowRangeRequests: true
)

try? server.start(options: [
GCDWebServerOption_Port: 0,
GCDWebServerOption_BindToLocalhost: true
])

return Int(server.port)
}

func stop() {
server.stop()
}

var baseURL: String {
return "http://localhost:\(server.port)"
}
}

scorm-again Configuration

The scorm-again API is initialized with these key settings for offline support:

{
enableOfflineSupport: true, // Enable offline capabilities
courseId: 'courseId', // Unique identifier for the course
autocommit: true, // Automatically commit data
autocommitSeconds: 60, // Commit interval in seconds
syncOnInitialize: true, // Try to sync when the API initializes
syncOnTerminate: true, // Try to sync when the API terminates
logLevel: 4, // Detailed logging for debugging
}

Key Features

1. Shared Business Logic

  • Network connectivity monitoring works identically across platforms
  • SCORM data persistence uses SQLDelight for cross-platform database access
  • Synchronization logic is shared, reducing code duplication

2. Platform-Native WebViews

  • Android uses native WebView with full JavaScript bridge support
  • iOS uses WKWebView with WKScriptMessageHandler for communication
  • Both implementations provide the same interface to shared code

3. Offline-First Architecture

  • Data is stored locally using SQLDelight before syncing
  • Network status changes trigger automatic sync attempts
  • Offline data is preserved across app restarts

4. Type-Safe Communication

  • Kotlin serialization ensures type safety for JavaScript bridge messages
  • Shared data models work across all platforms

Advanced Topics

Using Compose Multiplatform for UI

If using Compose Multiplatform, you can create a shared composable:

// shared/src/commonMain/kotlin/ui/ScormPlayerScreen.kt
@Composable
expect fun ScormWebView(
url: String,
onPageFinished: () -> Unit,
modifier: Modifier = Modifier
)

Dependency Injection with Koin

Set up Koin for cross-platform DI:

// shared/src/commonMain/kotlin/di/KoinModule.kt
val sharedModule = module {
single { ScormDataRepository(get()) }
factory { (courseId: String) ->
ScormPlayerManager(
webViewController = get(),
connectivityObserver = get(),
repository = get(),
courseId = courseId,
apiPath = get<FileManager>().getScormApiPath()
)
}
}

Security Considerations

1. Content Validation

// shared/src/commonMain/kotlin/storage/ScormValidator.kt
object ScormValidator {
fun validatePackage(courseDir: String): Boolean {
// Check for required SCORM files
val requiredFiles = listOf("index.html", "imsmanifest.xml")
return requiredFiles.all { file ->
// Platform-specific file existence check
true // Implement per platform
}
}
}

2. WebView Security

Restrict WebView navigation to local content only:

// In Android WebViewClient
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
return url?.startsWith("file://") != true
}

Troubleshooting

Common Issues

  1. WebView not loading local files

    • Ensure file permissions are properly configured on Android
    • Verify file paths are correct for each platform
    • Check that allowFileAccessFromFileURLs is enabled on Android
  2. JavaScript bridge not working

    • Verify interface names match between Kotlin and JavaScript
    • On iOS, ensure WKUserContentController is properly configured
    • Check for threading issues (UI updates must be on main thread)
  3. SQLDelight schema errors

    • Run ./gradlew generateSqlDelightInterface after schema changes
    • Verify database version migrations are handled
  4. iOS WKWebView content not loading

    • Add App Transport Security exceptions for local content
    • Verify bundle resources are correctly copied

Critical: SCORM API Not Found ("Unable to find an API adapter")

Many SCORM courses use a standard API discovery algorithm that searches window.parent for the API. In a WebView, window.parent === window, which causes the search to fail. Additionally, many courses declare var API = null; at global scope, overwriting any API you've set.

Solution: Inject JavaScript early before content loads to set up APIs and override window.parent:

Android

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

val jsCode = """
// Initialize both SCORM APIs
var apiSettings = { autocommit: true, logLevel: 4 };

// SCORM 2004
window.API_1484_11 = new window.Scorm2004API(apiSettings);

// SCORM 1.2 with getter protection (prevents 'var API = null' overwrite)
var scorm12Instance = new window.Scorm12API(apiSettings);
window._scorm12APIInstance = scorm12Instance;
Object.defineProperty(window, 'API', {
get: function() { return window._scorm12APIInstance; },
set: function(val) { /* ignore */ },
configurable: false
});

// Override window.parent for API discovery
Object.defineProperty(window, 'parent', {
get: function() {
return {
API_1484_11: window.API_1484_11,
API: window._scorm12APIInstance,
parent: null
};
},
configurable: true
});
""".trimIndent()

view?.evaluateJavascript(jsCode, null)
}
}

iOS

// Add user script to inject early
let script = WKUserScript(
source: """
// Initialize both SCORM APIs
var apiSettings = { autocommit: true, logLevel: 4 };

// SCORM 2004
window.API_1484_11 = new window.Scorm2004API(apiSettings);

// SCORM 1.2 with getter protection
var scorm12Instance = new window.Scorm12API(apiSettings);
window._scorm12APIInstance = scorm12Instance;
Object.defineProperty(window, 'API', {
get: function() { return window._scorm12APIInstance; },
set: function(val) { },
configurable: false
});

// Override window.parent
Object.defineProperty(window, 'parent', {
get: function() {
return {
API_1484_11: window.API_1484_11,
API: window._scorm12APIInstance,
parent: null
};
},
configurable: true
});
""",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)

configuration.userContentController.addUserScript(script)

Important: Use the full scorm-again.min.js bundle (not scorm2004.min.js) to support both SCORM 1.2 and SCORM 2004 courses.

This pattern is critical for WebView environments where the standard API discovery algorithm fails.

Alerts Not Displaying

WebView dialogs may not show natively. Forward them to the native layer:

window.alert = function(msg) {
window.ScormBridge.postMessage(JSON.stringify({
type: 'alert', message: String(msg)
}));
};

Handle in your JavaScript bridge callback to show native alerts.

Conclusion

Kotlin Multiplatform provides an excellent foundation for building cross-platform SCORM players with offline support. By sharing business logic, network monitoring, and data persistence while using platform-native WebViews, you can deliver a consistent learning experience across Android and iOS with significantly reduced code duplication.

The architecture outlined in this guide follows KMP best practices, using expect/actual declarations for platform-specific implementations while keeping the core SCORM handling logic shared. Combined with scorm-again's offline capabilities, this approach enables robust mobile learning experiences regardless of network connectivity.

References