Skip to main content

Using scorm-again with Native Android for Offline Learning

This guide demonstrates how to implement SCORM content in a native Android application with offline support using scorm-again.

Prerequisites

  • Android Studio
  • Basic knowledge of Android development
  • Understanding of SCORM packages

Project Setup

1. Configure Your Android Project

Create a new Android project or use an existing one. Make sure your build.gradle file includes the necessary dependencies:

// app/build.gradle
dependencies {
// WebView dependencies
implementation 'androidx.webkit:webkit:1.8.0'

// For network connectivity monitoring
implementation 'androidx.core:core-ktx:1.12.0'

// Zip handling (for extracting SCORM packages)
implementation 'net.lingala.zip4j:zip4j:2.11.5'

// Lifecycle components
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
}

2. Add Necessary Permissions

Modify your AndroidManifest.xml to include permissions for internet access and external storage:

<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- For API level 28 and below -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<application
...
android:requestLegacyExternalStorage="true">
...
</application>
</manifest>

3. Create Directory Structure for Assets

Place the scorm-again.js file in your assets folder:

/app/src/main/assets
/scorm-again
/api
scorm-again.js

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.

Use NanoHTTPD or AndroidAsync to serve content over HTTP:

import fi.iki.elonen.NanoHTTPD

class LocalServer(port: Int, private val rootDir: File) : NanoHTTPD(port) {
override fun serve(session: IHTTPSession): Response {
val uri = session.uri
val file = File(rootDir, uri)
val mimeType = getMimeTypeForFile(uri)
return newFixedLengthResponse(Response.Status.OK, mimeType, FileInputStream(file), file.length())
}
}

val server = LocalServer(0, documentsDir)
server.start()
val courseUrl = "http://127.0.0.1:${server.listeningPort}/courses/${courseId}/index.html"

This approach resolves most SCORM compatibility issues in WebView environments.

Implementation

1. Create a Network Connectivity Monitor

This class will help track the device's online/offline status:

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

class NetworkConnectivityMonitor(private val context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

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

fun observeNetworkConnectivity(): Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}

override fun onLost(network: Network) {
trySend(false)
}
}

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

connectivityManager.registerNetworkCallback(request, callback)

// Initial value
trySend(isNetworkAvailable())

awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}

2. Create a SCORM File Manager

This class handles file operations for your SCORM content:

import android.content.Context
import android.os.Environment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.lingala.zip4j.ZipFile
import java.io.File
import java.io.FileOutputStream

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

// Base directory for storing SCORM content
fun getScormBaseDirectory(): File {
val baseDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
EXTERNAL_SCORM_DIR
)
if (!baseDir.exists()) {
baseDir.mkdirs()
}
return baseDir
}

// Directory for a specific course
fun getCourseDirectory(courseId: String): File {
val courseDir = File(getScormBaseDirectory(), courseId)
if (!courseDir.exists()) {
courseDir.mkdirs()
}
return courseDir
}

// Get the API path
fun getScormApiPath(): String {
val apiFile = copyScormApiToExternal()
return "file://${apiFile.absolutePath}"
}

// Get the course URL for WebView loading
fun getCourseUrl(courseId: String): String {
return "file://${getCourseDirectory(courseId).absolutePath}/index.html"
}

// Copy the SCORM API from assets to external storage
private fun copyScormApiToExternal(): File {
val apiDir = File(getScormBaseDirectory(), "api")
if (!apiDir.exists()) {
apiDir.mkdirs()
}

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

return apiFile
}

// Extract a SCORM package to the course directory
suspend fun extractScormPackage(courseId: String, zipFilePath: String): Boolean = withContext(Dispatchers.IO) {
try {
val courseDir = getCourseDirectory(courseId)
// Clear existing content if needed
if (courseDir.exists() && courseDir.listFiles()?.isNotEmpty() == true) {
courseDir.deleteRecursively()
courseDir.mkdirs()
}

// Extract the zip file
ZipFile(zipFilePath).extractAll(courseDir.absolutePath)

// Verify extraction worked (check for index.html)
return@withContext File(courseDir, "index.html").exists()
} catch (e: Exception) {
e.printStackTrace()
return@withContext false
}
}

// Check if a course is already extracted
fun isCourseExtracted(courseId: String): Boolean {
val courseDir = getCourseDirectory(courseId)
return courseDir.exists() &&
File(courseDir, "index.html").exists()
}
}

3. Create a SCORM WebView Wrapper

This class handles the WebView configuration and JavaScript interactions:

import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.json.JSONObject

