diff --git a/SafeExamBrowser.Proctoring/ProctoringImplementation.cs b/SafeExamBrowser.Proctoring/ProctoringImplementation.cs
index 6be6625eb..9252c3aaa 100644
--- a/SafeExamBrowser.Proctoring/ProctoringImplementation.cs
+++ b/SafeExamBrowser.Proctoring/ProctoringImplementation.cs
@@ -39,7 +39,7 @@ void INotification.Terminate()
internal abstract void Stop();
internal abstract void Terminate();
- protected abstract void ActivateNotification();
- protected abstract void TerminateNotification();
+ protected virtual void ActivateNotification() { }
+ protected virtual void TerminateNotification() { }
}
}
diff --git a/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj b/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj
index e0e536c25..4ffa2b824 100644
--- a/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj
+++ b/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj
@@ -94,23 +94,27 @@
+
+
+
+
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Data/Metadata.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Data/Metadata.cs
index 25ec62060..b64f70907 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/Data/Metadata.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Data/Metadata.cs
@@ -7,7 +7,6 @@
*/
using System;
-using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
@@ -28,14 +27,16 @@ internal class Metadata
internal string ApplicationInfo { get; private set; }
internal string BrowserInfo { get; private set; }
+ internal TimeSpan Elapsed { get; private set; }
internal string TriggerInfo { get; private set; }
internal string Urls { get; private set; }
internal string WindowTitle { get; private set; }
- internal Metadata(IApplicationMonitor applicationMonitor, IBrowserApplication browser, ILogger logger)
+ internal Metadata(IApplicationMonitor applicationMonitor, IBrowserApplication browser, TimeSpan elapsed, ILogger logger)
{
this.applicationMonitor = applicationMonitor;
this.browser = browser;
+ this.Elapsed = elapsed;
this.logger = logger;
}
@@ -57,6 +58,7 @@ internal void Capture(IntervalTrigger interval = default, KeyboardTrigger keyboa
CaptureMouseTrigger(mouse);
}
+ // TODO: Can only log URLs when allowed by policy in browser configuration!
logger.Debug($"Captured metadata: {ApplicationInfo} / {BrowserInfo} / {TriggerInfo} / {Urls} / {WindowTitle}.");
}
@@ -79,7 +81,7 @@ private void CaptureApplicationData()
if (applicationMonitor.TryGetActiveApplication(out var application))
{
ApplicationInfo = BuildApplicationInfo(application);
- WindowTitle = BuildWindowTitle(application);
+ WindowTitle = string.IsNullOrEmpty(application.Window.Title) ? "-" : application.Window.Title;
}
else
{
@@ -88,46 +90,12 @@ private void CaptureApplicationData()
}
}
- private string BuildApplicationInfo(ActiveApplication application)
- {
- var info = new StringBuilder();
-
- info.Append(application.Process.Name);
-
- if (application.Process.OriginalName != default)
- {
- info.Append($" ({application.Process.OriginalName}{(application.Process.Signature == default ? ")" : "")}");
- }
-
- if (application.Process.Signature != default)
- {
- info.Append($"{(application.Process.OriginalName == default ? "(" : ", ")}{application.Process.Signature})");
- }
-
- return info.ToString();
- }
-
- private string BuildWindowTitle(ActiveApplication application)
- {
- return string.IsNullOrEmpty(application.Window.Title) ? "-" : application.Window.Title;
- }
-
private void CaptureBrowserData()
{
var windows = browser.GetWindows();
- BrowserInfo = BuildBrowserInfo(windows);
- Urls = BuildUrls(windows);
- }
-
- private string BuildUrls(IEnumerable windows)
- {
- return string.Join(", ", windows.Select(w => w.Url));
- }
-
- private string BuildBrowserInfo(IEnumerable windows)
- {
- return string.Join(", ", windows.Select(w => $"{(w.IsMainWindow ? "Main" : "Additional")} Window: {w.Title} ({w.Url})"));
+ BrowserInfo = string.Join(", ", windows.Select(w => $"{(w.IsMainWindow ? "Main" : "Additional")} Window: {w.Title} ({w.Url})"));
+ Urls = string.Join(", ", windows.Select(w => w.Url));
}
private void CaptureIntervalTrigger(IntervalTrigger interval)
@@ -154,5 +122,24 @@ private void CaptureMouseTrigger(MouseTrigger mouse)
TriggerInfo = $"{mouse.Button} mouse button has been {mouse.State.ToString().ToLower()} at ({mouse.Info.X}/{mouse.Info.Y}).";
}
}
+
+ private string BuildApplicationInfo(ActiveApplication application)
+ {
+ var info = new StringBuilder();
+
+ info.Append(application.Process.Name);
+
+ if (application.Process.OriginalName != default)
+ {
+ info.Append($" ({application.Process.OriginalName}{(application.Process.Signature == default ? ")" : "")}");
+ }
+
+ if (application.Process.Signature != default)
+ {
+ info.Append($"{(application.Process.OriginalName == default ? "(" : ", ")}{application.Process.Signature})");
+ }
+
+ return info.ToString();
+ }
}
}
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/DataCollector.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/DataCollector.cs
new file mode 100644
index 000000000..eddbffcf0
--- /dev/null
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/DataCollector.cs
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET)
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Timers;
+using System.Windows.Input;
+using SafeExamBrowser.Browser.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.Monitoring.Contracts.Applications;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Events;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
+using SafeExamBrowser.Settings.Proctoring;
+using SafeExamBrowser.WindowsApi.Contracts;
+using SafeExamBrowser.WindowsApi.Contracts.Events;
+using MouseButton = SafeExamBrowser.WindowsApi.Contracts.Events.MouseButton;
+using MouseButtonState = SafeExamBrowser.WindowsApi.Contracts.Events.MouseButtonState;
+using Timer = System.Timers.Timer;
+
+namespace SafeExamBrowser.Proctoring.ScreenProctoring
+{
+ internal class DataCollector
+ {
+ private readonly object @lock = new object();
+
+ private readonly IApplicationMonitor applicationMonitor;
+ private readonly IBrowserApplication browser;
+ private readonly IModuleLogger logger;
+ private readonly INativeMethods nativeMethods;
+ private readonly ScreenProctoringSettings settings;
+ private readonly Timer timer;
+
+ private DateTime last;
+ private Guid? keyboardHookId;
+ private Guid? mouseHookId;
+
+ internal event DataCollectedEventHandler DataCollected;
+
+ internal DataCollector(
+ IApplicationMonitor applicationMonitor,
+ IBrowserApplication browser,
+ IModuleLogger logger,
+ INativeMethods nativeMethods,
+ ScreenProctoringSettings settings)
+ {
+ this.applicationMonitor = applicationMonitor;
+ this.browser = browser;
+ this.logger = logger;
+ this.nativeMethods = nativeMethods;
+ this.settings = settings;
+ this.timer = new Timer();
+ }
+
+ internal void Start()
+ {
+ last = DateTime.Now;
+
+ keyboardHookId = nativeMethods.RegisterKeyboardHook(KeyboardHookCallback);
+ mouseHookId = nativeMethods.RegisterMouseHook(MouseHookCallback);
+
+ timer.AutoReset = false;
+ timer.Elapsed += MaxIntervalElapsed;
+ timer.Interval = settings.MaxInterval;
+ timer.Start();
+
+ logger.Debug("Started.");
+ }
+
+ internal void Stop()
+ {
+ last = DateTime.Now;
+
+ if (keyboardHookId.HasValue)
+ {
+ nativeMethods.DeregisterKeyboardHook(keyboardHookId.Value);
+ }
+
+ if (mouseHookId.HasValue)
+ {
+ nativeMethods.DeregisterMouseHook(mouseHookId.Value);
+ }
+
+ keyboardHookId = default;
+ mouseHookId = default;
+
+ timer.Elapsed -= MaxIntervalElapsed;
+ timer.Stop();
+
+ logger.Debug("Stopped.");
+ }
+
+ private bool KeyboardHookCallback(int keyCode, KeyModifier modifier, KeyState state)
+ {
+ var trigger = new KeyboardTrigger
+ {
+ Key = KeyInterop.KeyFromVirtualKey(keyCode),
+ Modifier = modifier,
+ State = state
+ };
+
+ TryCollect(keyboard: trigger);
+
+ return false;
+ }
+
+ private void MaxIntervalElapsed(object sender, ElapsedEventArgs args)
+ {
+ var trigger = new IntervalTrigger
+ {
+ ConfigurationValue = settings.MaxInterval,
+ TimeElapsed = Convert.ToInt32(DateTime.Now.Subtract(last).TotalMilliseconds)
+ };
+
+ TryCollect(interval: trigger);
+ }
+
+ private bool MouseHookCallback(MouseButton button, MouseButtonState state, MouseInformation info)
+ {
+ var trigger = new MouseTrigger
+ {
+ Button = button,
+ Info = info,
+ State = state
+ };
+
+ TryCollect(mouse: trigger);
+
+ return false;
+ }
+
+ private void TryCollect(IntervalTrigger interval = default, KeyboardTrigger keyboard = default, MouseTrigger mouse = default)
+ {
+ if (MinIntervalElapsed() && Monitor.TryEnter(@lock))
+ {
+ var elapsed = DateTime.Now.Subtract(last);
+
+ last = DateTime.Now;
+ timer.Stop();
+
+ Task.Run(() =>
+ {
+ try
+ {
+ var metadata = new Metadata(applicationMonitor, browser, elapsed, logger.CloneFor(nameof(Metadata)));
+ var screenShot = new ScreenShot(logger.CloneFor(nameof(ScreenShot)), settings);
+
+ metadata.Capture(interval, keyboard, mouse);
+ screenShot.Take();
+ screenShot.Compress();
+
+ DataCollected?.Invoke(metadata, screenShot);
+ }
+ catch (Exception e)
+ {
+ logger.Error("Failed to execute data collection!", e);
+ }
+ });
+
+ timer.Start();
+ Monitor.Exit(@lock);
+ }
+ }
+
+ private bool MinIntervalElapsed()
+ {
+ return DateTime.Now.Subtract(last) >= new TimeSpan(0, 0, 0, 0, settings.MinInterval);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Events/DataCollectedEventHandler.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Events/DataCollectedEventHandler.cs
new file mode 100644
index 000000000..5bc3d9eb7
--- /dev/null
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Events/DataCollectedEventHandler.cs
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET)
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
+
+namespace SafeExamBrowser.Proctoring.ScreenProctoring.Events
+{
+ internal delegate void DataCollectedEventHandler(Metadata metadata, ScreenShot screenShot);
+}
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs
index 2fd2f8de9..af3ba8ed4 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs
@@ -7,10 +7,6 @@
*/
using System;
-using System.Threading;
-using System.Threading.Tasks;
-using System.Timers;
-using System.Windows.Input;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Core.Contracts.Notifications.Events;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
@@ -23,29 +19,17 @@
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.WindowsApi.Contracts;
-using SafeExamBrowser.WindowsApi.Contracts.Events;
-using MouseButton = SafeExamBrowser.WindowsApi.Contracts.Events.MouseButton;
-using MouseButtonState = SafeExamBrowser.WindowsApi.Contracts.Events.MouseButtonState;
-using Timer = System.Timers.Timer;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class ScreenProctoringImplementation : ProctoringImplementation
{
- private readonly object @lock = new object();
-
- private readonly IApplicationMonitor applicationMonitor;
- private readonly IBrowserApplication browser;
+ private readonly DataCollector collector;
private readonly IModuleLogger logger;
- private readonly INativeMethods nativeMethods;
private readonly ServiceProxy service;
private readonly ScreenProctoringSettings settings;
+ private readonly TransmissionSpooler spooler;
private readonly IText text;
- private readonly Timer timer;
-
- private DateTime last;
- private Guid? keyboardHookId;
- private Guid? mouseHookId;
internal override string Name => nameof(ScreenProctoring);
@@ -60,14 +44,12 @@ internal ScreenProctoringImplementation(
ProctoringSettings settings,
IText text)
{
- this.applicationMonitor = applicationMonitor;
- this.browser = browser;
+ this.collector = new DataCollector(applicationMonitor, browser, logger.CloneFor(nameof(DataCollector)), nativeMethods, settings.ScreenProctoring);
this.logger = logger;
- this.nativeMethods = nativeMethods;
this.service = service;
this.settings = settings.ScreenProctoring;
+ this.spooler = new TransmissionSpooler(logger.CloneFor(nameof(TransmissionSpooler)), service);
this.text = text;
- this.timer = new Timer();
}
internal override void Initialize()
@@ -79,9 +61,6 @@ internal override void Initialize()
start &= !string.IsNullOrWhiteSpace(settings.GroupId);
start &= !string.IsNullOrWhiteSpace(settings.ServiceUrl);
- timer.AutoReset = false;
- timer.Interval = settings.MaxInterval;
-
if (start)
{
logger.Info($"Initialized proctoring: All settings are valid, starting automatically...");
@@ -91,7 +70,7 @@ internal override void Initialize()
}
else
{
- ShowNotificationInactive();
+ UpdateNotification(false);
logger.Info($"Initialized proctoring: Not all settings are valid or a server session is active, not starting automatically.");
}
}
@@ -125,60 +104,43 @@ internal override void ProctoringInstructionReceived(InstructionEventArgs args)
logger.Info("Successfully processed instruction.");
}
}
+
internal override void Start()
{
- last = DateTime.Now;
- keyboardHookId = nativeMethods.RegisterKeyboardHook(KeyboardHookCallback);
- mouseHookId = nativeMethods.RegisterMouseHook(MouseHookCallback);
+ collector.DataCollected += Collector_DataCollected;
+ collector.Start();
+ spooler.Start();
- timer.Elapsed += Timer_Elapsed;
- timer.Start();
-
- ShowNotificationActive();
+ UpdateNotification(true);
logger.Info($"Started proctoring.");
}
internal override void Stop()
{
- if (keyboardHookId.HasValue)
- {
- nativeMethods.DeregisterKeyboardHook(keyboardHookId.Value);
- }
+ collector.Stop();
+ collector.DataCollected -= Collector_DataCollected;
+ spooler.Stop();
- if (mouseHookId.HasValue)
- {
- nativeMethods.DeregisterMouseHook(mouseHookId.Value);
- }
-
- keyboardHookId = default;
- mouseHookId = default;
-
- timer.Elapsed -= Timer_Elapsed;
- timer.Stop();
-
- TerminateServiceSession();
- ShowNotificationInactive();
+ TerminateSession();
+ UpdateNotification(false);
logger.Info("Stopped proctoring.");
}
internal override void Terminate()
{
+ // TODO: Cache transmission or user information!
+
Stop();
TerminateNotification();
logger.Info("Terminated proctoring.");
}
- protected override void ActivateNotification()
- {
- // Nothing to do here for now...
- }
-
- protected override void TerminateNotification()
+ private void Collector_DataCollected(Metadata metadata, ScreenShot screenShot)
{
- // Nothing to do here for now...
+ spooler.Add(metadata, screenShot);
}
private void Connect(string sessionId = default)
@@ -201,52 +163,7 @@ private void Connect(string sessionId = default)
}
}
- private bool KeyboardHookCallback(int keyCode, KeyModifier modifier, KeyState state)
- {
- var trigger = new KeyboardTrigger
- {
- Key = KeyInterop.KeyFromVirtualKey(keyCode),
- Modifier = modifier,
- State = state
- };
-
- TryExecute(keyboard: trigger);
-
- return false;
- }
-
- private bool MouseHookCallback(MouseButton button, MouseButtonState state, MouseInformation info)
- {
- var trigger = new MouseTrigger
- {
- Button = button,
- Info = info,
- State = state
- };
-
- TryExecute(mouse: trigger);
-
- return false;
- }
-
- private void ShowNotificationActive()
- {
- // TODO: Replace with actual icon!
- // TODO: Extend INotification with IsEnabled or CanActivate, as the screen proctoring notification does not have any action or window!
- IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Active.xaml") };
- Tooltip = text.Get(TextKey.Notification_ProctoringActiveTooltip);
- NotificationChanged?.Invoke();
- }
-
- private void ShowNotificationInactive()
- {
- // TODO: Replace with actual icon!
- IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Inactive.xaml") };
- Tooltip = text.Get(TextKey.Notification_ProctoringInactiveTooltip);
- NotificationChanged?.Invoke();
- }
-
- private void TerminateServiceSession()
+ private void TerminateSession()
{
if (service.IsConnected)
{
@@ -255,60 +172,24 @@ private void TerminateServiceSession()
}
}
- private void Timer_Elapsed(object sender, ElapsedEventArgs args)
+ private void UpdateNotification(bool live)
{
- var trigger = new IntervalTrigger
- {
- ConfigurationValue = settings.MaxInterval,
- TimeElapsed = Convert.ToInt32(DateTime.Now.Subtract(last).TotalMilliseconds)
- };
-
- TryExecute(interval: trigger);
- }
+ // TODO: Replace with actual icon!
+ // TODO: Extend INotification with IsEnabled or CanActivate!
+ // TODO: Service health, HD space and caching indicators!
- private void TryExecute(IntervalTrigger interval = default, KeyboardTrigger keyboard = default, MouseTrigger mouse = default)
- {
- if (MinimumIntervalElapsed() && Monitor.TryEnter(@lock))
+ if (live)
{
- last = DateTime.Now;
- timer.Stop();
-
- Task.Run(() =>
- {
- try
- {
- var metadata = new Metadata(applicationMonitor, browser, logger.CloneFor(nameof(Metadata)));
-
- using (var screenShot = new ScreenShot(logger.CloneFor(nameof(ScreenShot)), settings))
- {
- metadata.Capture(interval, keyboard, mouse);
- screenShot.Take();
- screenShot.Compress();
-
- if (service.IsConnected)
- {
- service.Send(metadata, screenShot);
- }
- else
- {
- logger.Warn("Cannot send screen shot as service is disconnected!");
- }
- }
- }
- catch (Exception e)
- {
- logger.Error("Failed to execute capturing and/or transmission!", e);
- }
- });
-
- timer.Start();
- Monitor.Exit(@lock);
+ IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Active.xaml") };
+ Tooltip = text.Get(TextKey.Notification_ProctoringActiveTooltip);
+ }
+ else
+ {
+ IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Inactive.xaml") };
+ Tooltip = text.Get(TextKey.Notification_ProctoringInactiveTooltip);
}
- }
- private bool MinimumIntervalElapsed()
- {
- return DateTime.Now.Subtract(last) >= new TimeSpan(0, 0, 0, 0, settings.MinInterval);
+ NotificationChanged?.Invoke();
}
}
}
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs
index e3c9d7250..6ec3d7601 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs
@@ -13,12 +13,14 @@ internal class Api
internal const string SESSION_ID = "%%_SESSION_ID_%%";
internal string AccessTokenEndpoint { get; set; }
+ internal string HealthEndpoint { get; set; }
internal string ScreenShotEndpoint { get; set; }
internal string SessionEndpoint { get; set; }
internal Api()
{
AccessTokenEndpoint = "/oauth/token";
+ HealthEndpoint = "/health";
ScreenShotEndpoint = $"/seb-api/v1/session/{SESSION_ID}/screenshot";
SessionEndpoint = "/seb-api/v1/session";
}
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs
index 762fafdab..b7036eec7 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs
@@ -46,6 +46,20 @@ internal bool IsTokenExpired(HttpContent content)
return isExpired;
}
+ internal bool TryParseHealth(HttpResponseMessage response, out int health)
+ {
+ var success = false;
+
+ health = default;
+
+ if (response.Headers.TryGetValues(Header.HEALTH, out var values))
+ {
+ success = int.TryParse(values.First(), out health);
+ }
+
+ return success;
+ }
+
internal bool TryParseOauth2Token(HttpContent content, out string oauth2Token)
{
oauth2Token = default;
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs
index 06b67825c..1d21127f5 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs
@@ -13,6 +13,7 @@ internal static class Header
internal const string ACCEPT = "Accept";
internal const string AUTHORIZATION = "Authorization";
internal const string GROUP_ID = "SEB_GROUP_UUID";
+ internal const string HEALTH = "sps_server_health";
internal const string IMAGE_FORMAT = "imageFormat";
internal const string METADATA = "metaData";
internal const string SESSION_ID = "SEB_SESSION_UUID";
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/HealthRequest.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/HealthRequest.cs
new file mode 100644
index 000000000..11631f0b5
--- /dev/null
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/HealthRequest.cs
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET)
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System.Net.Http;
+using SafeExamBrowser.Logging.Contracts;
+
+namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
+{
+ internal class HealthRequest : Request
+ {
+ internal HealthRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser)
+ {
+ }
+
+ internal bool TryExecute(out int health, out string message)
+ {
+ var url = api.HealthEndpoint;
+ var success = TryExecute(HttpMethod.Get, url, out var response);
+
+ health = default;
+ message = response.ToLogString();
+
+ if (success)
+ {
+ parser.TryParseHealth(response, out health);
+ }
+
+ return success;
+ }
+ }
+}
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs
index 5b75f9f60..7a20d8e01 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs
@@ -23,11 +23,11 @@ internal ScreenShotRequest(Api api, HttpClient httpClient, ILogger logger, Parse
internal bool TryExecute(Metadata metadata, ScreenShot screenShot, string sessionId, out string message)
{
- var data = (Header.METADATA, metadata.ToJson());
var imageFormat = (Header.IMAGE_FORMAT, ToString(screenShot.Format));
+ var metdataJson = (Header.METADATA, metadata.ToJson());
var timestamp = (Header.TIMESTAMP, DateTime.Now.ToUnixTimestamp().ToString());
var url = api.ScreenShotEndpoint.Replace(Api.SESSION_ID, sessionId);
- var success = TryExecute(HttpMethod.Post, url, out var response, screenShot.Data, ContentType.OCTET_STREAM, Authorization, data, imageFormat, timestamp);
+ var success = TryExecute(HttpMethod.Post, url, out var response, screenShot.Data, ContentType.OCTET_STREAM, Authorization, imageFormat, metdataJson, timestamp);
message = response.ToLogString();
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs
index e9a7e2085..edddad64b 100644
--- a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs
@@ -70,6 +70,23 @@ internal ServiceResponse CreateSession(string groupId)
return new ServiceResponse(success, message);
}
+ internal ServiceResponse GetHealth()
+ {
+ var request = new HealthRequest(api, httpClient, logger, parser);
+ var success = request.TryExecute(out var health, out var message);
+
+ if (success)
+ {
+ logger.Info($"Successfully queried health (value: {health}).");
+ }
+ else
+ {
+ logger.Error("Failed to query health!");
+ }
+
+ return new ServiceResponse(success, health, message);
+ }
+
internal ServiceResponse Send(Metadata metadata, ScreenShot screenShot)
{
var request = new ScreenShotRequest(api, httpClient, logger, parser);
diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/TransmissionSpooler.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/TransmissionSpooler.cs
new file mode 100644
index 000000000..4a1129a34
--- /dev/null
+++ b/SafeExamBrowser.Proctoring/ScreenProctoring/TransmissionSpooler.cs
@@ -0,0 +1,410 @@
+/*
+ * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET)
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Timers;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
+using SafeExamBrowser.Proctoring.ScreenProctoring.Service;
+using Timer = System.Timers.Timer;
+
+namespace SafeExamBrowser.Proctoring.ScreenProctoring
+{
+ internal class TransmissionSpooler
+ {
+ const int BAD = 10;
+ const int GOOD = 0;
+
+ private readonly ILogger logger;
+ private readonly ConcurrentQueue<(Metadata metadata, ScreenShot screenShot)> queue;
+ private readonly Random random;
+ private readonly ServiceProxy service;
+ private readonly Timer timer;
+
+ private Queue<(Metadata metadata, DateTime schedule, ScreenShot screenShot)> buffer;
+ private int health;
+ private bool recovering;
+ private DateTime resume;
+ private Thread thread;
+ private CancellationTokenSource token;
+
+ internal TransmissionSpooler(ILogger logger, ServiceProxy service)
+ {
+ this.buffer = new Queue<(Metadata, DateTime, ScreenShot)>();
+ this.logger = logger;
+ this.queue = new ConcurrentQueue<(Metadata, ScreenShot)>();
+ this.random = new Random();
+ this.service = service;
+ this.timer = new Timer();
+ }
+
+ internal void Add(Metadata metadata, ScreenShot screenShot)
+ {
+ queue.Enqueue((metadata, screenShot));
+ }
+
+ internal void Start()
+ {
+ const int FIFTEEN_SECONDS = 15000;
+
+ logger.Debug("Starting...");
+
+ health = GOOD;
+ recovering = false;
+ resume = default;
+ token = new CancellationTokenSource();
+
+ thread = new Thread(Execute);
+ thread.IsBackground = true;
+ thread.Start();
+
+ timer.AutoReset = false;
+ timer.Elapsed += Timer_Elapsed;
+ timer.Interval = 2000;
+ // TODO: Revert!
+ // timer.Interval = FIFTEEN_SECONDS;
+ timer.Start();
+ }
+
+ internal void Stop()
+ {
+ const int TEN_SECONDS = 10000;
+
+ if (thread != default)
+ {
+ logger.Debug("Stopping...");
+
+ timer.Elapsed -= Timer_Elapsed;
+ timer.Stop();
+
+ try
+ {
+ token.Cancel();
+ }
+ catch (Exception e)
+ {
+ logger.Error("Failed to initiate cancellation!", e);
+ }
+
+ try
+ {
+ var success = thread.Join(TEN_SECONDS);
+
+ if (!success)
+ {
+ thread.Abort();
+ logger.Warn($"Aborted since stopping gracefully within {TEN_SECONDS / 1000:N0} seconds failed!");
+ }
+ }
+ catch (Exception e)
+ {
+ logger.Error("Failed to stop!", e);
+ }
+
+ resume = default;
+ thread = default;
+ token = default;
+ }
+ }
+
+ private void Execute()
+ {
+ logger.Debug("Ready.");
+
+ while (!token.IsCancellationRequested)
+ {
+ if (health == BAD)
+ {
+ ExecuteCacheOnly();
+ }
+ else if (recovering)
+ {
+ ExecuteRecovery();
+ }
+ else if (health == GOOD)
+ {
+ ExecuteNormally();
+ }
+ else
+ {
+ ExecuteDeferred();
+ }
+
+ Thread.Sleep(50);
+ }
+
+ logger.Debug("Stopped.");
+ }
+
+ private void ExecuteCacheOnly()
+ {
+ const int THREE_MINUTES = 180;
+
+ if (!recovering)
+ {
+ recovering = true;
+ resume = DateTime.Now.AddSeconds(random.Next(0, THREE_MINUTES));
+
+ logger.Warn($"Activating local caching and suspending transmission due to bad service health (value: {health}, resume: {resume:HH:mm:ss}).");
+ }
+
+ CacheBuffer();
+ CacheFromQueue();
+ }
+
+ private void ExecuteDeferred()
+ {
+ Schedule(health);
+
+ if (TryPeekFromBuffer(out _, out var schedule, out _) && schedule <= DateTime.Now)
+ {
+ TryTransmitFromBuffer();
+ }
+ }
+
+ private void ExecuteNormally()
+ {
+ TryTransmitFromBuffer();
+ TryTransmitFromCache();
+ TryTransmitFromQueue();
+ }
+
+ private void ExecuteRecovery()
+ {
+ CacheFromQueue();
+ recovering = DateTime.Now < resume;
+
+ if (!recovering)
+ {
+ logger.Info($"Deactivating local caching and resuming transmission due to improved service health (value: {health}).");
+ }
+ }
+
+ private void Buffer(Metadata metadata, DateTime schedule, ScreenShot screenShot)
+ {
+ buffer.Enqueue((metadata, schedule, screenShot));
+ buffer = new Queue<(Metadata, DateTime, ScreenShot)>(buffer.OrderBy((b) => b.schedule));
+
+ // TODO: Remove!
+ PrintBuffer();
+ }
+
+ private void PrintBuffer()
+ {
+ logger.Log("-------------------------------------------------------------------------------------------------------");
+ logger.Info($"Buffer: {buffer.Count}");
+
+ foreach (var (m, t, s) in buffer)
+ {
+ logger.Log($"\t\t{t} ({m.Elapsed} {s.Data.Length})");
+ }
+
+ logger.Log("-------------------------------------------------------------------------------------------------------");
+ }
+
+ private void CacheBuffer()
+ {
+ foreach (var (metadata, _, screenShot) in buffer)
+ {
+ using (screenShot)
+ {
+ Cache(metadata, screenShot);
+ }
+ }
+
+ // TODO: Revert!
+ // buffer.Clear();
+ }
+
+ private void CacheFromQueue()
+ {
+ if (TryDequeue(out var metadata, out var screenShot))
+ {
+ using (screenShot)
+ {
+ Cache(metadata, screenShot);
+ }
+ }
+ }
+
+ private void Cache(Metadata metadata, ScreenShot screenShot)
+ {
+ // TODO: Implement caching!
+ //var directory = Dispatcher.Invoke(() => OutputPath.Text);
+ //var extension = screenShot.Format.ToString().ToLower();
+ //var path = Path.Combine(directory, $"{DateTime.Now:HH\\hmm\\mss\\sfff\\m\\s}.{extension}");
+
+ //if (!Directory.Exists(directory))
+ //{
+ // Directory.CreateDirectory(directory);
+ // logger.Debug($"Created local output directory '{directory}'.");
+ //}
+
+ //File.WriteAllBytes(path, screenShot.Data);
+ //logger.Debug($"Screen shot saved as '{path}'.");
+ }
+
+ private void Schedule(int health)
+ {
+ if (TryDequeue(out var metadata, out var screenShot))
+ {
+ var schedule = DateTime.Now.AddMilliseconds((health + 1) * metadata.Elapsed.TotalMilliseconds);
+
+ Buffer(metadata, schedule, screenShot);
+ }
+ }
+
+ private bool TryDequeue(out Metadata metadata, out ScreenShot screenShot)
+ {
+ metadata = default;
+ screenShot = default;
+
+ if (queue.TryDequeue(out var item))
+ {
+ metadata = item.metadata;
+ screenShot = item.screenShot;
+ }
+
+ return metadata != default && screenShot != default;
+ }
+
+ private bool TryPeekFromBuffer(out Metadata metadata, out DateTime schedule, out ScreenShot screenShot)
+ {
+ metadata = default;
+ schedule = default;
+ screenShot = default;
+
+ if (buffer.Any())
+ {
+ (metadata, schedule, screenShot) = buffer.Peek();
+ }
+
+ return metadata != default && screenShot != default;
+ }
+
+ private bool TryTransmitFromBuffer()
+ {
+ var success = false;
+
+ if (TryPeekFromBuffer(out var metadata, out _, out var screenShot))
+ {
+ // TODO: Exception after sending of screenshot, most likely due to concurrent disposal!!
+ success = TryTransmit(metadata, screenShot);
+
+ if (success)
+ {
+ buffer.Dequeue();
+ screenShot.Dispose();
+
+ // TODO: Revert!
+ PrintBuffer();
+ }
+ }
+
+ return success;
+ }
+
+ private bool TryTransmitFromCache()
+ {
+ var success = false;
+
+ // TODO: Implement transmission from cache!
+ //if (Cache.Any())
+ //{
+ //
+ //}
+ //else
+ //{
+ // success = true;
+ //}
+
+ return success;
+ }
+
+ private bool TryTransmitFromQueue()
+ {
+ var success = false;
+
+ if (TryDequeue(out var metadata, out var screenShot))
+ {
+ success = TryTransmit(metadata, screenShot);
+
+ if (success)
+ {
+ screenShot.Dispose();
+ }
+ else
+ {
+ Buffer(metadata, DateTime.Now, screenShot);
+ }
+ }
+
+ return success;
+ }
+
+ private bool TryTransmit(Metadata metadata, ScreenShot screenShot)
+ {
+ var success = false;
+
+ if (service.IsConnected)
+ {
+ success = service.Send(metadata, screenShot).Success;
+ }
+ else
+ {
+ logger.Warn("Cannot send screen shot as service is disconnected!");
+ }
+
+ return success;
+ }
+
+ private readonly Random temp = new Random();
+
+ private void Timer_Elapsed(object sender, ElapsedEventArgs e)
+ {
+ // TODO: Revert!
+ //if (service.IsConnected)
+ //{
+ // var response = service.GetHealth();
+
+ // if (response.Success)
+ // {
+ // var previous = health;
+
+ // health = response.Value > BAD ? BAD : (response.Value < GOOD ? GOOD : response.Value);
+
+ // if (previous != health)
+ // {
+ // logger.Info($"Service health {(previous < health ? "deteriorated" : "improved")} from {previous} to {health}.");
+ // }
+ // }
+ //}
+ //else
+ //{
+ // logger.Warn("Cannot query health as service is disconnected!");
+ //}
+
+ var previous = health;
+
+ health += temp.Next(-3, 5);
+ health = health < GOOD ? GOOD : (health > BAD ? BAD : health);
+
+ if (previous != health)
+ {
+ logger.Info($"Service health {(previous < health ? "deteriorated" : "improved")} from {previous} to {health}.");
+ }
+
+ timer.Start();
+ }
+ }
+}