diff --git a/public/lib/amd/build/network.min.js b/public/lib/amd/build/network.min.js
index d797fb65821..87937585b1d 100644
--- a/public/lib/amd/build/network.min.js
+++ b/public/lib/amd/build/network.min.js
@@ -5,6 +5,6 @@
* @copyright 2019 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define("core/network",["jquery","core/ajax","core/config","core/notification","core/str"],(function($,Ajax,Config,Notification,Str){var started=!1,warningDisplayed=!1,keepAliveFrequency=0,requestTimeout=0,keepAliveMessage=!1,sessionTimeout=!1,checkFrequency=1e3*Math.min(Config.sessiontimeout/10,600),warningLimit=Config.sessiontimeoutwarning>0?1e3*Config.sessiontimeoutwarning:2*checkFrequency,firstWait=Config.sessiontimeoutwarning>0?Math.min(1e3*(Config.sessiontimeout-Config.sessiontimeoutwarning),5*checkFrequency):5*checkFrequency,timeoutSessionExpired=function(modal){sessionTimeout=!0,warningDisplayed=!1,closeModal(modal),displaySessionExpired()},closeModal=function(modal){modal.destroy()},displaySessionExpired=function(){return Ajax.call([{methodname:"core_session_time_remaining",args:{}}],!0,!0,!0)[0].then((function(args){return!(1e3*args.timeremaining>warningLimit)&&Str.get_strings([{key:"sessionexpired",component:"error"},{key:"sessionerroruser",component:"error"},{key:"loginagain",component:"moodle"},{key:"cancel",component:"moodle"}]).then((function(strings){return Notification.confirm(strings[0],strings[1],strings[2],strings[3],(function(){return location.reload(),!0})),!0})).catch(Notification.exception)}))},touchSession=function(){return sessionTimeout?displaySessionExpired():Ajax.call([{methodname:"core_session_touch",args:{}}],!0,!0,!1,requestTimeout)[0].then((function(){return keepAliveFrequency>0&&setTimeout(touchSession,keepAliveFrequency),!0})).catch((function(){Notification.alert("",keepAliveMessage)}))},checkSession=function(){return sessionTimeout=!1,Ajax.call([{methodname:"core_session_time_remaining",args:{}}],!0,!0,!0)[0].then((function(args){return!(args.userid<=0)&&(args.timeremaining<=0?displaySessionExpired():(1e3*args.timeremaining<=warningLimit&&!warningDisplayed?(warningDisplayed=!0,Str.get_strings([{key:"norecentactivity",component:"moodle"},{key:"sessiontimeoutsoon",component:"moodle"},{key:"extendsession",component:"moodle"},{key:"cancel",component:"moodle"}]).then((function(strings){return Notification.confirm(strings[0],strings[1],strings[2],strings[3],(function(){return touchSession(),warningDisplayed=!1,setTimeout(checkSession,firstWait),!0}),(function(){setTimeout(checkSession,checkFrequency)}))})).then((modal=>{setTimeout(timeoutSessionExpired,1e3*args.timeremaining,modal)})).catch(Notification.exception)):setTimeout(checkSession,checkFrequency),!0))}))},start=function(){keepAliveFrequency>0?setTimeout(touchSession,keepAliveFrequency):setTimeout(checkSession,firstWait)};const isMoodleIframe=function(){if(window.parent===window)return!1;let parentUrl;try{parentUrl=window.parent.location.href}catch(e){return!1}return parentUrl.startsWith(M.cfg.wwwroot)};return{keepalive:async function(freq,timeout,identifier,component){started?window.console.warn("Ignoring session keep-alive. The core/network module was already initialised."):(started=!0,isMoodleIframe()?window.console.warn("Ignoring session keep-alive in this iframe inside another Moodle page."):(window.console.log("Starting Moodle session keep-alive."),keepAliveFrequency=1e3*freq,keepAliveMessage=await Str.get_string(identifier,component),requestTimeout=1e3*timeout,start()))},init:function(){started||(started=!0,isMoodleIframe()?window.console.log("Not starting Moodle session timeout warning in this iframe."):(window.console.log("Starting Moodle session timeout warning."),start()))}}}));
+define("core/network",["jquery","core/ajax","core/config","core/notification","core/str","core/modal_save_cancel","core/modal_events","core/network_events"],(function($,Ajax,Config,Notification,Str,SaveCancelModal,ModalEvents,NetworkEvents){var started=!1,warningDisplayed=!1,keepAliveFrequency=0,requestTimeout=0,keepAliveMessage=!1,sessionTimeout=!1,checkFrequency=1e3*Math.min(Config.sessiontimeout/10,600),warningLimit=Config.sessiontimeoutwarning>0?1e3*Config.sessiontimeoutwarning:2*checkFrequency,firstWait=Config.sessiontimeoutwarning>0?Math.min(1e3*(Config.sessiontimeout-Config.sessiontimeoutwarning),5*checkFrequency):5*checkFrequency,alertModal=null,redirectUrl=null,connectionRetries=0,timeoutSessionExpired=function(modal){sessionTimeout=!0,warningDisplayed=!1,closeModal(modal),displaySessionExpired()},closeModal=function(modal){modal.destroy()},displaySessionExpired=function(){return Ajax.call([{methodname:"core_session_time_remaining",args:{}}],!0,!0,!0)[0].then((function(args){return!(1e3*args.timeremaining>warningLimit)&&Str.get_strings([{key:"sessionexpired",component:"error"},{key:"sessionerroruser",component:"error"},{key:"loginagain",component:"moodle"},{key:"cancel",component:"moodle"}]).then((function(strings){return Notification.confirm(strings[0],strings[1],strings[2],strings[3],(function(){return location.reload(),!0})),!0})).catch(Notification.exception)}))},touchSession=function(){return sessionTimeout?displaySessionExpired():Ajax.call([{methodname:"core_session_touch",args:{}}],!0,!0,!1,requestTimeout)[0].then((function(){return window.dispatchEvent(new CustomEvent(NetworkEvents.sessionTouched)),!0})).catch((function(){window.dispatchEvent(new CustomEvent(NetworkEvents.unstableConnection))}))},checkSession=function(){return sessionTimeout=!1,Ajax.call([{methodname:"core_session_time_remaining",args:{}}],!0,!0,!0)[0].then((function(args){return!(args.userid<=0)&&(args.timeremaining<=0?displaySessionExpired():(1e3*args.timeremaining<=warningLimit&&!warningDisplayed?(warningDisplayed=!0,Str.get_strings([{key:"norecentactivity",component:"moodle"},{key:"sessiontimeoutsoon",component:"moodle"},{key:"extendsession",component:"moodle"},{key:"cancel",component:"moodle"}]).then((function(strings){return Notification.confirm(strings[0],strings[1],strings[2],strings[3],(function(){return touchSession(),warningDisplayed=!1,setTimeout(checkSession,firstWait),!0}),(function(){setTimeout(checkSession,checkFrequency)}))})).then((modal=>{setTimeout(timeoutSessionExpired,1e3*args.timeremaining,modal)})).catch(Notification.exception)):setTimeout(checkSession,checkFrequency),!0))}))},start=function(){keepAliveFrequency>0?setTimeout(touchSession,keepAliveFrequency):setTimeout(checkSession,firstWait)};const isMoodleIframe=function(){if(window.parent===window)return!1;let parentUrl;try{parentUrl=window.parent.location.href}catch(e){return!1}return parentUrl.startsWith(M.cfg.wwwroot)};var leavePage=function(){"string"!=typeof redirectUrl?window.location.reload():window.location.replace(redirectUrl)},handleUnstableConnection=function(){if(connectionRetries<1)return alertModal.show(),setTimeout(touchSession,keepAliveFrequency),void connectionRetries++;null!==alertModal&&(alertModal.getRoot().find('[data-action="cancel"]').hide(),alertModal.show().then((function(){return alertModal.getRoot().on(ModalEvents.hidden,leavePage),!0})).catch(Notification.exception))},handleSessionTouched=function(){keepAliveFrequency>0&&(setTimeout(touchSession,keepAliveFrequency),connectionRetries=0)};return{keepalive:async function(freq,timeout,identifier,component){let redirect=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;if(started)window.console.warn("Ignoring session keep-alive. The core/network module was already initialised.");else if(started=!0,isMoodleIframe())window.console.warn("Ignoring session keep-alive in this iframe inside another Moodle page.");else{window.console.log("Starting Moodle session keep-alive."),keepAliveFrequency=1e3*freq,keepAliveMessage=await Str.get_string(identifier,component),requestTimeout=1e3*timeout,redirectUrl=redirect;var strings=await Str.get_strings([{key:"unstablenetwork",component:"mod_scorm"},{key:"leavepage",component:"mod_scorm"},{key:"refresh",component:"moodle"}]);(alertModal=await SaveCancelModal.create({title:strings[0],body:keepAliveMessage,buttons:{save:"string"==typeof redirectUrl?strings[1]:strings[2]},removeOnClose:!1,show:!1})).getRoot().on(ModalEvents.save,leavePage),window.addEventListener(NetworkEvents.sessionTouched,handleSessionTouched),window.addEventListener(NetworkEvents.unstableConnection,handleUnstableConnection),start()}},init:function(){started||(started=!0,isMoodleIframe()?window.console.log("Not starting Moodle session timeout warning in this iframe."):(window.console.log("Starting Moodle session timeout warning."),start()))}}}));
//# sourceMappingURL=network.min.js.map
\ No newline at end of file
diff --git a/public/lib/amd/build/network.min.js.map b/public/lib/amd/build/network.min.js.map
index 3c57e409790..eb5dd9e3e96 100644
--- a/public/lib/amd/build/network.min.js.map
+++ b/public/lib/amd/build/network.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"network.min.js","sources":["../src/network.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Poll the server to keep the session alive.\n *\n * @module core/network\n * @copyright 2019 Damyon Wiese\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],\n function($, Ajax, Config, Notification, Str) {\n\n var started = false;\n var warningDisplayed = false;\n var keepAliveFrequency = 0;\n var requestTimeout = 0;\n var keepAliveMessage = false;\n var sessionTimeout = false;\n // 1/10 of session timeout, max of 10 minutes.\n var checkFrequency = Math.min((Config.sessiontimeout / 10), 600) * 1000;\n // Check if sessiontimeoutwarning is set or double the checkFrequency.\n var warningLimit = (Config.sessiontimeoutwarning > 0) ? (Config.sessiontimeoutwarning * 1000) : (checkFrequency * 2);\n // First wait is minimum of remaining time or half of the session timeout.\n var firstWait = (Config.sessiontimeoutwarning > 0) ?\n Math.min((Config.sessiontimeout - Config.sessiontimeoutwarning) * 1000, checkFrequency * 5) : checkFrequency * 5;\n /**\n * The session time has expired - we can't extend it now.\n * @param {Modal} modal\n */\n var timeoutSessionExpired = function(modal) {\n sessionTimeout = true;\n warningDisplayed = false;\n closeModal(modal);\n displaySessionExpired();\n };\n\n /**\n * Close modal - this relies on modal object passed from Notification.confirm.\n *\n * @param {Modal} modal\n */\n var closeModal = function(modal) {\n modal.destroy();\n };\n\n /**\n * The session time has expired - we can't extend it now.\n * @return {Promise}\n */\n var displaySessionExpired = function() {\n // Check again if its already extended before displaying session expired popup in case multiple tabs are open.\n var request = {\n methodname: 'core_session_time_remaining',\n args: { }\n };\n\n return Ajax.call([request], true, true, true)[0].then(function(args) {\n if (args.timeremaining * 1000 > warningLimit) {\n return false;\n } else {\n return Str.get_strings([\n {key: 'sessionexpired', component: 'error'},\n {key: 'sessionerroruser', component: 'error'},\n {key: 'loginagain', component: 'moodle'},\n {key: 'cancel', component: 'moodle'}\n ]).then(function(strings) {\n Notification.confirm(\n strings[0], // Title.\n strings[1], // Message.\n strings[2], // Login Again.\n strings[3], // Cancel.\n function() {\n location.reload();\n return true;\n }\n );\n return true;\n }).catch(Notification.exception);\n }\n });\n };\n\n /**\n * Ping the server to keep the session alive.\n *\n * @return {Promise}\n */\n var touchSession = function() {\n var request = {\n methodname: 'core_session_touch',\n args: { }\n };\n\n if (sessionTimeout) {\n // We timed out before we extended the session.\n return displaySessionExpired();\n } else {\n return Ajax.call([request], true, true, false, requestTimeout)[0].then(function() {\n if (keepAliveFrequency > 0) {\n setTimeout(touchSession, keepAliveFrequency);\n }\n return true;\n }).catch(function() {\n Notification.alert('', keepAliveMessage);\n });\n }\n };\n\n /**\n * Ask the server how much time is remaining in this session and\n * show confirm/cancel notifications if the session is about to run out.\n *\n * @return {Promise}\n */\n var checkSession = function() {\n var request = {\n methodname: 'core_session_time_remaining',\n args: { }\n };\n sessionTimeout = false;\n return Ajax.call([request], true, true, true)[0].then(function(args) {\n if (args.userid <= 0) {\n return false;\n }\n if (args.timeremaining <= 0) {\n return displaySessionExpired();\n } else if (args.timeremaining * 1000 <= warningLimit && !warningDisplayed) {\n warningDisplayed = true;\n Str.get_strings([\n {key: 'norecentactivity', component: 'moodle'},\n {key: 'sessiontimeoutsoon', component: 'moodle'},\n {key: 'extendsession', component: 'moodle'},\n {key: 'cancel', component: 'moodle'}\n ]).then(function(strings) {\n return Notification.confirm(\n strings[0], // Title.\n strings[1], // Message.\n strings[2], // Extend session.\n strings[3], // Cancel.\n function() {\n touchSession();\n warningDisplayed = false;\n // First wait is minimum of remaining time or half of the session timeout.\n setTimeout(checkSession, firstWait);\n return true;\n },\n function() {\n // User has cancelled notification.\n setTimeout(checkSession, checkFrequency);\n }\n );\n }).then(modal => {\n // If we don't extend the session before the timeout - warn.\n setTimeout(timeoutSessionExpired, args.timeremaining * 1000, modal);\n return;\n }).catch(Notification.exception);\n } else {\n setTimeout(checkSession, checkFrequency);\n }\n return true;\n });\n // We do not catch the fails from the above ajax call because they will fail when\n // we are not logged in - we don't need to take any action then.\n };\n\n /**\n * Start calling a function to check if the session is still alive.\n */\n var start = function() {\n if (keepAliveFrequency > 0) {\n setTimeout(touchSession, keepAliveFrequency);\n } else {\n // First wait is minimum of remaining time or half of the session timeout.\n setTimeout(checkSession, firstWait);\n }\n };\n\n /**\n * Are we in an iframe and the parent page is from the same Moodle site?\n *\n * @return {boolean} true if we are in an iframe in a page from this Moodle site.\n */\n const isMoodleIframe = function() {\n if (window.parent === window) {\n // Not in an iframe.\n return false;\n }\n\n // We are in an iframe. Is the parent from the same Moodle site?\n let parentUrl;\n try {\n parentUrl = window.parent.location.href;\n } catch (e) {\n // If we cannot access the URL of the parent page, it must be another site.\n return false;\n }\n\n return parentUrl.startsWith(M.cfg.wwwroot);\n };\n\n /**\n * Don't allow more than one of these polling loops in a single page.\n */\n var init = function() {\n // We only allow one concurrent instance of this checker.\n if (started) {\n return;\n }\n started = true;\n\n if (isMoodleIframe()) {\n window.console.log('Not starting Moodle session timeout warning in this iframe.');\n return;\n }\n\n window.console.log('Starting Moodle session timeout warning.');\n\n start();\n };\n\n /**\n * Start polling with more specific values for the frequency, timeout and message.\n *\n * @param {number} freq How ofter to poll the server.\n * @param {number} timeout The time to wait for each request to the server.\n * @param {string} identifier The string identifier for the message to show if session is going to time out.\n * @param {string} component The string component for the message to show if session is going to time out.\n */\n var keepalive = async function(freq, timeout, identifier, component) {\n // We only allow one concurrent instance of this checker.\n if (started) {\n window.console.warn('Ignoring session keep-alive. The core/network module was already initialised.');\n return;\n }\n started = true;\n\n if (isMoodleIframe()) {\n window.console.warn('Ignoring session keep-alive in this iframe inside another Moodle page.');\n return;\n }\n\n window.console.log('Starting Moodle session keep-alive.');\n\n keepAliveFrequency = freq * 1000;\n keepAliveMessage = await Str.get_string(identifier, component);\n requestTimeout = timeout * 1000;\n start();\n };\n\n return {\n keepalive: keepalive,\n init: init\n };\n});\n"],"names":["define","$","Ajax","Config","Notification","Str","started","warningDisplayed","keepAliveFrequency","requestTimeout","keepAliveMessage","sessionTimeout","checkFrequency","Math","min","sessiontimeout","warningLimit","sessiontimeoutwarning","firstWait","timeoutSessionExpired","modal","closeModal","displaySessionExpired","destroy","call","methodname","args","then","timeremaining","get_strings","key","component","strings","confirm","location","reload","catch","exception","touchSession","setTimeout","alert","checkSession","userid","start","isMoodleIframe","window","parent","parentUrl","href","e","startsWith","M","cfg","wwwroot","keepalive","async","freq","timeout","identifier","console","warn","log","get_string","init"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"network.min.js","sources":["../src/network.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Poll the server to keep the session alive.\n *\n * @module core/network\n * @copyright 2019 Damyon Wiese\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'jquery',\n 'core/ajax',\n 'core/config',\n 'core/notification',\n 'core/str',\n 'core/modal_save_cancel',\n 'core/modal_events',\n 'core/network_events',\n],\nfunction(\n $,\n Ajax,\n Config,\n Notification,\n Str,\n SaveCancelModal,\n ModalEvents,\n NetworkEvents,\n) {\n\n var started = false;\n var warningDisplayed = false;\n var keepAliveFrequency = 0;\n var requestTimeout = 0;\n var keepAliveMessage = false;\n var sessionTimeout = false;\n // 1/10 of session timeout, max of 10 minutes.\n var checkFrequency = Math.min((Config.sessiontimeout / 10), 600) * 1000;\n // Check if sessiontimeoutwarning is set or double the checkFrequency.\n var warningLimit = (Config.sessiontimeoutwarning > 0) ? (Config.sessiontimeoutwarning * 1000) : (checkFrequency * 2);\n // First wait is minimum of remaining time or half of the session timeout.\n var firstWait = (Config.sessiontimeoutwarning > 0) ?\n Math.min((Config.sessiontimeout - Config.sessiontimeoutwarning) * 1000, checkFrequency * 5) : checkFrequency * 5;\n // Modal to display when the connection is unstable.\n var alertModal = null;\n // URL to redirect to if network is unstable.\n var redirectUrl = null;\n // Number of times we have tried to touch the session after a network request failure.\n var connectionRetries = 0;\n /**\n * The session time has expired - we can't extend it now.\n * @param {Modal} modal\n */\n var timeoutSessionExpired = function(modal) {\n sessionTimeout = true;\n warningDisplayed = false;\n closeModal(modal);\n displaySessionExpired();\n };\n\n /**\n * Close modal - this relies on modal object passed from Notification.confirm.\n *\n * @param {Modal} modal\n */\n var closeModal = function(modal) {\n modal.destroy();\n };\n\n /**\n * The session time has expired - we can't extend it now.\n * @return {Promise}\n */\n var displaySessionExpired = function() {\n // Check again if its already extended before displaying session expired popup in case multiple tabs are open.\n var request = {\n methodname: 'core_session_time_remaining',\n args: { }\n };\n\n return Ajax.call([request], true, true, true)[0].then(function(args) {\n if (args.timeremaining * 1000 > warningLimit) {\n return false;\n } else {\n return Str.get_strings([\n {key: 'sessionexpired', component: 'error'},\n {key: 'sessionerroruser', component: 'error'},\n {key: 'loginagain', component: 'moodle'},\n {key: 'cancel', component: 'moodle'}\n ]).then(function(strings) {\n Notification.confirm(\n strings[0], // Title.\n strings[1], // Message.\n strings[2], // Login Again.\n strings[3], // Cancel.\n function() {\n location.reload();\n return true;\n }\n );\n return true;\n }).catch(Notification.exception);\n }\n });\n };\n\n /**\n * Ping the server to keep the session alive.\n *\n * @return {Promise}\n */\n var touchSession = function() {\n var request = {\n methodname: 'core_session_touch',\n args: { }\n };\n\n if (sessionTimeout) {\n // We timed out before we extended the session.\n return displaySessionExpired();\n } else {\n return Ajax.call([request], true, true, false, requestTimeout)[0].then(function() {\n window.dispatchEvent(new CustomEvent(NetworkEvents.sessionTouched));\n return true;\n }).catch(function() {\n window.dispatchEvent(new CustomEvent(NetworkEvents.unstableConnection));\n });\n }\n };\n\n /**\n * Ask the server how much time is remaining in this session and\n * show confirm/cancel notifications if the session is about to run out.\n *\n * @return {Promise}\n */\n var checkSession = function() {\n var request = {\n methodname: 'core_session_time_remaining',\n args: { }\n };\n sessionTimeout = false;\n return Ajax.call([request], true, true, true)[0].then(function(args) {\n if (args.userid <= 0) {\n return false;\n }\n if (args.timeremaining <= 0) {\n return displaySessionExpired();\n } else if (args.timeremaining * 1000 <= warningLimit && !warningDisplayed) {\n warningDisplayed = true;\n Str.get_strings([\n {key: 'norecentactivity', component: 'moodle'},\n {key: 'sessiontimeoutsoon', component: 'moodle'},\n {key: 'extendsession', component: 'moodle'},\n {key: 'cancel', component: 'moodle'}\n ]).then(function(strings) {\n return Notification.confirm(\n strings[0], // Title.\n strings[1], // Message.\n strings[2], // Extend session.\n strings[3], // Cancel.\n function() {\n touchSession();\n warningDisplayed = false;\n // First wait is minimum of remaining time or half of the session timeout.\n setTimeout(checkSession, firstWait);\n return true;\n },\n function() {\n // User has cancelled notification.\n setTimeout(checkSession, checkFrequency);\n }\n );\n }).then(modal => {\n // If we don't extend the session before the timeout - warn.\n setTimeout(timeoutSessionExpired, args.timeremaining * 1000, modal);\n return;\n }).catch(Notification.exception);\n } else {\n setTimeout(checkSession, checkFrequency);\n }\n return true;\n });\n // We do not catch the fails from the above ajax call because they will fail when\n // we are not logged in - we don't need to take any action then.\n };\n\n /**\n * Start calling a function to check if the session is still alive.\n */\n var start = function() {\n if (keepAliveFrequency > 0) {\n setTimeout(touchSession, keepAliveFrequency);\n } else {\n // First wait is minimum of remaining time or half of the session timeout.\n setTimeout(checkSession, firstWait);\n }\n };\n\n /**\n * Are we in an iframe and the parent page is from the same Moodle site?\n *\n * @return {boolean} true if we are in an iframe in a page from this Moodle site.\n */\n const isMoodleIframe = function() {\n if (window.parent === window) {\n // Not in an iframe.\n return false;\n }\n\n // We are in an iframe. Is the parent from the same Moodle site?\n let parentUrl;\n try {\n parentUrl = window.parent.location.href;\n } catch (e) {\n // If we cannot access the URL of the parent page, it must be another site.\n return false;\n }\n\n return parentUrl.startsWith(M.cfg.wwwroot);\n };\n\n /**\n * Don't allow more than one of these polling loops in a single page.\n */\n var init = function() {\n // We only allow one concurrent instance of this checker.\n if (started) {\n return;\n }\n started = true;\n\n if (isMoodleIframe()) {\n window.console.log('Not starting Moodle session timeout warning in this iframe.');\n return;\n }\n\n window.console.log('Starting Moodle session timeout warning.');\n\n start();\n };\n\n /**\n * Leave the page, either by redirecting or reloading.\n */\n var leavePage = function() {\n if (typeof redirectUrl === 'string') {\n window.location.replace(redirectUrl);\n return;\n }\n window.location.reload();\n };\n\n /**\n * When the network connection is unstable show the user an alert.\n * If the unstable connection persists, force the user to exit the\n * current page.\n */\n var handleUnstableConnection = function() {\n // Show the alert modal warning the user that their\n // connection is unstable but allow them to continue\n // interacting with the page if they choose.\n if (connectionRetries < 1) {\n alertModal.show();\n setTimeout(touchSession, keepAliveFrequency);\n connectionRetries++;\n return;\n }\n\n // If we have failed to touch the session twice in a row,\n // show the alert modal (if not already visible) but remove\n // any option for the user to continue on this page.\n if (alertModal !== null) {\n alertModal.getRoot().find('[data-action=\"cancel\"]').hide();\n alertModal.show().then(function() {\n alertModal.getRoot().on(ModalEvents.hidden, leavePage);\n return true;\n }).catch(Notification.exception);\n }\n };\n\n /**\n * When the session is touched, set a timeout to touch it again\n * and reset the number of failed connection retries.\n */\n var handleSessionTouched = function() {\n if (keepAliveFrequency > 0) {\n setTimeout(touchSession, keepAliveFrequency);\n connectionRetries = 0;\n }\n };\n\n /**\n * Start polling with more specific values for the frequency, timeout and message.\n *\n * @param {number} freq How ofter to poll the server.\n * @param {number} timeout The time to wait for each request to the server.\n * @param {string} identifier The string identifier for the message to show if session is going to time out.\n * @param {string} component The string component for the message to show if session is going to time out.\n * @param {string|null} redirect A url to redirect to in case of a lost connection to the server.\n */\n var keepalive = async function(freq, timeout, identifier, component, redirect = null) {\n // We only allow one concurrent instance of this checker.\n if (started) {\n window.console.warn('Ignoring session keep-alive. The core/network module was already initialised.');\n return;\n }\n started = true;\n\n if (isMoodleIframe()) {\n window.console.warn('Ignoring session keep-alive in this iframe inside another Moodle page.');\n return;\n }\n\n window.console.log('Starting Moodle session keep-alive.');\n\n keepAliveFrequency = freq * 1000;\n keepAliveMessage = await Str.get_string(identifier, component);\n requestTimeout = timeout * 1000;\n redirectUrl = redirect;\n\n var strings = await Str.get_strings([\n {key: 'unstablenetwork', component: 'mod_scorm'},\n {key: 'leavepage', component: 'mod_scorm'},\n {key: 'refresh', component: 'moodle'},\n ]);\n\n alertModal = await SaveCancelModal.create({\n title: strings[0],\n body: keepAliveMessage,\n buttons: {\n save: typeof redirectUrl === 'string' ? strings[1] : strings[2],\n },\n removeOnClose: false,\n show: false\n });\n\n // The save button is renamed and will redirect the user or\n // reload the page when clicked.\n alertModal.getRoot().on(ModalEvents.save, leavePage);\n\n window.addEventListener(NetworkEvents.sessionTouched, handleSessionTouched);\n window.addEventListener(NetworkEvents.unstableConnection, handleUnstableConnection);\n\n start();\n };\n\n return {\n keepalive: keepalive,\n init: init\n };\n});\n"],"names":["define","$","Ajax","Config","Notification","Str","SaveCancelModal","ModalEvents","NetworkEvents","started","warningDisplayed","keepAliveFrequency","requestTimeout","keepAliveMessage","sessionTimeout","checkFrequency","Math","min","sessiontimeout","warningLimit","sessiontimeoutwarning","firstWait","alertModal","redirectUrl","connectionRetries","timeoutSessionExpired","modal","closeModal","displaySessionExpired","destroy","call","methodname","args","then","timeremaining","get_strings","key","component","strings","confirm","location","reload","catch","exception","touchSession","window","dispatchEvent","CustomEvent","sessionTouched","unstableConnection","checkSession","userid","setTimeout","start","isMoodleIframe","parent","parentUrl","href","e","startsWith","M","cfg","wwwroot","leavePage","replace","handleUnstableConnection","show","getRoot","find","hide","on","hidden","handleSessionTouched","keepalive","async","freq","timeout","identifier","redirect","console","warn","log","get_string","create","title","body","buttons","save","removeOnClose","addEventListener","init"],"mappings":";;;;;;;AAsBAA,sBAAO,CACH,SACA,YACA,cACA,oBACA,WACA,yBACA,oBACA,wBAEJ,SACIC,EACAC,KACAC,OACAC,aACAC,IACAC,gBACAC,YACAC,mBAGIC,SAAU,EACVC,kBAAmB,EACnBC,mBAAqB,EACrBC,eAAiB,EACjBC,kBAAmB,EACnBC,gBAAiB,EAEjBC,eAA+D,IAA9CC,KAAKC,IAAKd,OAAOe,eAAiB,GAAK,KAExDC,aAAgBhB,OAAOiB,sBAAwB,EAAqC,IAA/BjB,OAAOiB,sBAAkD,EAAjBL,eAE7FM,UAAalB,OAAOiB,sBAAwB,EAC5CJ,KAAKC,IAA6D,KAAxDd,OAAOe,eAAiBf,OAAOiB,uBAAgD,EAAjBL,gBAAuC,EAAjBA,eAE9FO,WAAa,KAEbC,YAAc,KAEdC,kBAAoB,EAKpBC,sBAAwB,SAASC,OACjCZ,gBAAiB,EACjBJ,kBAAmB,EACnBiB,WAAWD,OACXE,yBAQAD,WAAa,SAASD,OACtBA,MAAMG,WAOND,sBAAwB,kBAOjB1B,KAAK4B,KAAK,CALH,CACVC,WAAY,8BACZC,KAAM,MAGkB,GAAM,GAAM,GAAM,GAAGC,MAAK,SAASD,cAClC,IAArBA,KAAKE,cAAuBf,eAGrBd,IAAI8B,YAAY,CACnB,CAACC,IAAK,iBAAkBC,UAAW,SACnC,CAACD,IAAK,mBAAoBC,UAAW,SACrC,CAACD,IAAK,aAAcC,UAAW,UAC/B,CAACD,IAAK,SAAUC,UAAW,YAC5BJ,MAAK,SAASK,gBACblC,aAAamC,QACTD,QAAQ,GACRA,QAAQ,GACRA,QAAQ,GACRA,QAAQ,IACR,kBACIE,SAASC,UACF,MAGR,KACRC,MAAMtC,aAAauC,eAU9BC,aAAe,kBAMX9B,eAEOc,wBAEA1B,KAAK4B,KAAK,CATP,CACVC,WAAY,qBACZC,KAAM,MAOsB,GAAM,GAAM,EAAOpB,gBAAgB,GAAGqB,MAAK,kBACnEY,OAAOC,cAAc,IAAIC,YAAYvC,cAAcwC,kBAC5C,KACRN,OAAM,WACLG,OAAOC,cAAc,IAAIC,YAAYvC,cAAcyC,yBAW3DC,aAAe,kBAKfpC,gBAAiB,EACVZ,KAAK4B,KAAK,CALH,CACVC,WAAY,8BACZC,KAAM,MAGkB,GAAM,GAAM,GAAM,GAAGC,MAAK,SAASD,cACvDA,KAAKmB,QAAU,KAGfnB,KAAKE,eAAiB,EACfN,yBACqB,IAArBI,KAAKE,eAAwBf,eAAiBT,kBACrDA,kBAAmB,EACnBL,IAAI8B,YAAY,CACZ,CAACC,IAAK,mBAAoBC,UAAW,UACrC,CAACD,IAAK,qBAAsBC,UAAW,UACvC,CAACD,IAAK,gBAAiBC,UAAW,UAClC,CAACD,IAAK,SAAUC,UAAW,YAC5BJ,MAAK,SAASK,gBACLlC,aAAamC,QACjBD,QAAQ,GACRA,QAAQ,GACRA,QAAQ,GACRA,QAAQ,IACR,kBACIM,eACAlC,kBAAmB,EAEnB0C,WAAWF,aAAc7B,YAClB,KAEX,WAEI+B,WAAWF,aAAcnC,sBAGlCkB,MAAKP,QAEJ0B,WAAW3B,sBAA4C,IAArBO,KAAKE,cAAsBR,UAE9DgB,MAAMtC,aAAauC,YAEtBS,WAAWF,aAAcnC,iBAEtB,QASXsC,MAAQ,WACJ1C,mBAAqB,EACrByC,WAAWR,aAAcjC,oBAGzByC,WAAWF,aAAc7B,kBAS3BiC,eAAiB,cACfT,OAAOU,SAAWV,cAEX,MAIPW,cAEAA,UAAYX,OAAOU,OAAOf,SAASiB,KACrC,MAAOC,UAEE,SAGJF,UAAUG,WAAWC,EAAEC,IAAIC,cA0BlCC,UAAY,WACe,iBAAhBxC,YAIXsB,OAAOL,SAASC,SAHZI,OAAOL,SAASwB,QAAQzC,cAW5B0C,yBAA2B,cAIvBzC,kBAAoB,SACpBF,WAAW4C,OACXd,WAAWR,aAAcjC,yBACzBa,oBAOe,OAAfF,aACAA,WAAW6C,UAAUC,KAAK,0BAA0BC,OACpD/C,WAAW4C,OAAOjC,MAAK,kBACnBX,WAAW6C,UAAUG,GAAG/D,YAAYgE,OAAQR,YACrC,KACRrB,MAAMtC,aAAauC,aAQ1B6B,qBAAuB,WACnB7D,mBAAqB,IACrByC,WAAWR,aAAcjC,oBACzBa,kBAAoB,UA2DrB,CACHiD,UA/CYC,eAAeC,KAAMC,QAASC,WAAYxC,eAAWyC,gEAAW,QAExErE,QACAoC,OAAOkC,QAAQC,KAAK,yFAGxBvE,SAAU,EAEN6C,iBACAT,OAAOkC,QAAQC,KAAK,+EAIxBnC,OAAOkC,QAAQE,IAAI,uCAEnBtE,mBAA4B,IAAPgE,KACrB9D,uBAAyBR,IAAI6E,WAAWL,WAAYxC,WACpDzB,eAA2B,IAAVgE,QACjBrD,YAAcuD,aAEVxC,cAAgBjC,IAAI8B,YAAY,CAChC,CAACC,IAAK,kBAAmBC,UAAW,aACpC,CAACD,IAAK,YAAaC,UAAW,aAC9B,CAACD,IAAK,UAAWC,UAAW,aAGhCf,iBAAmBhB,gBAAgB6E,OAAO,CACtCC,MAAO9C,QAAQ,GACf+C,KAAMxE,iBACNyE,QAAS,CACLC,KAA6B,iBAAhBhE,YAA2Be,QAAQ,GAAKA,QAAQ,IAEjEkD,eAAe,EACftB,MAAM,KAKCC,UAAUG,GAAG/D,YAAYgF,KAAMxB,WAE1ClB,OAAO4C,iBAAiBjF,cAAcwC,eAAgBwB,sBACtD3B,OAAO4C,iBAAiBjF,cAAcyC,mBAAoBgB,0BAE1DZ,UAKAqC,KA5HO,WAEHjF,UAGJA,SAAU,EAEN6C,iBACAT,OAAOkC,QAAQE,IAAI,gEAIvBpC,OAAOkC,QAAQE,IAAI,4CAEnB5B"}
\ No newline at end of file
diff --git a/public/lib/amd/build/network_events.min.js b/public/lib/amd/build/network_events.min.js
new file mode 100644
index 00000000000..bd6fab67db6
--- /dev/null
+++ b/public/lib/amd/build/network_events.min.js
@@ -0,0 +1,3 @@
+define("core/network_events",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={sessionTouched:"network:sessiontouched",unstableConnection:"network:unstableconnection"},_exports.default}));
+
+//# sourceMappingURL=network_events.min.js.map
\ No newline at end of file
diff --git a/public/lib/amd/build/network_events.min.js.map b/public/lib/amd/build/network_events.min.js.map
new file mode 100644
index 00000000000..4100f7cc4a6
--- /dev/null
+++ b/public/lib/amd/build/network_events.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"network_events.min.js","sources":["../src/network_events.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Contains the events fired by the networking module.\n *\n * @module core/network_events\n * @copyright 2025 Sam Smucker \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n sessionTouched: 'network:sessiontouched',\n unstableConnection: 'network:unstableconnection',\n};\n"],"names":["sessionTouched","unstableConnection"],"mappings":"qKAsBe,CACXA,eAAgB,yBAChBC,mBAAoB"}
\ No newline at end of file
diff --git a/public/lib/amd/src/network.js b/public/lib/amd/src/network.js
index c8a7f86289f..cdefbf54c91 100644
--- a/public/lib/amd/src/network.js
+++ b/public/lib/amd/src/network.js
@@ -20,8 +20,26 @@
* @copyright 2019 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
- function($, Ajax, Config, Notification, Str) {
+define([
+ 'jquery',
+ 'core/ajax',
+ 'core/config',
+ 'core/notification',
+ 'core/str',
+ 'core/modal_save_cancel',
+ 'core/modal_events',
+ 'core/network_events',
+],
+function(
+ $,
+ Ajax,
+ Config,
+ Notification,
+ Str,
+ SaveCancelModal,
+ ModalEvents,
+ NetworkEvents,
+) {
var started = false;
var warningDisplayed = false;
@@ -36,6 +54,12 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
// First wait is minimum of remaining time or half of the session timeout.
var firstWait = (Config.sessiontimeoutwarning > 0) ?
Math.min((Config.sessiontimeout - Config.sessiontimeoutwarning) * 1000, checkFrequency * 5) : checkFrequency * 5;
+ // Modal to display when the connection is unstable.
+ var alertModal = null;
+ // URL to redirect to if network is unstable.
+ var redirectUrl = null;
+ // Number of times we have tried to touch the session after a network request failure.
+ var connectionRetries = 0;
/**
* The session time has expired - we can't extend it now.
* @param {Modal} modal
@@ -109,12 +133,10 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
return displaySessionExpired();
} else {
return Ajax.call([request], true, true, false, requestTimeout)[0].then(function() {
- if (keepAliveFrequency > 0) {
- setTimeout(touchSession, keepAliveFrequency);
- }
+ window.dispatchEvent(new CustomEvent(NetworkEvents.sessionTouched));
return true;
}).catch(function() {
- Notification.alert('', keepAliveMessage);
+ window.dispatchEvent(new CustomEvent(NetworkEvents.unstableConnection));
});
}
};
@@ -231,6 +253,56 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
start();
};
+ /**
+ * Leave the page, either by redirecting or reloading.
+ */
+ var leavePage = function() {
+ if (typeof redirectUrl === 'string') {
+ window.location.replace(redirectUrl);
+ return;
+ }
+ window.location.reload();
+ };
+
+ /**
+ * When the network connection is unstable show the user an alert.
+ * If the unstable connection persists, force the user to exit the
+ * current page.
+ */
+ var handleUnstableConnection = function() {
+ // Show the alert modal warning the user that their
+ // connection is unstable but allow them to continue
+ // interacting with the page if they choose.
+ if (connectionRetries < 1) {
+ alertModal.show();
+ setTimeout(touchSession, keepAliveFrequency);
+ connectionRetries++;
+ return;
+ }
+
+ // If we have failed to touch the session twice in a row,
+ // show the alert modal (if not already visible) but remove
+ // any option for the user to continue on this page.
+ if (alertModal !== null) {
+ alertModal.getRoot().find('[data-action="cancel"]').hide();
+ alertModal.show().then(function() {
+ alertModal.getRoot().on(ModalEvents.hidden, leavePage);
+ return true;
+ }).catch(Notification.exception);
+ }
+ };
+
+ /**
+ * When the session is touched, set a timeout to touch it again
+ * and reset the number of failed connection retries.
+ */
+ var handleSessionTouched = function() {
+ if (keepAliveFrequency > 0) {
+ setTimeout(touchSession, keepAliveFrequency);
+ connectionRetries = 0;
+ }
+ };
+
/**
* Start polling with more specific values for the frequency, timeout and message.
*
@@ -238,8 +310,9 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
* @param {number} timeout The time to wait for each request to the server.
* @param {string} identifier The string identifier for the message to show if session is going to time out.
* @param {string} component The string component for the message to show if session is going to time out.
+ * @param {string|null} redirect A url to redirect to in case of a lost connection to the server.
*/
- var keepalive = async function(freq, timeout, identifier, component) {
+ var keepalive = async function(freq, timeout, identifier, component, redirect = null) {
// We only allow one concurrent instance of this checker.
if (started) {
window.console.warn('Ignoring session keep-alive. The core/network module was already initialised.');
@@ -257,6 +330,31 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
keepAliveFrequency = freq * 1000;
keepAliveMessage = await Str.get_string(identifier, component);
requestTimeout = timeout * 1000;
+ redirectUrl = redirect;
+
+ var strings = await Str.get_strings([
+ {key: 'unstablenetwork', component: 'mod_scorm'},
+ {key: 'leavepage', component: 'mod_scorm'},
+ {key: 'refresh', component: 'moodle'},
+ ]);
+
+ alertModal = await SaveCancelModal.create({
+ title: strings[0],
+ body: keepAliveMessage,
+ buttons: {
+ save: typeof redirectUrl === 'string' ? strings[1] : strings[2],
+ },
+ removeOnClose: false,
+ show: false
+ });
+
+ // The save button is renamed and will redirect the user or
+ // reload the page when clicked.
+ alertModal.getRoot().on(ModalEvents.save, leavePage);
+
+ window.addEventListener(NetworkEvents.sessionTouched, handleSessionTouched);
+ window.addEventListener(NetworkEvents.unstableConnection, handleUnstableConnection);
+
start();
};
diff --git a/public/lib/amd/src/network_events.js b/public/lib/amd/src/network_events.js
new file mode 100644
index 00000000000..9f2d17fbfb5
--- /dev/null
+++ b/public/lib/amd/src/network_events.js
@@ -0,0 +1,26 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Contains the events fired by the networking module.
+ *
+ * @module core/network_events
+ * @copyright 2025 Sam Smucker
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+export default {
+ sessionTouched: 'network:sessiontouched',
+ unstableConnection: 'network:unstableconnection',
+};
diff --git a/public/lib/classes/session/manager.php b/public/lib/classes/session/manager.php
index aa5ef496f45..b814af9d4d4 100644
--- a/public/lib/classes/session/manager.php
+++ b/public/lib/classes/session/manager.php
@@ -1209,9 +1209,16 @@ class manager {
* @param string $component The string component for the message to show on failure.
* @param int $frequency The update frequency in seconds.
* @param int $timeout The timeout of each request in seconds.
- * @throws \coding_exception IF the frequency is longer than the session lifetime.
+ * @param \moodle_url|string|null $redirect A URL to redirect to if connection is lost.
+ * @throws \coding_exception If the frequency is longer than the session lifetime.
*/
- public static function keepalive($identifier = 'sessionerroruser', $component = 'error', $frequency = null, $timeout = 0) {
+ public static function keepalive(
+ $identifier = 'sessionerroruser',
+ $component = 'error',
+ $frequency = null,
+ $timeout = 0,
+ $redirect = null
+ ) {
global $CFG, $PAGE;
if ($frequency) {
@@ -1224,11 +1231,16 @@ class manager {
$frequency = $CFG->sessiontimeout / 10;
}
+ if ($redirect instanceof \moodle_url) {
+ $redirect = $redirect->out();
+ }
+
$PAGE->requires->js_call_amd('core/network', 'keepalive', array(
$frequency,
$timeout,
$identifier,
- $component
+ $component,
+ $redirect,
));
}
diff --git a/public/lib/tests/behat/behat_networking.php b/public/lib/tests/behat/behat_networking.php
new file mode 100644
index 00000000000..7a924055d20
--- /dev/null
+++ b/public/lib/tests/behat/behat_networking.php
@@ -0,0 +1,58 @@
+.
+
+/**
+ * Networking steps definitions
+ *
+ * @package core
+ * @category test
+ * @copyright 2025 Sam Smucker
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../behat/behat_base.php');
+
+/**
+ * Steps definitions to trigger Javascript events in the network AMD module.
+ *
+ * @package core
+ * @category test
+ * @copyright 2025 Sam Smucker
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_networking extends behat_base {
+ /**
+ * Trigger a custom Javascript event indicating network instability.
+ *
+ * @When my network is unstable
+ */
+ public function my_network_is_unstable() {
+ $this->getSession()
+ ->getDriver()
+ ->evaluateScript("window.dispatchEvent(new CustomEvent('network:unstableconnection'));");
+ }
+
+ /**
+ * Trigger a custom Javascript event indicating that the session has been touched.
+ *
+ * @When the session is touched
+ */
+ public function the_session_is_touched() {
+ $this->getSession()
+ ->getDriver()
+ ->evaluateScript("window.dispatchEvent(new CustomEvent('network:sessiontouched'));");
+ }
+}
diff --git a/public/mod/scorm/lang/en/scorm.php b/public/mod/scorm/lang/en/scorm.php
index d97ef9d00a6..7ff6019e12a 100644
--- a/public/mod/scorm/lang/en/scorm.php
+++ b/public/mod/scorm/lang/en/scorm.php
@@ -263,6 +263,7 @@ $string['lastattempt'] = 'Last completed attempt';
$string['lastattemptlock'] = 'Lock after final attempt';
$string['lastattemptlock_help'] = 'If enabled, a student is prevented from launching the SCORM player after using up all their allocated attempts.';
$string['lastattemptlockdesc'] = 'If enabled, a student is prevented from launching the SCORM player after using up all their allocated attempts.';
+$string['leavepage'] = 'Leave page';
$string['location'] = 'Show the location bar';
$string['max'] = 'Max score';
$string['maximumattempts'] = 'Number of attempts';
@@ -299,8 +300,8 @@ There are 3 options:
$string['navdesc'] = 'This setting specifies whether to show or hide navigation buttons and their position.';
$string['navpositionleft'] = 'Position of navigation buttons from left in pixels.';
$string['navpositiontop'] = 'Position of navigation buttons from top in pixels.';
-$string['networkdropped'] = 'The SCORM player has determined that your Internet connection is unreliable or has been interrupted. If you continue in this SCORM activity, your progress may not be saved.
-You should exit the activity now, and return when you have a dependable Internet connection.';
+$string['networkdropped'] = 'It looks like you have an unstable internet connection or your session has timed out. Your progress may not be saved. Please exit and relaunch this activity.';
+$string['networkdroppedmobile'] = 'It looks like you have an unstable internet connection or your session has timed out. Your progress may not be saved. Please exit and relaunch this activity. You may wish to return to this activity in the mobile app for a more stable connection.';
$string['newattempt'] = 'Start a new attempt';
$string['next'] = 'Continue';
$string['noactivity'] = 'Nothing to report';
@@ -439,6 +440,7 @@ $string['typeexternal'] = 'External SCORM manifest';
$string['typelocal'] = 'Uploaded package';
$string['typelocalsync'] = 'Downloaded package';
$string['undercontent'] = 'Under content';
+$string['unstablenetwork'] = 'Unstable network';
$string['unziperror'] = 'An error occurs during package unzip';
$string['updatefreq'] = 'Auto-update frequency';
$string['updatefreq_error'] = 'Auto-update frequency can only be set when the package file is hosted externally';
diff --git a/public/mod/scorm/player.php b/public/mod/scorm/player.php
index 28b5fe66056..2f3e7f52eae 100644
--- a/public/mod/scorm/player.php
+++ b/public/mod/scorm/player.php
@@ -309,7 +309,9 @@ if (file_exists($CFG->dirroot.'/mod/scorm/datamodels/'.$scorm->version.'.php'))
}
// Add the keepalive system to keep checking for a connection.
-\core\session\manager::keepalive('networkdropped', 'mod_scorm', 30, 10);
+$redirecturl = new moodle_url('/mod/scorm/view.php', ['id' => $cm->id]);
+$stringname = get_config('core', 'enablemobilewebservice') ? 'networkdroppedmobile' : 'networkdropped';
+\core\session\manager::keepalive($stringname, 'mod_scorm', 30, 10, $redirecturl);
echo $OUTPUT->footer();
diff --git a/public/mod/scorm/tests/behat/unstable_network.feature b/public/mod/scorm/tests/behat/unstable_network.feature
new file mode 100644
index 00000000000..0e528683c6b
--- /dev/null
+++ b/public/mod/scorm/tests/behat/unstable_network.feature
@@ -0,0 +1,69 @@
+@mod @mod_scorm
+Feature: Unstable network
+ In order to avoid losing progress in a SCORM package
+ As a learner
+ I need to be notified if my network is unstable
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | student1 | Student | 1 | student1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | student1 | C1 | student |
+ And the following "activities" exist:
+ | activity | course | name | popup |
+ | scorm | C1 | Same window SCORM | 0 |
+ | scorm | C1 | New window SCORM | 1 |
+
+ @javascript
+ Scenario: The unstable network modal renders on the SCORM player page when network is unstable
+ When I am on the "Same window SCORM" "scorm activity" page logged in as "student1"
+ And I press "Enter"
+ And my network is unstable
+ Then I should see "It looks like you have an unstable internet connection or your session has timed out"
+ And I should see "Cancel" in the "Unstable network" "dialogue"
+
+ @javascript
+ Scenario: The unstable network modal can be closed without a redirect the first time it is shown
+ When I am on the "Same window SCORM" "scorm activity" page logged in as "student1"
+ And I press "Enter"
+ And my network is unstable
+ And I click on "Cancel" "button" in the "Unstable network" "dialogue"
+ Then I should not see "It looks like you have an unstable internet connection or your session has timed out"
+ And I should not see "Enter"
+
+ @javascript
+ Scenario: The unstable network modal cannot be closed without a redirect the second time it is shown
+ When I am on the "Same window SCORM" "scorm activity" page logged in as "student1"
+ And I press "Enter"
+ And my network is unstable
+ And my network is unstable
+ Then I should not see "Cancel" in the "Unstable network" "dialogue"
+ And I click on "Leave page" "button" in the "Unstable network" "dialogue"
+ And I should see "Enter"
+
+ @javascript
+ Scenario: Unstable network must be detected twice consecutively before a user must leave the SCORM activity
+ When I am on the "Same window SCORM" "scorm activity" page logged in as "student1"
+ And I press "Enter"
+ And my network is unstable
+ And I click on "Cancel" "button" in the "Unstable network" "dialogue"
+ And the session is touched
+ And my network is unstable
+ Then I should see "Cancel" in the "Unstable network" "dialogue"
+ And my network is unstable
+ And I should not see "Cancel" in the "Unstable network" "dialogue"
+
+ @javascript @_switch_window
+ Scenario: Unstable network modal redirects correctly when SCORM opens in new window
+ When I am on the "New window SCORM" "scorm activity" page logged in as "student1"
+ And I press "Enter"
+ And I switch to a second window
+ And my network is unstable
+ And I click on "Leave page" "button" in the "Unstable network" "dialogue"
+ Then I should see "Enter"
+ And I should see "New window SCORM"