class ScormWebViewWrapper(
private val context: Context,
private val webView: WebView,
private val lifecycle: Lifecycle
) : LifecycleObserver {

init {
lifecycle.addObserver(this)
setupWebView()
}

private val messageFlow = callbackFlow<ScormMessage> {
val jsInterface = object {
@JavascriptInterface
fun postMessage(message: String) {
try {
val jsonMessage = JSONObject(message)
when (jsonMessage.getString("type")) {
"log" -> {
trySend(
ScormMessage.Log(
jsonMessage.getString("message"),
jsonMessage.getInt("level")
)
)
}
"sync" -> {
trySend(
ScormMessage.Sync(
jsonMessage.getString("status") == "success"
)
)
}
else -> trySend(ScormMessage.Error("Unknown message type"))
}
} catch (e: Exception) {
trySend(ScormMessage.Error(e.message ?: "Unknown error"))
}
}
}

webView.addJavascriptInterface(jsInterface, "AndroidBridge")

awaitClose {
webView.removeJavascriptInterface("AndroidBridge")
}
}

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

webView.webChromeClient = WebChromeClient()
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// You can add additional functionality here when the page loads
}
}
}

fun loadCourse(courseUrl: String) {
webView.loadUrl(courseUrl)
}

fun injectScormAgain(apiPath: String, courseId: String, isOnline: Boolean) {
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.AndroidBridge.postMessage(JSON.stringify({
type: 'log',
message: message,
level: level
}));
}
});

window.API.on('OfflineDataSynced', function() {
window.AndroidBridge.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()

webView.evaluateJavascript(js, null)
}

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

webView.evaluateJavascript(js, null)
}

fun getMessages(): Flow<ScormMessage> = messageFlow

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun cleanup() {
webView.loadUrl("about:blank")
}
}

sealed class ScormMessage {
data class Log(val message: String, val level: Int) : ScormMessage()
data class Sync(val success: Boolean) : ScormMessage()
data class Error(val message: String) : ScormMessage()
}

4. Create a ViewModel for SCORM Player

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

class ScormPlayerViewModel(
application: Application,
private val courseId: String
) : AndroidViewModel(application) {

private val fileManager = ScormFileManager(application)
private val networkMonitor = NetworkConnectivityMonitor(application)

private val _state = MutableStateFlow<ScormPlayerState>(ScormPlayerState.Loading)
val state: StateFlow<ScormPlayerState> = _state

private val _isOnline = MutableStateFlow(true)
val isOnline: StateFlow<Boolean> = _isOnline

init {
viewModelScope.launch {
// Monitor network connectivity
networkMonitor.observeNetworkConnectivity().collect { isConnected ->
_isOnline.value = isConnected
}
}

// Initialize the course
viewModelScope.launch {
initializeScormContent()
}
}

private suspend fun initializeScormContent() {
_state.value = ScormPlayerState.Loading

try {
// Check if course is already extracted
if (fileManager.isCourseExtracted(courseId)) {
val apiPath = fileManager.getScormApiPath()
val coursePath = fileManager.getCourseUrl(courseId)

_state.value = ScormPlayerState.Ready(
apiPath = apiPath,
coursePath = coursePath
)
} else {
_state.value = ScormPlayerState.NeedsDownload
}
} catch (e: Exception) {
_state.value = ScormPlayerState.Error(e.message ?: "Unknown error")
}
}

fun extractCourse(zipFilePath: String) {
viewModelScope.launch {
_state.value = ScormPlayerState.Loading

val extractResult = fileManager.extractScormPackage(courseId, zipFilePath)

if (extractResult) {
val apiPath = fileManager.getScormApiPath()
val coursePath = fileManager.getCourseUrl(courseId)

_state.value = ScormPlayerState.Ready(
apiPath = apiPath,
coursePath = coursePath
)
} else {
_state.value = ScormPlayerState.Error("Failed to extract SCORM package")
}
}
}

fun syncOfflineData(webView: ScormWebViewWrapper) {
if (_isOnline.value) {
webView.syncOfflineData()
}
}
}

sealed class ScormPlayerState {
object Loading : ScormPlayerState()
object NeedsDownload : ScormPlayerState()
data class Ready(val apiPath: String, val coursePath: String) : ScormPlayerState()
data class Error(val message: String) : ScormPlayerState()
}

5. Create the SCORM Player Activity

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

class ScormPlayerActivity : AppCompatActivity() {

companion object {
private const val PERMISSIONS_REQUEST_CODE = 100
private const val COURSE_ID_EXTRA = "course_id"
private const val ZIP_PATH_EXTRA = "zip_path"
}

private lateinit var webView: WebView
private lateinit var scormWebViewWrapper: ScormWebViewWrapper

private val viewModel: ScormPlayerViewModel by viewModels {
val courseId = intent.getStringExtra(COURSE_ID_EXTRA) ?: "default_course"
ScormPlayerViewModelFactory(application, courseId)
}

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

// Set the layout
setContentView(R.layout.activity_scorm_player)

// Request permissions if needed
requestPermissionsIfNeeded()

// Initialize WebView
webView = findViewById(R.id.webView)
scormWebViewWrapper = ScormWebViewWrapper(this, webView, lifecycle)

// Observe ViewModel states
observeViewModelState()

// Extract the course if zip path was provided
intent.getStringExtra(ZIP_PATH_EXTRA)?.let { zipPath ->
viewModel.extractCourse(zipPath)
}

// Observe WebView messages
lifecycleScope.launch {
scormWebViewWrapper.getMessages().collect { message ->
handleScormMessage(message)
}
}
}

