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(); + } + } +}