Using scorm-again with React Native for Offline Learning
Note: A complete working example is available at
examples/react-native-offline/in the scorm-again repository.
This guide demonstrates how to implement SCORM content in a React Native application with offline support using scorm-again.
Prerequisites
- React Native development environment set up
- Basic knowledge of React Native
- Understanding of SCORM packages
Setup
1. Install Dependencies
First, install the necessary dependencies:
# Install core dependencies
npm install --save react-native-webview react-native-fs react-native-device-info react-native-zip-archive
# For handling network connectivity
npm install --save @react-native-community/netinfo
# For external storage access
npm install --save react-native-permissions react-native-blob-util
2. Project Structure
Create a directory structure for storing SCORM content:
/assets
/scorm-again
/api
scorm-again.js (Copy the scorm-again.js here)
/courses
/course1
(Unzipped SCORM content or course zip file)
3. Loading From External Storage (Recommended)
For production applications, it's recommended to load SCORM modules from external/shared storage rather than bundling them with your app. This approach offers several advantages:
- Allows for larger course content beyond app size limitations
- Enables sharing courses between applications
- Makes content management more flexible for users
- Preserves content during app updates or reinstallation
Configure Required Permissions
First, set up the necessary permissions:
Android: Add to android/app/src/main/AndroidManifest.xml:
<manifest ...>
<!-- For all Android versions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- For Android 10 (API 29) and below -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- For Android 11+ specific directories -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:requestLegacyExternalStorage="true"
...>
...
</application>
</manifest>
iOS: Add to ios/YourAppName/Info.plist:
<key>NSDocumentsFolderUsageDescription</key>
<string>Access to documents is required to store and load SCORM courses</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>Access to downloads is required to import SCORM courses</string>
Implement External Storage Service
Create a utility service for managing SCORM content in external storage:
// src/services/ExternalStorageService.js
import RNFS from 'react-native-fs';
import { Platform } from 'react-native';
import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions';
import { unzip } from 'react-native-zip-archive';
import RNBlobUtil from 'react-native-blob-util';
class ExternalStorageService {
constructor() {
this.baseStoragePath = null;
this.initialized = false;
}
async initialize() {
if (this.initialized) return true;
try {
// Request permissions first
await this.requestStoragePermission();
// Set up base directory path based on platform
if (Platform.OS === 'android') {
// Use Download directory on Android
// Note: On Android 10+ (API 29+), use app-specific directory for better compatibility
// RNFS.ExternalStorageDirectoryPath may not be accessible on newer Android versions
this.baseStoragePath = `${RNFS.DownloadDirectoryPath}/ScormContent`;
} else if (Platform.OS === 'ios') {
// Use Documents directory on iOS
this.baseStoragePath = `${RNFS.DocumentDirectoryPath}/ScormContent`;
} else {
throw new Error('Unsupported platform');
}
// Create base directory if it doesn't exist
const exists = await RNFS.exists(this.baseStoragePath);
if (!exists) {
await RNFS.mkdir(this.baseStoragePath);
}
this.initialized = true;
return true;
} catch (error) {
console.error('Failed to initialize external storage:', error);
return false;
}
}
async requestStoragePermission() {
if (Platform.OS === 'android') {
const apiLevel = parseInt(Platform.Version, 10);
if (apiLevel >= 30) { // Android 11+
// For Android 11+, we need to request MANAGE_EXTERNAL_STORAGE
// Note: This requires special handling - the user needs to
// go to a system settings page to grant this permission
const result = await check(PERMISSIONS.ANDROID.MANAGE_EXTERNAL_STORAGE);
if (result !== RESULTS.GRANTED) {
// Ideally, guide user to system settings here
throw new Error('Storage permission required for Android 11+');
}
} else {
// For Android 10 and below, request READ/WRITE_EXTERNAL_STORAGE
const readResult = await request(PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE);
const writeResult = await request(PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE);
if (readResult !== RESULTS.GRANTED || writeResult !== RESULTS.GRANTED) {
throw new Error('Storage permissions required');
}
}
} else if (Platform.OS === 'ios') {
// iOS doesn't need explicit permissions for app Documents directory
return true;
}
return true;
}
async getCourseDirectory(courseId) {
await this.initialize();
const courseDir = `${this.baseStoragePath}/${courseId}`;
const exists = await RNFS.exists(courseDir);
if (!exists) {
await RNFS.mkdir(courseDir);
}
return courseDir;
}
async extractCourse(zipFilePath, courseId) {
try {
const courseDir = await this.getCourseDirectory(courseId);
// Extract the course zip file
await unzip(zipFilePath, courseDir);
// Verify that the extracted content is valid
const indexPath = `${courseDir}/index.html`;
const indexExists = await RNFS.exists(indexPath);
if (!indexExists) {
throw new Error('Invalid SCORM package: No index.html found');
}
return courseDir;
} catch (error) {
console.error('Failed to extract course:', error);
throw error;
}
}
async downloadAndExtractCourse(courseId, downloadUrl) {
try {
await this.initialize();
// Create a temporary download path
const tempZipPath = `${RNFS.CachesDirectoryPath}/${courseId}.zip`;
// Download the file
const response = await RNBlobUtil.config({
fileCache: true,
path: tempZipPath,
}).fetch('GET', downloadUrl);
// Extract the course
const courseDir = await this.extractCourse(tempZipPath, courseId);
// Clean up the zip file
await RNFS.unlink(tempZipPath);
return courseDir;
} catch (error) {
console.error('Failed to download and extract course:', error);
throw error;
}
}
async listAvailableCourses() {
try {
await this.initialize();
const courses = [];
const items = await RNFS.readDir(this.baseStoragePath);
for (const item of items) {
if (item.isDirectory()) {
// Check if it contains an index.html file
const indexPath = `${item.path}/index.html`;
const indexExists = await RNFS.exists(indexPath);
if (indexExists) {
courses.push({
id: item.name,
path: item.path,
url: `file://${item.path}/index.html`,
});
}
}
}
return courses;
} catch (error) {
console.error('Failed to list courses:', error);
return [];
}
}
getCourseUrl(coursePath) {
return `file://${coursePath}/index.html`;
}
async deleteCourse(courseId) {
try {
const courseDir = await this.getCourseDirectory(courseId);
await RNFS.unlink(courseDir);
return true;
} catch (error) {
console.error('Failed to delete course:', error);
return false;
}
}
}
export default new ExternalStorageService();
Modify the SCORM Player to Use External Storage
Update your SCORM player component to load content from external storage:
// src/screens/ScormPlayerScreen.js
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, ActivityIndicator, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { WebView } from 'react-native-webview';
import RNFS from 'react-native-fs';
import NetInfo from '@react-native-community/netinfo';
import Icon from 'react-native-vector-icons/MaterialIcons';
import ExternalStorageService from '../services/ExternalStorageService';
const ScormPlayerScreen = ({ route }) => {
const { courseId } = route.params || { courseId: 'course1' };
const [isLoading, setIsLoading] = useState(true);
const [isOnline, setIsOnline] = useState(true);
const [errorMessage, setErrorMessage] = useState(null);
const [courseUrl, setCourseUrl] = useState(null);
const [apiPath, setApiPath] = useState(null);
const webViewRef = useRef(null);
useEffect(() => {
// Setup network connectivity listener
const unsubscribe = NetInfo.addEventListener(state => {
setIsOnline(state.isConnected);
// If we just came back online, try to sync data
if (state.isConnected && webViewRef.current) {
syncOfflineData();
}
});
// Prepare the necessary files and paths
prepareScormContent();
// Cleanup network listener on unmount
return () => {
unsubscribe();
};
}, []);
const prepareScormContent = async () => {
try {
// Step 1: Copy scorm-again.js API to app documents directory if needed
const documentsDir = RNFS.DocumentDirectoryPath;
const apiDir = `${documentsDir}/scorm-again/api`;
const apiDirExists = await RNFS.exists(apiDir);
if (!apiDirExists) {
await RNFS.mkdir(apiDir, { NSURLIsExcludedFromBackupKey: true });
}
const apiJsPath = `${apiDir}/scorm-again.js`;
const apiJsExists = await RNFS.exists(apiJsPath);
if (!apiJsExists) {
// Copy the API from the app bundle
// Note: You'll need to use platform-specific code or copy from assets manually
// For Android: await RNFS.copyFileAssets('scorm-again/api/scorm-again.js', apiJsPath);
// For iOS: The file should be in the app bundle and can be accessed directly
const assetPath = Platform.OS === 'android'
? 'scorm-again/api/scorm-again.js'
: `${RNFS.MainBundlePath}/scorm-again/api/scorm-again.js`;
if (Platform.OS === 'android') {
await RNFS.copyFileAssets(assetPath, apiJsPath);
} else {
await RNFS.copyFile(assetPath, apiJsPath);
}
}
setApiPath(`file://${apiJsPath}`);
// Step 2: Initialize external storage and get course path
await ExternalStorageService.initialize();
// Get the course directory (will create if it doesn't exist)
const courseDir = await ExternalStorageService.getCourseDirectory(courseId);
// Check if the course exists (has an index.html file)
const indexPath = `${courseDir}/index.html`;
const indexExists = await RNFS.exists(indexPath);
if (!indexExists) {
// Course doesn't exist in external storage yet
// In a real app, you might want to download it or extract from assets
// For this example, we'll copy from app assets
await copyExampleCourseFromAssets(courseDir);
}
// Get the URL for loading in the WebView
const url = ExternalStorageService.getCourseUrl(courseDir);
setCourseUrl(url);
setIsLoading(false);
} catch (error) {
console.error('Error preparing SCORM content:', error);
setErrorMessage(`Failed to prepare course: ${error.message}`);
setIsLoading(false);
}
};
const copyExampleCourseFromAssets = async (targetDir) => {
try {
// This is a simplified example - in a real app this would be more robust
// Note: readDirAssets and copyFileAssets are Android-only
// For a cross-platform solution, use react-native-fs differently or bundle courses in the app
if (Platform.OS === 'android') {
// Get a list of all course files from assets
const courseFiles = await RNFS.readDirAssets(`courses/${courseId}`);
// Copy each file to the external storage
for (const file of courseFiles) {
const relativePath = file.path.split(`courses/${courseId}/`)[1];
const targetPath = `${targetDir}/${relativePath}`;
// Create parent directories if needed
const targetParentDir = targetPath.substring(0, targetPath.lastIndexOf('/'));
await RNFS.mkdir(targetParentDir);
// Copy the file
await RNFS.copyFileAssets(file.path, targetPath);
}
} else {
// For iOS, you would need to bundle the course differently
// or download it from a remote source
throw new Error('Course copying from assets is not implemented for iOS in this example');
}
} catch (error) {
console.error('Error copying example course:', error);
throw error;
}
};
const injectScormAgain = () => {
if (!webViewRef.current) return;
const jsCode = `
var scormAgainScript = document.createElement('script');
scormAgainScript.src = '${apiPath}';
scormAgainScript.onload = function() {
// Initialize scorm-again with offline support
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.ReactNativeWebView.postMessage(JSON.stringify({
type: 'log',
message: message,
level: level
}));
}
});
// Notify when offline data is synced
window.API.on('OfflineDataSynced', function() {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'sync',
status: 'success'
}));
});
// Override the isDeviceOnline method to use our React state
// 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);
true;
`;
webViewRef.current.injectJavaScript(jsCode);
};
const syncOfflineData = () => {
if (!isOnline || !webViewRef.current) return;
const jsCode = `
if (window.API && window.API._offlineStorageService) {
window.API._offlineStorageService.syncOfflineData().then(function(success) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'sync',
status: success ? 'success' : 'failed'
}));
});
}
true;
`;
webViewRef.current.injectJavaScript(jsCode);
};
const handleWebViewMessage = (event) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'log') {
console.log('SCORM Log:', data.message);
} else if (data.type === 'sync') {
if (data.status === 'success') {
Alert.alert('Sync Complete', 'SCORM data synchronized successfully');
} else {
Alert.alert('Sync Failed', 'Failed to synchronize some SCORM data');
}
}
} catch (error) {
console.error('Error handling WebView message:', error);
}
};
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.loadingText}>Loading SCORM content...</Text>
</View>
);
}
if (errorMessage) {
return (
<View style={styles.errorContainer}>
<Icon name="error" size={64} color="#d32f2f" />
<Text style={styles.errorText}>{errorMessage}</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>SCORM Course Player</Text>
<View style={styles.headerActions}>
<Icon
name={isOnline ? 'wifi' : 'wifi-off'}
size={24}
color="#fff"
style={styles.headerIcon}
/>
<TouchableOpacity
onPress={syncOfflineData}
disabled={!isOnline}
style={[styles.syncButton, !isOnline && styles.syncButtonDisabled]}
>
<Icon name="sync" size={24} color="#fff" />
</TouchableOpacity>
</View>
</View>
<WebView
ref={webViewRef}
source={{ uri: courseUrl }}
style={styles.webView}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoad={() => injectScormAgain()}
onMessage={handleWebViewMessage}
renderLoading={() => (
<View style={styles.webViewLoading}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
)}
/>
</View>
);
};
// Styles remain the same
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 10,
fontSize: 16,
color: '#757575',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2196F3',
paddingVertical: 12,
paddingHorizontal: 16,
},
headerTitle: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 16,
},
syncButton: {
padding: 8,
},
syncButtonDisabled: {
opacity: 0.5,
},
webView: {
flex: 1,
},
webViewLoading: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
marginTop: 16,
fontSize: 16,
textAlign: 'center',
color: '#d32f2f',
},
});
export default ScormPlayerScreen;
Create a Course Library Screen
Implement a screen to browse and manage locally stored SCORM courses:
// src/screens/CourseLibraryScreen.js
import React, { useState, useEffect } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import ExternalStorageService from '../services/ExternalStorageService';
import NetInfo from '@react-native-community/netinfo';
const CourseLibraryScreen = ({ navigation }) => {
const [courses, setCourses] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isOnline, setIsOnline] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
// Check network connectivity
NetInfo.fetch().then(state => {
setIsOnline(state.isConnected);
});
// Subscribe to network changes
const unsubscribe = NetInfo.addEventListener(state => {
setIsOnline(state.isConnected);
});
// Load available courses
loadCourses();
return () => {
unsubscribe();
};
}, []);
const loadCourses = async () => {
try {
setIsLoading(true);
// Initialize external storage
await ExternalStorageService.initialize();
// Get list of available courses
const availableCourses = await ExternalStorageService.listAvailableCourses();
setCourses(availableCourses);
} catch (error) {
console.error('Error loading courses:', error);
Alert.alert('Error', 'Failed to load courses: ' + error.message);
} finally {
setIsLoading(false);
setRefreshing(false);
}
};
const handleRefresh = () => {
setRefreshing(true);
loadCourses();
};
const handleDownloadCourse = async () => {
// In a real app, this would open a modal or screen to select a course to download
Alert.alert(
'Download Course',
'Enter course details',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Download',
onPress: () => {
// Example download URL and course ID
downloadExampleCourse('example-course', 'https://example.com/courses/example-course.zip');
},
},
]
);
};
const downloadExampleCourse = async (courseId, url) => {
try {
setIsLoading(true);
// Show download progress (in a real app, use a progress indicator)
Alert.alert('Downloading', 'Downloading course package...');
// Download and extract the course
await ExternalStorageService.downloadAndExtractCourse(courseId, url);
// Refresh the course list
await loadCourses();
Alert.alert('Success', 'Course downloaded successfully!');
} catch (error) {
console.error('Error downloading course:', error);
Alert.alert('Error', 'Failed to download course: ' + error.message);
} finally {
setIsLoading(false);
}
};
const handleDeleteCourse = (courseId) => {
Alert.alert(
'Delete Course',
'Are you sure you want to delete this course? This cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await ExternalStorageService.deleteCourse(courseId);
await loadCourses();
Alert.alert('Success', 'Course deleted successfully!');
} catch (error) {
console.error('Error deleting course:', error);
Alert.alert('Error', 'Failed to delete course: ' + error.message);
}
},
},
]
);
};
const renderCourseItem = ({ item }) => (
<View style={styles.courseItem}>
<TouchableOpacity
style={styles.courseContent}
onPress={() => navigation.navigate('ScormPlayer', { courseId: item.id })}
>
<Icon name="school" size={36} color="#2196F3" style={styles.courseIcon} />
<View style={styles.courseInfo}>
<Text style={styles.courseTitle}>{item.id}</Text>
<Text style={styles.coursePath}>{item.path}</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDeleteCourse(item.id)}
>
<Icon name="delete" size={24} color="#d32f2f" />
</TouchableOpacity>
</View>
);
if (isLoading && !refreshing) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2196F3" />
<Text style={styles.loadingText}>Loading courses...</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={courses}
renderItem={renderCourseItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.coursesList}
refreshing={refreshing}
onRefresh={handleRefresh}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Icon name="folder-open" size={64} color="#9e9e9e" />
<Text style={styles.emptyText}>No SCORM courses found</Text>
<Text style={styles.emptySubtext}>
Tap the download button to add a course
</Text>
</View>
}
/>
<TouchableOpacity
style={[styles.fab, !isOnline && styles.fabDisabled]}
onPress={handleDownloadCourse}
disabled={!isOnline}
>
<Icon name="cloud-download" size={24} color="#fff" />
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 10,
fontSize: 16,
color: '#757575',
},
coursesList: {
padding: 16,
paddingBottom: 80, // Space for FAB
},
courseItem: {
flexDirection: 'row',
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 1.41,
},
courseContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
courseIcon: {
marginRight: 16,
},
courseInfo: {
flex: 1,
},
courseTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#333',
},
coursePath: {
fontSize: 14,
color: '#757575',
marginTop: 4,
},
deleteButton: {
padding: 8,
justifyContent: 'center',
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 32,
},
emptyText: {
fontSize: 18,
fontWeight: 'bold',
color: '#757575',
marginTop: 16,
},
emptySubtext: {
fontSize: 14,
color: '#9e9e9e',
marginTop: 8,
textAlign: 'center',
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#2196F3',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
fabDisabled: {
backgroundColor: '#9e9e9e',
},
});
export default CourseLibraryScreen;
Benefits of External Storage
Using external storage for SCORM content provides several advantages:
-
Flexible Storage Management
- Users can manage their storage space more effectively
- Courses can be much larger than what would fit in app-specific storage
- Content persists even if the app is uninstalled or updated
-
Cross-App Compatibility
- Content can potentially be shared between different learning apps
- Easier integration with device file managers and other tools
-
User Control
- Users can manually add/remove content as needed
- Simplifies backup and transfer of learning materials
-
Reduced App Size
- Keeps your app's installed size smaller
- Avoids storage issues related to app size limits
Important Considerations
When implementing external storage for SCORM content, keep these points in mind:
-
Android Storage Access Framework
- For Android 11+ (API 30+), consider using the Storage Access Framework
MANAGE_EXTERNAL_STORAGEpermission requires special justification in Play Store listings
-
Content Validation
- Always validate SCORM packages before loading
- Implement integrity checks to detect corrupted content
-
Security
- Consider encrypting sensitive content when storing externally
- Implement proper package signing and verification
-
User Experience
- Provide clear UI for managing downloaded content
- Implement proper error handling and retry mechanisms for downloads
Implementation
1. Create the SCORM Player Component
Here's a complete implementation example:
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, ActivityIndicator, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { WebView } from 'react-native-webview';
import RNFS from 'react-native-fs';
import NetInfo from '@react-native-community/netinfo';
import { unzip } from 'react-native-zip-archive';
import Icon from 'react-native-vector-icons/MaterialIcons';
const COURSE_ID = 'course1';
const ScormPlayerScreen = () => {
const [isLoading, setIsLoading] = useState(true);
const [isOnline, setIsOnline] = useState(true);
const [coursePath, setCoursePath] = useState('');
const [apiPath, setApiPath] = useState('');
const webViewRef = useRef(null);
useEffect(() => {
// Setup network connectivity listener
const unsubscribe = NetInfo.addEventListener(state => {
setIsOnline(state.isConnected);
// If we just came back online, try to sync data
if (state.isConnected && webViewRef.current) {
syncOfflineData();
}
});
// Prepare the necessary files and paths
prepareLocalFiles();
// Cleanup network listener on unmount
return () => {
unsubscribe();
};
}, []);
const prepareLocalFiles = async () => {
try {
const documentsDir = RNFS.DocumentDirectoryPath;
// Create and copy API files
const apiDir = `${documentsDir}/scorm-again/api`;
const apiExists = await RNFS.exists(apiDir);
if (!apiExists) {
await RNFS.mkdir(apiDir, { NSURLIsExcludedFromBackupKey: true });
// Copy scorm-again API from assets to documents directory
// Note: Platform-specific implementation needed
if (Platform.OS === 'android') {
await RNFS.copyFileAssets(
'scorm-again/api/scorm-again.js',
`${apiDir}/scorm-again.js`
);
} else {
// For iOS, copy from app bundle
await RNFS.copyFile(
`${RNFS.MainBundlePath}/scorm-again/api/scorm-again.js`,
`${apiDir}/scorm-again.js`
);
}
}
setApiPath(`file://${apiDir}`);
// Setup course directory
const courseDir = `${documentsDir}/courses/${COURSE_ID}`;
const courseExists = await RNFS.exists(courseDir);
if (!courseExists) {
await RNFS.mkdir(courseDir, { NSURLIsExcludedFromBackupKey: true });
// Check if we need to extract a zip file
const zipExists = await RNFS.exists(`${RNFS.MainBundlePath}/courses/${COURSE_ID}.zip`);
if (zipExists) {
// Extract the course zip file to the course directory
await unzip(
`${RNFS.MainBundlePath}/courses/${COURSE_ID}.zip`,
courseDir
);
} else {
// Copy unzipped course files instead
// This is a simplified example - in a real app you'd need to copy all course files
// Note: Platform-specific implementation needed
if (Platform.OS === 'android') {
await RNFS.copyFileAssets(
`courses/${COURSE_ID}/index.html`,
`${courseDir}/index.html`
);
// Copy other course files as needed
} else {
// For iOS, use different approach or download course package
throw new Error('Course copying from assets not implemented for iOS in this example');
}
}
}
setCoursePath(`file://${courseDir}`);
setIsLoading(false);
} catch (error) {
console.error('Error preparing local files:', error);
Alert.alert('Error', 'Failed to prepare course files');
}
};
const injectScormAgain = () => {
const jsCode = `
var scormAgainScript = document.createElement('script');
scormAgainScript.src = '${apiPath}/scorm-again.js';
scormAgainScript.onload = function() {
// Initialize scorm-again with offline support
window.API = new window.ScormAgain.SCORM2004API({
enableOfflineSupport: true,
courseId: '${COURSE_ID}',
autocommit: true,
autocommitSeconds: 60,
syncOnInitialize: true,
syncOnTerminate: true,
logLevel: 4,
onLogMessage: function(message, level) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'log',
message: message,
level: level
}));
}
});
// Notify when offline data is synced
window.API.on('OfflineDataSynced', function() {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'sync',
status: 'success'
}));
});
// Override the isDeviceOnline method to use our React state
// 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);
true;
`;
webViewRef.current?.injectJavaScript(jsCode);
};
const syncOfflineData = () => {
if (isOnline && webViewRef.current) {
const jsCode = `
if (window.API && window.API._offlineStorageService) {
window.API._offlineStorageService.syncOfflineData().then(function(success) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'sync',
status: success ? 'success' : 'failed'
}));
});
}
true;
`;
webViewRef.current.injectJavaScript(jsCode);
}
};
const handleWebViewMessage = (event) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'log') {
console.log('SCORM Log:', data.message);
} else if (data.type === 'sync') {
if (data.status === 'success') {
Alert.alert('Sync Complete', 'SCORM data synchronized successfully');
} else {
Alert.alert('Sync Failed', 'Failed to synchronize some SCORM data');
}
}
} catch (error) {
console.error('Error handling WebView message:', error);
}
};
const handleWebViewLoad = () => {
injectScormAgain();
};
if (isLoading || !coursePath) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.loadingText}>Loading SCORM content...</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>SCORM Course Player</Text>
<View style={styles.headerActions}>
<Icon
name={isOnline ? 'wifi' : 'wifi-off'}
size={24}
color="#fff"
style={styles.headerIcon}
/>
<TouchableOpacity
onPress={syncOfflineData}
disabled={!isOnline}
style={[styles.syncButton, !isOnline && styles.syncButtonDisabled]}
>
<Icon name="sync" size={24} color="#fff" />
</TouchableOpacity>
</View>
</View>
<WebView
ref={webViewRef}
source={{ uri: `${coursePath}/index.html` }}
style={styles.webView}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
onLoad={handleWebViewLoad}
onMessage={handleWebViewMessage}
renderLoading={() => (
<View style={styles.webViewLoading}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 10,
fontSize: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2196F3',
paddingVertical: 12,
paddingHorizontal: 16,
},
headerTitle: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 16,
},
syncButton: {
padding: 8,
},
syncButtonDisabled: {
opacity: 0.5,
},
webView: {
flex: 1,
},
webViewLoading: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
},
});
export default ScormPlayerScreen;
2. Usage in Your App
Include the SCORM player in your app's navigation:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import ScormPlayerScreen from './src/screens/ScormPlayerScreen';
import HomeScreen from './src/screens/HomeScreen';
const Stack = createStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ title: 'Course Library' }}
/>
<Stack.Screen
name="ScormPlayer"
component={ScormPlayerScreen}
options={{ headerShown: false }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
3. Add Permission for Android
Add the following to your AndroidManifest.xml to ensure file access:
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- ... rest of manifest ... -->
</manifest>
4. Configure iOS Permissions
Update your Info.plist file to allow loading local files:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
Key Features of This Implementation
1. Local HTTP Server (Recommended)
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.
npm install @dr.pogodin/react-native-static-server
See examples/react-native-offline for a complete implementation example.
2. Offline Support
- Detects device connectivity status and updates the scorm-again API accordingly
- Automatically syncs data when the device comes online
- Provides visual feedback on connectivity status
3. Local File Management
- Uses
react-native-fsto manage file operations - Extracts SCORM course content from zip files if needed
- Loads content from local filesystem instead of remote URLs
4. scorm-again Configuration
The scorm-again API is initialized with these key settings:
{
enableOfflineSupport: true, // Enable offline capabilities
courseId: 'course1', // 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
}
5. WebView Communication
- Uses
postMessageto send data from the WebView to React Native - Injects JavaScript to initialize scorm-again and handle messages
- Provides feedback via alerts when data is synchronized
Advanced Topics
1. Managing Multiple Courses
For applications with multiple courses, consider implementing a course selection screen and managing multiple course directories:
// Example Course Selection Component
const CourseLibraryScreen = ({ navigation }) => {
const [courses, setCourses] = useState([]);
useEffect(() => {
// Load available courses
loadAvailableCourses();
}, []);
const loadAvailableCourses = async () => {
// Read courses from directory or API
const availableCourses = [
{ id: 'course1', title: 'Introduction to SCORM', description: 'Learn the basics of SCORM' },
{ id: 'course2', title: 'Advanced Training', description: 'In-depth training module' },
];
setCourses(availableCourses);
};
return (
<FlatList
data={courses}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.courseItem}
onPress={() => navigation.navigate('ScormPlayer', { courseId: item.id })}
>
<Text style={styles.courseTitle}>{item.title}</Text>
<Text style={styles.courseDescription}>{item.description}</Text>
</TouchableOpacity>
)}
/>
);
};
2. Downloading Courses
For downloading courses from a remote server:
const downloadCourse = async (courseId, courseUrl) => {
const documentsDir = RNFS.DocumentDirectoryPath;
const courseZipPath = `${documentsDir}/downloads/${courseId}.zip`;
const courseDir = `${documentsDir}/courses/${courseId}`;
try {
// Create directories if needed
await RNFS.mkdir(`${documentsDir}/downloads`, { NSURLIsExcludedFromBackupKey: true });
// Download the zip file
const downloadResult = await RNFS.downloadFile({
fromUrl: courseUrl,
toFile: courseZipPath,
progress: (res) => {
const progress = (res.bytesWritten / res.contentLength) * 100;
console.log(`Download progress: ${progress.toFixed(2)}%`);
},
}).promise;
if (downloadResult.statusCode === 200) {
// Create course directory
await RNFS.mkdir(courseDir, { NSURLIsExcludedFromBackupKey: true });
// Extract the zip file
await unzip(courseZipPath, courseDir);
// Remove the zip file to save space
await RNFS.unlink(courseZipPath);
return true;
}
return false;
} catch (error) {
console.error('Error downloading course:', error);
return false;
}
};
Performance Considerations
For optimal performance in React Native WebView:
-
Memory Management
- Unload WebView content when not in use to free memory
- Consider implementing a lightweight course viewer for course previews
-
File Optimization
- Remove unnecessary files from SCORM packages before deployment
- Consider lazy-loading large media files in the SCORM content
-
WebView Configuration
- Use appropriate WebView settings for your content needs
- Consider using
cacheEnabled={true}for better performance
// Example of optimized WebView settings
<WebView
ref={webViewRef}
source={{ uri: `${coursePath}/index.html` }}
javaScriptEnabled={true}
domStorageEnabled={true}
cacheEnabled={true}
thirdPartyCookiesEnabled={false}
sharedCookiesEnabled={false}
mediaPlaybackRequiresUserAction={true}
androidLayerType="hardware"
onLoad={handleWebViewLoad}
onMessage={handleWebViewMessage}
/>
Security Considerations
1. Content Validation
Always validate SCORM packages before loading them:
const validateScormPackage = async (courseDir) => {
// Check for required files
const hasIndexFile = await RNFS.exists(`${courseDir}/index.html`);
const hasImsManifest = await RNFS.exists(`${courseDir}/imsmanifest.xml`);
if (!hasIndexFile || !hasImsManifest) {
throw new Error('Invalid SCORM package: Missing required files');
}
// Additional validation can be performed here
// e.g., content security checks, manifest validation, etc.
return true;
};
2. Data Protection
For sensitive SCORM data, consider implementing encryption:
// Using react-native-encrypted-storage for sensitive data
import EncryptedStorage from 'react-native-encrypted-storage';
// Storing encrypted SCORM data
const secureStorageScormData = async (courseId, data) => {
try {
await EncryptedStorage.setItem(
`scorm_data_${courseId}`,
JSON.stringify(data)
);
} catch (error) {
console.error('Error storing encrypted data:', error);
}
};
// Retrieving encrypted SCORM data
const retrieveSecureScormData = async (courseId) => {
try {
const data = await EncryptedStorage.getItem(`scorm_data_${courseId}`);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Error retrieving encrypted data:', error);
return null;
}
};
Troubleshooting
Common Issues
-
WebView not loading local files
- Ensure file paths are correct (use
file://protocol) - Check that you have proper permissions in your app manifests
- Verify the files exist in the correct location
- Ensure file paths are correct (use
-
JavaScript communication issues
- Make sure
javaScriptEnabledis set totrue - Check that
postMessageand message handlers are properly implemented - Look for console errors in your development environment
- Make sure
-
SCORM API not connecting
- Verify that scorm-again.js is properly loaded
- Check the WebView console for JavaScript errors
- Ensure your HTML content is correctly structured to work with SCORM
-
File access problems
- Verify that the app has proper permissions
- Check that directories are created with proper error handling
- Ensure file paths are correctly formatted for the platform
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: Use injectedJavaScriptBeforeContentLoaded to set up APIs and override window.parent:
<WebView
injectedJavaScriptBeforeContentLoaded={`
// 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
});
true;
`}
// ... other props
/>
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. Forward them to native:
window.alert = function(msg) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'alert', message: String(msg)
}));
};
Handle in your onMessage callback with React Native's Alert.alert().
Conclusion
This React Native implementation provides a robust foundation for integrating SCORM content with offline support in your mobile applications. By leveraging scorm-again's offline capabilities and React Native's WebView and file management features, you can deliver effective mobile learning experiences even in disconnected environments.