private fun handleScormMessage(message: ScormMessage) {
when (message) {
is ScormMessage.Log -> {
// Log SCORM messages
if (message.level >= 3) { // Error or warning
Toast.makeText(this, message.message, Toast.LENGTH_SHORT).show()
}
}
is ScormMessage.Sync -> {
val messageText = if (message.success) {
"SCORM data synchronized successfully"
} else {
"Failed to synchronize some SCORM data"
}
Toast.makeText(this, messageText, Toast.LENGTH_SHORT).show()
}
is ScormMessage.Error -> {
Toast.makeText(this, message.message, Toast.LENGTH_SHORT).show()
}
}
}

private fun observeViewModelState() {
lifecycleScope.launch {
viewModel.state.collect { state ->
when (state) {
is ScormPlayerState.Loading -> {
// Show loading indicator
}
is ScormPlayerState.NeedsDownload -> {
// Show download prompt or handle as needed
// For this example, we assume the ZIP is provided via intent
}
is ScormPlayerState.Ready -> {
// Load the course
scormWebViewWrapper.loadCourse(state.coursePath)

// Wait for page to load, then inject scorm-again
webView.webViewClient = object : android.webkit.WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)

viewLifecycleScope.launch {
viewModel.isOnline.collect { isOnline ->
// Inject scorm-again with current online status
scormWebViewWrapper.injectScormAgain(
apiPath = state.apiPath,
courseId = viewModel.courseId,
isOnline = isOnline
)

// Try to sync when back online
if (isOnline) {
scormWebViewWrapper.syncOfflineData()
}
}
}
}
}
}
is ScormPlayerState.Error -> {
Toast.makeText(this@ScormPlayerActivity, state.message, Toast.LENGTH_LONG).show()
}
}
}
}
}

private fun requestPermissionsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
),
PERMISSIONS_REQUEST_CODE
)
}
}
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_CODE && grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permissions granted, reload the content
viewModel.initializeScormContent()
} else {
// Permissions denied, show error
Toast.makeText(
this,
"Storage permissions are required for offline SCORM content",
Toast.LENGTH_LONG
).show()
finish()
}
}
}

class ScormPlayerViewModelFactory(
private val application: Application,
private val courseId: String
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ScormPlayerViewModel::class.java)) {
return ScormPlayerViewModel(application, courseId) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

6. Create the Layout XML

<!-- res/layout/activity_scorm_player.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<WebView
android:id="@+id/webView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Advanced Usage

1. Handling Downloaded SCORM Packages

To handle downloaded packages, create a download manager:

import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File

class ScormDownloadManager(private val context: Context) {
private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager

private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.Idle)
val downloadState: StateFlow<DownloadState> = _downloadState

private var downloadId: Long = -1
private var courseId: String = ""

// Broadcast receiver to listen for download completion
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id == downloadId) {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)

if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = cursor.getInt(statusIndex)

when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val downloadedFileUri = cursor.getString(uriIndex)
val file = File(Uri.parse(downloadedFileUri).path!!)

_downloadState.value = DownloadState.Completed(
courseId = courseId,
filePath = file.absolutePath
)
}
DownloadManager.STATUS_FAILED -> {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = cursor.getInt(reasonIndex)
_downloadState.value = DownloadState.Failed("Download failed: $reason")
}
}
}
cursor.close()

// Unregister after handling
context?.unregisterReceiver(this)
}
}
}

fun downloadScormPackage(courseId: String, downloadUrl: String) {
this.courseId = courseId

// Register receiver for download completion
context.registerReceiver(
downloadReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)

// Create download request
val request = DownloadManager.Request(Uri.parse(downloadUrl)).apply {
setTitle("Downloading SCORM Course: $courseId")
setDescription("Downloading learning content")
setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
"$courseId.zip"
)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
}

// Start download
downloadId = downloadManager.enqueue(request)
_downloadState.value = DownloadState.InProgress(downloadId)
}

// Cancel ongoing download
fun cancelDownload() {
if (downloadId != -1L) {
downloadManager.remove(downloadId)
_downloadState.value = DownloadState.Cancelled
}
}
}

sealed class DownloadState {
object Idle : DownloadState()
data class InProgress(val downloadId: Long) : DownloadState()
data class Completed(val courseId: String, val filePath: String) : DownloadState()
data class Failed(val reason: String) : DownloadState()
object Cancelled : DownloadState()
}

2. Handling Scoped Storage (Android 10+)

For Android 10 and above, modify your code to comply with scoped storage:

import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi

@RequiresApi(Build.VERSION_CODES.Q)
fun createScormDirectoryWithScopedStorage(context: Context, courseId: String): Uri? {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, courseId)
put(MediaStore.MediaColumns.MIME_TYPE, "application/zip")
put(MediaStore.MediaColumns.RELATIVE_PATH, "Documents/ScormContent")
}

return context.contentResolver.insert(
MediaStore.Files.getContentUri("external"),
contentValues
)
}

3. Enhanced File Management with Storage Access Framework

For more user control, implement SAF (Storage Access Framework):

import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts

// In your activity:
private val createDocumentLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
// Save URI permission for future access
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)

// Use the URI for file operations
extractScormPackageToUri(uri, zipFilePath)
}
}
}

// Launch file picker
fun selectDestinationForScormPackage(courseId: String) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
putExtra(Intent.EXTRA_TITLE, "$courseId.zip")
}
createDocumentLauncher.launch(intent)
}

SCORM API Configuration

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

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

Performance Considerations

1. Memory Management

  • Implement proper lifecycle management for WebView
  • Handle configuration changes appropriately
  • Dispose of resources when the activity is destroyed
override fun onDestroy() {
super.onDestroy()
webView.destroy()
}

2. WebView Configuration Optimization

For better performance, consider these additional WebView settings:

webView.settings.apply {
// Cache optimization
cacheMode = WebSettings.LOAD_DEFAULT

// Limit DOM storage size for large courses
setDomStorageEnabled(true)

// Disable features not needed
blockNetworkImage = false
loadsImagesAutomatically = true

// Hardware acceleration
setRenderPriority(WebSettings.RenderPriority.HIGH)
}

3. Large File Handling

For large SCORM packages, use chunked file operations:

private suspend fun unzipLargeFile(zipFilePath: String, destinationPath: String) = withContext(Dispatchers.IO) {
val buffer = ByteArray(1024 * 8) // 8KB buffer

ZipFile(zipFilePath).use { zipFile ->
val entries = zipFile.entries
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
val entryPath = File(destinationPath, entry.name)

// Create parent directories if needed
entryPath.parentFile?.mkdirs()

if (!entry.isDirectory) {
zipFile.getInputStream(entry).use { input ->
FileOutputStream(entryPath).use { output ->
var len: Int
while (input.read(buffer).also { len = it } > 0) {
output.write(buffer, 0, len)
// Allow cancellation check here
yield()
}
}
}
}
}
}
}

Security Considerations

1. Content Validation

Always validate SCORM packages before loading them:

fun validateScormPackage(courseDir: File): Boolean {
val manifestFile = File(courseDir, "imsmanifest.xml")
val indexFile = File(courseDir, "index.html")

if (!manifestFile.exists() || !indexFile.exists()) {
return false
}

try {
// Parse the manifest to validate SCORM version and structure
val manifestContent = manifestFile.readText()
return manifestContent.contains("adlcp:scormType") ||
manifestContent.contains("imsss:sequencing")
} catch (e: Exception) {
return false
}
}

2. WebView Security

Implement additional security measures for WebView:

// Restrict WebView to only access the course directory
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
url?.let {
if (!it.startsWith("file:///") ||
!it.contains(courseId)) {
// Block navigation to external resources
return true
}
}
return false
}
}

Troubleshooting

Common Issues and Solutions

  1. WebView not loading local files

    • Check file permissions
    • Verify correct file:// path formatting
    • Ensure WebView settings allow file access
  2. JavaScript communication problems

    • Verify JavascriptInterface is properly added
    • Check that method has @JavascriptInterface annotation
    • Ensure method parameters match JavaScript call
  3. Storage permission issues

    • For Android 6+: Request permissions at runtime
    • For Android 10+: Use Storage Access Framework
    • For Android 11+: Consider using MediaStore API
  4. Offline data not syncing

    • Check network connectivity monitoring
    • Verify scorm-again configurations
    • Inspect JavaScript console logs for errors

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: Override onPageStarted in WebViewClient to inject JavaScript early:

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

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

Android WebView may suppress JavaScript dialogs. Forward them to native:

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

Handle in your @JavascriptInterface method to show a Toast or AlertDialog.

Conclusion

This native Android implementation provides a robust solution for implementing SCORM content with offline support. By utilizing the Android WebView with the scorm-again library and implementing proper file and network management, you can deliver a seamless learning experience even when users are offline. The implementation handles course downloading, storage, and synchronization in a way that works across different Android versions and respects modern storage requirements.