SEBSP-15, SEBSP-70: Implemented basic screen proctoring functionality including image format & quantization settings.

This commit is contained in:
Damian Büchel 2024-02-01 17:36:11 +01:00
parent 70ba9ad7b6
commit acb6b8cf09
47 changed files with 1787 additions and 102 deletions

View file

@ -6,6 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
using System;
using SafeExamBrowser.Settings;
using SafeExamBrowser.Settings.Proctoring;
@ -74,6 +75,45 @@ namespace SafeExamBrowser.Configuration.ConfigurationData.DataMapping
case Keys.Proctoring.JitsiMeet.VideoMuted:
MapJitsiMeetVideoMuted(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.CaptureApplicationName:
MapCaptureApplicationName(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.CaptureBrowserUrl:
MapCaptureBrowserUrl(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.CaptureWindowTitle:
MapCaptureWindowTitle(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.ClientId:
MapClientId(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.ClientSecret:
MapClientSecret(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.GroupId:
MapGroupId(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.ImageDownscaling:
MapImageDownscaling(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.ImageFormat:
MapImageFormat(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.ImageQuantization:
MapImageQuantization(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.MaxInterval:
MapMaxInterval(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.MinInterval:
MapMinInterval(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.Enabled:
MapScreenProctoringEnabled(settings, value);
break;
case Keys.Proctoring.ScreenProctoring.ServiceUrl:
MapServiceUrl(settings, value);
break;
case Keys.Proctoring.ShowRaiseHand:
MapShowRaiseHand(settings, value);
break;
@ -280,6 +320,134 @@ namespace SafeExamBrowser.Configuration.ConfigurationData.DataMapping
}
}
private void MapCaptureApplicationName(AppSettings settings, object value)
{
if (value is bool capture)
{
settings.Proctoring.ScreenProctoring.CaptureApplicationName = capture;
}
}
private void MapCaptureBrowserUrl(AppSettings settings, object value)
{
if (value is bool capture)
{
settings.Proctoring.ScreenProctoring.CaptureBrowserUrl = capture;
}
}
private void MapCaptureWindowTitle(AppSettings settings, object value)
{
if (value is bool capture)
{
settings.Proctoring.ScreenProctoring.CaptureWindowTitle = capture;
}
}
private void MapClientId(AppSettings settings, object value)
{
if (value is string clientId)
{
settings.Proctoring.ScreenProctoring.ClientId = clientId;
}
}
private void MapClientSecret(AppSettings settings, object value)
{
if (value is string secret)
{
settings.Proctoring.ScreenProctoring.ClientSecret = secret;
}
}
private void MapGroupId(AppSettings settings, object value)
{
if (value is string groupId)
{
settings.Proctoring.ScreenProctoring.GroupId = groupId;
}
}
private void MapImageDownscaling(AppSettings settings, object value)
{
if (value is double downscaling)
{
settings.Proctoring.ScreenProctoring.ImageDownscaling = downscaling;
}
}
private void MapImageFormat(AppSettings settings, object value)
{
if (value is string s && Enum.TryParse<ImageFormat>(s, true, out var format))
{
settings.Proctoring.ScreenProctoring.ImageFormat = format;
}
}
private void MapImageQuantization(AppSettings settings, object value)
{
if (value is int quantization)
{
switch (quantization)
{
case 0:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.BlackAndWhite1bpp;
break;
case 1:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale2bpp;
break;
case 2:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale4bpp;
break;
case 3:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale8bpp;
break;
case 4:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Color8bpp;
break;
case 5:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Color16bpp;
break;
case 6:
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Color24bpp;
break;
}
}
}
private void MapMaxInterval(AppSettings settings, object value)
{
if (value is int interval)
{
settings.Proctoring.ScreenProctoring.MaxInterval = interval;
}
}
private void MapMinInterval(AppSettings settings, object value)
{
if (value is int interval)
{
settings.Proctoring.ScreenProctoring.MinInterval = interval;
}
}
private void MapScreenProctoringEnabled(AppSettings settings, object value)
{
if (value is bool enabled)
{
settings.Proctoring.ScreenProctoring.Enabled = enabled;
}
}
private void MapServiceUrl(AppSettings settings, object value)
{
if (value is string url)
{
settings.Proctoring.ScreenProctoring.ServiceUrl = url;
}
}
private void MapShowRaiseHand(AppSettings settings, object value)
{
if (value is bool show)

View file

@ -76,7 +76,7 @@ namespace SafeExamBrowser.Configuration.ConfigurationData
private void InitializeProctoringSettings(AppSettings settings)
{
settings.Proctoring.Enabled = settings.Proctoring.JitsiMeet.Enabled;
settings.Proctoring.Enabled = settings.Proctoring.JitsiMeet.Enabled || settings.Proctoring.ScreenProctoring.Enabled;
if (settings.Proctoring.JitsiMeet.Enabled && !settings.Proctoring.JitsiMeet.ReceiveVideo)
{

View file

@ -257,6 +257,15 @@ namespace SafeExamBrowser.Configuration.ConfigurationData
settings.Proctoring.JitsiMeet.SendVideo = true;
settings.Proctoring.JitsiMeet.ShowMeetingName = false;
settings.Proctoring.JitsiMeet.VideoMuted = false;
settings.Proctoring.ScreenProctoring.CaptureApplicationName = true;
settings.Proctoring.ScreenProctoring.CaptureBrowserUrl = true;
settings.Proctoring.ScreenProctoring.CaptureWindowTitle = true;
settings.Proctoring.ScreenProctoring.Enabled = false;
settings.Proctoring.ScreenProctoring.ImageDownscaling = 1.0;
settings.Proctoring.ScreenProctoring.ImageFormat = ImageFormat.Png;
settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale4bpp;
settings.Proctoring.ScreenProctoring.MaxInterval = 5000;
settings.Proctoring.ScreenProctoring.MinInterval = 1000;
settings.Proctoring.ShowRaiseHandNotification = true;
settings.Proctoring.ShowTaskbarNotification = true;
settings.Proctoring.WindowVisibility = WindowVisibility.Hidden;

View file

@ -256,6 +256,23 @@ namespace SafeExamBrowser.Configuration.ConfigurationData
internal const string VideoMuted = "jitsiMeetVideoMuted";
}
internal static class ScreenProctoring
{
internal const string CaptureApplicationName = "screenProctoringMetadataActiveAppEnabled";
internal const string CaptureBrowserUrl = "screenProctoringMetadataURLEnabled";
internal const string CaptureWindowTitle = "screenProctoringMetadataWindowTitleEnabled";
internal const string ClientId = "screenProctoringClientId";
internal const string ClientSecret = "screenProctoringClientSecret";
internal const string Enabled = "enableScreenProctoring";
internal const string GroupId = "screenProctoringGroupId";
internal const string ImageDownscaling = "screenProctoringImageDownscale";
internal const string ImageFormat = "screenProctoringImageFormat";
internal const string ImageQuantization = "screenProctoringImageQuantization";
internal const string MaxInterval = "screenProctoringScreenshotMaxInterval";
internal const string MinInterval = "screenProctoringScreenshotMinInterval";
internal const string ServiceUrl = "screenProctoringServiceURL";
}
internal static class Zoom
{
internal const string AllowChat = "zoomFeatureFlagChat";

View file

@ -17,7 +17,7 @@ using SafeExamBrowser.Core.Contracts.Notifications.Events;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
using SafeExamBrowser.I18n.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.SystemComponents.Contracts;
using SafeExamBrowser.UserInterface.Contracts;
@ -41,9 +41,6 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet
internal override string Name => nameof(JitsiMeet);
public override string Tooltip { get; protected set; }
public override IconResource IconResource { get; protected set; }
public override event NotificationChangedEventHandler NotificationChanged;
internal JitsiMeetImplementation(
@ -60,21 +57,6 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet
this.settings = settings;
this.text = text;
this.uiFactory = uiFactory;
IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Inactive.xaml") };
Tooltip = text.Get(TextKey.Notification_ProctoringInactiveTooltip);
}
public override void Activate()
{
if (settings.WindowVisibility == WindowVisibility.Visible)
{
window?.BringToForeground();
}
else if (settings.WindowVisibility == WindowVisibility.AllowToHide || settings.WindowVisibility == WindowVisibility.AllowToShow)
{
window?.Toggle();
}
}
internal override void Initialize()
@ -87,11 +69,15 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet
start &= !string.IsNullOrWhiteSpace(settings.JitsiMeet.RoomName);
start &= !string.IsNullOrWhiteSpace(settings.JitsiMeet.ServerUrl);
logger.Info("Initialized proctoring.");
if (start)
{
StartProctoring();
logger.Info($"Initialized proctoring: All settings are valid, starting automatically...");
Start();
}
else
{
ShowNotificationInactive();
logger.Info($"Initialized proctoring: Not all settings are valid or a server session is active, not starting automatically.");
}
}
@ -112,27 +98,37 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet
settings.WindowVisibility = initialVisibility;
}
StopProctoring();
StartProctoring();
Stop();
Start();
logger.Info($"Successfully updated configuration: {nameof(allowChat)}={allowChat}, {nameof(receiveAudio)}={receiveAudio}, {nameof(receiveVideo)}={receiveVideo}.");
}
internal override void ProctoringInstructionReceived(ProctoringInstructionEventArgs args)
internal override void ProctoringInstructionReceived(InstructionEventArgs args)
{
logger.Info("Proctoring instruction received.");
if (args is JitsiMeetInstruction instruction)
{
logger.Info($"Proctoring instruction received: {instruction.Method}");
settings.JitsiMeet.RoomName = args.JitsiMeetRoomName;
settings.JitsiMeet.ServerUrl = args.JitsiMeetServerUrl;
settings.JitsiMeet.Token = args.JitsiMeetToken;
if (instruction.Method == InstructionMethod.Join)
{
settings.JitsiMeet.RoomName = instruction.RoomName;
settings.JitsiMeet.ServerUrl = instruction.ServerUrl;
settings.JitsiMeet.Token = instruction.Token;
StopProctoring();
StartProctoring();
Stop();
Start();
}
else
{
Stop();
}
logger.Info("Successfully processed instruction.");
logger.Info("Successfully processed instruction.");
}
}
internal override void StartProctoring()
internal override void Start()
{
Application.Current.Dispatcher.Invoke(() =>
{
@ -173,7 +169,7 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet
});
}
internal override void StopProctoring()
internal override void Stop()
{
if (control != default && window != default)
{
@ -197,9 +193,28 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet
internal override void Terminate()
{
Stop();
TerminateNotification();
logger.Info("Terminated proctoring.");
}
protected override void ActivateNotification()
{
if (settings.WindowVisibility == WindowVisibility.Visible)
{
window?.BringToForeground();
}
else if (settings.WindowVisibility == WindowVisibility.AllowToHide || settings.WindowVisibility == WindowVisibility.AllowToShow)
{
window?.Toggle();
}
}
protected override void TerminateNotification()
{
// Nothing to do here for now.
}
private string LoadContent(ProctoringSettings settings)
{
var assembly = Assembly.GetAssembly(typeof(ProctoringController));

View file

@ -15,7 +15,7 @@ using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.Contracts;
using SafeExamBrowser.Proctoring.Contracts.Events;
using SafeExamBrowser.Server.Contracts;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.SystemComponents.Contracts;
using SafeExamBrowser.UserInterface.Contracts;
@ -144,7 +144,7 @@ namespace SafeExamBrowser.Proctoring
}
}
private void Server_ProctoringInstructionReceived(ProctoringInstructionEventArgs args)
private void Server_ProctoringInstructionReceived(InstructionEventArgs args)
{
foreach (var implementation in implementations)
{

View file

@ -11,6 +11,8 @@ using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.I18n.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.JitsiMeet;
using SafeExamBrowser.Proctoring.ScreenProctoring;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.SystemComponents.Contracts;
using SafeExamBrowser.UserInterface.Contracts;
@ -40,7 +42,17 @@ namespace SafeExamBrowser.Proctoring
if (settings.JitsiMeet.Enabled)
{
implementations.Add(new JitsiMeetImplementation(appConfig, fileSystem, logger.CloneFor(nameof(JitsiMeet)), settings, text, uiFactory));
var logger = this.logger.CloneFor(nameof(JitsiMeet));
implementations.Add(new JitsiMeetImplementation(appConfig, fileSystem, logger, settings, text, uiFactory));
}
if (settings.ScreenProctoring.Enabled)
{
var logger = this.logger.CloneFor(nameof(ScreenProctoring));
var service = new ServiceProxy(logger.CloneFor(nameof(ServiceProxy)));
implementations.Add(new ScreenProctoringImplementation(logger, service, settings, text));
}
return implementations;

View file

@ -9,7 +9,7 @@
using SafeExamBrowser.Core.Contracts.Notifications;
using SafeExamBrowser.Core.Contracts.Notifications.Events;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
namespace SafeExamBrowser.Proctoring
{
@ -17,19 +17,29 @@ namespace SafeExamBrowser.Proctoring
{
internal abstract string Name { get; }
public abstract string Tooltip { get; protected set; }
public abstract IconResource IconResource { get; protected set; }
public string Tooltip { get; protected set; }
public IconResource IconResource { get; protected set; }
public abstract event NotificationChangedEventHandler NotificationChanged;
public abstract void Activate();
void INotification.Terminate() { }
void INotification.Activate()
{
ActivateNotification();
}
void INotification.Terminate()
{
TerminateNotification();
}
internal abstract void Initialize();
internal abstract void ProctoringConfigurationReceived(bool allowChat, bool receiveAudio, bool receiveVideo);
internal abstract void ProctoringInstructionReceived(ProctoringInstructionEventArgs args);
internal abstract void StartProctoring();
internal abstract void StopProctoring();
internal abstract void ProctoringInstructionReceived(InstructionEventArgs args);
internal abstract void Start();
internal abstract void Stop();
internal abstract void Terminate();
protected abstract void ActivateNotification();
protected abstract void TerminateNotification();
}
}

View file

@ -53,6 +53,15 @@
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<Reference Include="KGySoft.CoreLibraries, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b45eba277439ddfe, processorArchitecture=MSIL">
<HintPath>..\packages\KGySoft.CoreLibraries.8.0.0\lib\net472\KGySoft.CoreLibraries.dll</HintPath>
</Reference>
<Reference Include="KGySoft.Drawing, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b45eba277439ddfe, processorArchitecture=MSIL">
<HintPath>..\packages\KGySoft.Drawing.8.0.0\lib\net46\KGySoft.Drawing.dll</HintPath>
</Reference>
<Reference Include="KGySoft.Drawing.Core, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b45eba277439ddfe, processorArchitecture=MSIL">
<HintPath>..\packages\KGySoft.Drawing.Core.8.0.0\lib\net46\KGySoft.Drawing.Core.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Web.WebView2.Core, Version=1.0.2088.41, Culture=neutral, PublicKeyToken=2a8ab48044d2601e, processorArchitecture=MSIL">
<HintPath>..\packages\Microsoft.Web.WebView2.1.0.2088.41\lib\net45\Microsoft.Web.WebView2.Core.dll</HintPath>
</Reference>
@ -69,6 +78,9 @@
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Drawing" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xaml" />
<Reference Include="WindowsBase" />
</ItemGroup>
@ -79,6 +91,22 @@
<Compile Include="ProctoringFactory.cs" />
<Compile Include="ProctoringImplementation.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="ScreenProctoring\Imaging\Extensions.cs" />
<Compile Include="ScreenProctoring\Imaging\ProcessingOrder.cs" />
<Compile Include="ScreenProctoring\Imaging\ScreenShot.cs" />
<Compile Include="ScreenProctoring\ScreenProctoringImplementation.cs" />
<Compile Include="ScreenProctoring\Service\Api.cs" />
<Compile Include="ScreenProctoring\Service\Parser.cs" />
<Compile Include="ScreenProctoring\Service\Requests\ContentType.cs" />
<Compile Include="ScreenProctoring\Service\Requests\CreateSessionRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\Header.cs" />
<Compile Include="ScreenProctoring\Service\Requests\Extensions.cs" />
<Compile Include="ScreenProctoring\Service\Requests\TerminateSessionRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\OAuth2TokenRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\Request.cs" />
<Compile Include="ScreenProctoring\Service\Requests\ScreenShotRequest.cs" />
<Compile Include="ScreenProctoring\Service\ServiceProxy.cs" />
<Compile Include="ScreenProctoring\Service\ServiceResponse.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SafeExamBrowser.Configuration.Contracts\SafeExamBrowser.Configuration.Contracts.csproj">
@ -124,6 +152,7 @@
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Microsoft.Web.WebView2.1.0.2088.41\build\Microsoft.Web.WebView2.targets" Condition="Exists('..\packages\Microsoft.Web.WebView2.1.0.2088.41\build\Microsoft.Web.WebView2.targets')" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">

View file

@ -0,0 +1,93 @@
/*
* 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.Drawing;
using System.Drawing.Imaging;
using System.Windows.Forms;
using KGySoft.Drawing.Imaging;
using SafeExamBrowser.Settings.Proctoring;
using ImageFormat = SafeExamBrowser.Settings.Proctoring.ImageFormat;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging
{
internal static class Extensions
{
internal static void DrawCursorPosition(this Graphics graphics)
{
graphics.DrawArc(new Pen(Color.Red, 3), Cursor.Position.X - 25, Cursor.Position.Y - 25, 50, 50, 0, 360);
graphics.DrawArc(new Pen(Color.Yellow, 3), Cursor.Position.X - 22, Cursor.Position.Y - 22, 44, 44, 0, 360);
graphics.FillEllipse(Brushes.Red, Cursor.Position.X - 4, Cursor.Position.Y - 4, 8, 8);
graphics.FillEllipse(Brushes.Yellow, Cursor.Position.X - 2, Cursor.Position.Y - 2, 4, 4);
}
internal static PixelFormat ToPixelFormat(this ImageQuantization quantization)
{
switch (quantization)
{
case ImageQuantization.BlackAndWhite1bpp:
return PixelFormat.Format1bppIndexed;
case ImageQuantization.Color8bpp:
return PixelFormat.Format8bppIndexed;
case ImageQuantization.Color16bpp:
return PixelFormat.Format16bppArgb1555;
case ImageQuantization.Color24bpp:
return PixelFormat.Format24bppRgb;
case ImageQuantization.Grayscale2bpp:
return PixelFormat.Format4bppIndexed;
case ImageQuantization.Grayscale4bpp:
return PixelFormat.Format4bppIndexed;
case ImageQuantization.Grayscale8bpp:
return PixelFormat.Format8bppIndexed;
default:
throw new NotImplementedException($"Image quantization '{quantization}' is not yet implemented!");
}
}
internal static IQuantizer ToQuantizer(this ImageQuantization quantization)
{
switch (quantization)
{
case ImageQuantization.BlackAndWhite1bpp:
return PredefinedColorsQuantizer.BlackAndWhite();
case ImageQuantization.Color8bpp:
return PredefinedColorsQuantizer.SystemDefault8BppPalette();
case ImageQuantization.Color16bpp:
return PredefinedColorsQuantizer.Rgb555();
case ImageQuantization.Color24bpp:
return PredefinedColorsQuantizer.Rgb888();
case ImageQuantization.Grayscale2bpp:
return PredefinedColorsQuantizer.Grayscale4();
case ImageQuantization.Grayscale4bpp:
return PredefinedColorsQuantizer.Grayscale16();
case ImageQuantization.Grayscale8bpp:
return PredefinedColorsQuantizer.Grayscale();
default:
throw new NotImplementedException($"Image quantization '{quantization}' is not yet implemented!");
}
}
internal static System.Drawing.Imaging.ImageFormat ToSystemFormat(this ImageFormat format)
{
switch (format)
{
case ImageFormat.Bmp:
return System.Drawing.Imaging.ImageFormat.Bmp;
case ImageFormat.Gif:
return System.Drawing.Imaging.ImageFormat.Gif;
case ImageFormat.Jpg:
return System.Drawing.Imaging.ImageFormat.Jpeg;
case ImageFormat.Png:
return System.Drawing.Imaging.ImageFormat.Png;
default:
throw new NotImplementedException($"Image format '{format}' is not yet implemented!");
}
}
}
}

View file

@ -0,0 +1,16 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging
{
internal enum ProcessingOrder
{
DownscalingQuantizing,
QuantizingDownscaling,
}
}

View file

@ -0,0 +1,152 @@
/*
* 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.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using KGySoft.Drawing;
using KGySoft.Drawing.Imaging;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Settings.Proctoring;
using ImageFormat = SafeExamBrowser.Settings.Proctoring.ImageFormat;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging
{
internal class ScreenShot : IDisposable
{
private readonly ILogger logger;
private readonly ScreenProctoringSettings settings;
internal Bitmap Bitmap { get; private set; }
internal byte[] Data { get; private set; }
internal ImageFormat Format { get; private set; }
internal int Height { get; private set; }
internal int Width { get; private set; }
public ScreenShot(ILogger logger, ScreenProctoringSettings settings)
{
this.logger = logger;
this.settings = settings;
}
public void Dispose()
{
Bitmap?.Dispose();
Bitmap = default;
Data = default;
}
public string ToReducedString()
{
return $"{Width}x{Height}, {Data.Length / 1000:N0} kB, {Format.ToString().ToUpper()}";
}
public override string ToString()
{
return $"resolution: {Width}x{Height}, size: {Data.Length / 1000:N0} kB, format: {Format.ToString().ToUpper()}";
}
internal void Compress()
{
var order = ProcessingOrder.QuantizingDownscaling;
var original = ToReducedString();
var parameters = $"{order}, {settings.ImageQuantization}, 1:{settings.ImageDownscaling}";
switch (order)
{
case ProcessingOrder.DownscalingQuantizing:
Downscale();
Quantize();
Serialize();
break;
case ProcessingOrder.QuantizingDownscaling:
Quantize();
Downscale();
Serialize();
break;
}
logger.Debug($"Compressed from '{original}' to '{ToReducedString()}' ({parameters}).");
}
internal void Take()
{
var x = Screen.AllScreens.Min(s => s.Bounds.X);
var y = Screen.AllScreens.Min(s => s.Bounds.Y);
var width = Screen.AllScreens.Max(s => s.Bounds.X + s.Bounds.Width) - x;
var height = Screen.AllScreens.Max(s => s.Bounds.Y + s.Bounds.Height) - y;
Bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);
Format = settings.ImageFormat;
Height = height;
Width = width;
using (var graphics = Graphics.FromImage(Bitmap))
{
graphics.CopyFromScreen(x, y, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy);
graphics.DrawCursorPosition();
}
Serialize();
}
private void Downscale()
{
if (settings.ImageDownscaling > 1)
{
Height = Convert.ToInt32(Height / settings.ImageDownscaling);
Width = Convert.ToInt32(Width / settings.ImageDownscaling);
var downscaled = new Bitmap(Width, Height, Bitmap.PixelFormat);
Bitmap.DrawInto(downscaled, new Rectangle(0, 0, Width, Height), ScalingMode.NearestNeighbor);
Bitmap.Dispose();
Bitmap = downscaled;
}
}
private void Quantize()
{
var ditherer = settings.ImageDownscaling > 1 ? OrderedDitherer.Bayer2x2 : default;
var pixelFormat = settings.ImageQuantization.ToPixelFormat();
var quantizer = settings.ImageQuantization.ToQuantizer();
Bitmap = Bitmap.ConvertPixelFormat(pixelFormat, quantizer, ditherer);
}
private void Serialize()
{
using (var memoryStream = new MemoryStream())
{
if (Format == ImageFormat.Jpg)
{
SerializeJpg(memoryStream);
}
else
{
Bitmap.Save(memoryStream, Format.ToSystemFormat());
}
Data = memoryStream.ToArray();
}
}
private void SerializeJpg(MemoryStream memoryStream)
{
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == System.Drawing.Imaging.ImageFormat.Jpeg.Guid);
var parameters = new EncoderParameters(1);
var quality = 100;
parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality);
Bitmap.Save(memoryStream, codec, parameters);
}
}
}

View file

@ -0,0 +1,207 @@
/*
* 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.Timers;
using SafeExamBrowser.Core.Contracts.Notifications.Events;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
using SafeExamBrowser.I18n.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Proctoring;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class ScreenProctoringImplementation : ProctoringImplementation
{
private readonly IModuleLogger logger;
private readonly ServiceProxy service;
private readonly ScreenProctoringSettings settings;
private readonly IText text;
private readonly Timer timer;
internal override string Name => nameof(ScreenProctoring);
public override event NotificationChangedEventHandler NotificationChanged;
internal ScreenProctoringImplementation(IModuleLogger logger, ServiceProxy service, ProctoringSettings settings, IText text)
{
this.logger = logger;
this.service = service;
this.settings = settings.ScreenProctoring;
this.text = text;
this.timer = new Timer();
}
internal override void Initialize()
{
var start = true;
start &= !string.IsNullOrWhiteSpace(settings.ClientId);
start &= !string.IsNullOrWhiteSpace(settings.ClientSecret);
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...");
Connect();
Start();
}
else
{
ShowNotificationInactive();
logger.Info($"Initialized proctoring: Not all settings are valid or a server session is active, not starting automatically.");
}
}
internal override void ProctoringConfigurationReceived(bool allowChat, bool receiveAudio, bool receiveVideo)
{
// Nothing to do here for now...
}
internal override void ProctoringInstructionReceived(InstructionEventArgs args)
{
if (args is ScreenProctoringInstruction instruction)
{
logger.Info($"Proctoring instruction received: {instruction.Method}.");
if (instruction.Method == InstructionMethod.Join)
{
settings.ClientId = instruction.ClientId;
settings.ClientSecret = instruction.ClientSecret;
settings.GroupId = instruction.GroupId;
settings.ServiceUrl = instruction.ServiceUrl;
Connect(instruction.SessionId);
Start();
}
else
{
Stop();
}
logger.Info("Successfully processed instruction.");
}
}
internal override void Start()
{
timer.Elapsed += Timer_Elapsed;
timer.Start();
ShowNotificationActive();
logger.Info($"Started proctoring.");
}
internal override void Stop()
{
timer.Elapsed -= Timer_Elapsed;
timer.Stop();
TerminateServiceSession();
ShowNotificationInactive();
logger.Info("Stopped proctoring.");
}
internal override void Terminate()
{
if (timer.Enabled)
{
Stop();
}
TerminateNotification();
logger.Info("Terminated proctoring.");
}
protected override void ActivateNotification()
{
// Nothing to do here for now...
}
protected override void TerminateNotification()
{
// Nothing to do here for now...
}
private void Connect(string sessionId = default)
{
logger.Info("Connecting to service...");
var connect = service.Connect(settings.ServiceUrl);
if (connect.Success)
{
if (sessionId == default)
{
logger.Info("Creating session...");
service.CreateSession(settings.GroupId);
}
else
{
service.SessionId = sessionId;
}
}
}
private void ShowNotificationActive()
{
// TODO: Replace with actual icon!
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()
{
if (service.IsConnected)
{
logger.Info("Terminating session...");
service.TerminateSession();
}
}
private void Timer_Elapsed(object sender, ElapsedEventArgs args)
{
try
{
using (var screenShot = new ScreenShot(logger.CloneFor(nameof(ScreenShot)), settings))
{
screenShot.Take();
screenShot.Compress();
service.SendScreenShot(screenShot);
}
}
catch (Exception e)
{
logger.Error("Failed to process screen shot!", e);
}
timer.Start();
}
}
}

View file

@ -0,0 +1,26 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service
{
internal class Api
{
internal const string SESSION_ID = "%%_SESSION_ID_%%";
internal string AccessTokenEndpoint { get; set; }
internal string ScreenShotEndpoint { get; set; }
internal string SessionEndpoint { get; set; }
internal Api()
{
AccessTokenEndpoint = "/oauth/token";
ScreenShotEndpoint = $"/seb-api/v1/session/{SESSION_ID}/screenshot";
SessionEndpoint = "/seb-api/v1/session";
}
}
}

View file

@ -0,0 +1,98 @@
/*
* 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.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service
{
internal class Parser
{
private readonly ILogger logger;
internal Parser(ILogger logger)
{
this.logger = logger;
}
internal bool IsTokenExpired(HttpContent content)
{
var isExpired = false;
try
{
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
var error = json["error"].Value<string>();
isExpired = error?.Equals("invalid_token", StringComparison.OrdinalIgnoreCase) == true;
}
catch (Exception e)
{
logger.Error("Failed to parse token expiration content!", e);
}
return isExpired;
}
internal bool TryParseOauth2Token(HttpContent content, out string oauth2Token)
{
oauth2Token = default;
try
{
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
oauth2Token = json["access_token"].Value<string>();
}
catch (Exception e)
{
logger.Error("Failed to parse Oauth2 token!", e);
}
return oauth2Token != default;
}
internal bool TryParseSessionId(HttpResponseMessage response, out string sessionId)
{
sessionId = default;
try
{
if (response.Headers.TryGetValues(Header.SESSION_ID, out var values))
{
sessionId = values.First();
}
}
catch (Exception e)
{
logger.Error("Failed to parse session identifier!", e);
}
return sessionId != default;
}
private string Extract(HttpContent content)
{
var task = Task.Run(async () =>
{
return await content.ReadAsStreamAsync();
});
var stream = task.GetAwaiter().GetResult();
var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
}

View file

@ -0,0 +1,17 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
internal static class ContentType
{
internal const string JSON = "application/json;charset=UTF-8";
internal const string OCTET_STREAM = "application/octet-stream";
internal const string URL_ENCODED = "application/x-www-form-urlencoded";
}
}

View file

@ -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 CreateSessionRequest : Request
{
internal CreateSessionRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser)
{
}
internal bool TryExecute(string groupId, out string message, out string sessionId)
{
var group = (Header.GROUP_ID, groupId);
var success = TryExecute(HttpMethod.Post, api.SessionEndpoint, out var response, string.Empty, ContentType.URL_ENCODED, Authorization, group);
message = response.ToLogString();
sessionId = default;
if (success)
{
parser.TryParseSessionId(response, out sessionId);
}
return success;
}
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.Net.Http;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
internal static class Extensions
{
internal static string ToLogString(this HttpResponseMessage response)
{
return $"{(int?) response?.StatusCode} {response?.StatusCode} {response?.ReasonPhrase}";
}
internal static long ToUnixTimestamp(this DateTime date)
{
return new DateTimeOffset(date).ToUnixTimeMilliseconds();
}
}
}

View file

@ -0,0 +1,18 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
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 SESSION_ID = "SEB_SESSION_UUID";
}
}

View file

@ -0,0 +1,25 @@
/*
* 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 OAuth2TokenRequest : Request
{
internal OAuth2TokenRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser)
{
}
internal bool TryExecute(out string message)
{
return TryRetrieveOAuth2Token(out message);
}
}
}

View file

@ -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.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using SafeExamBrowser.Logging.Contracts;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
internal abstract class Request
{
private const int ATTEMPTS = 5;
private static string connectionToken;
private static string oauth2Token;
private readonly HttpClient httpClient;
protected readonly Api api;
protected readonly ILogger logger;
protected readonly Parser parser;
protected (string, string) Authorization => (Header.AUTHORIZATION, $"Bearer {oauth2Token}");
internal static string ConnectionToken
{
get { return connectionToken; }
set { connectionToken = value; }
}
internal static string Oauth2Token
{
get { return oauth2Token; }
set { oauth2Token = value; }
}
protected Request(Api api, HttpClient httpClient, ILogger logger, Parser parser)
{
this.api = api;
this.httpClient = httpClient;
this.logger = logger;
this.parser = parser;
}
protected bool TryExecute(
HttpMethod method,
string url,
out HttpResponseMessage response,
object content = default,
string contentType = default,
params (string name, string value)[] headers)
{
response = default;
for (var attempt = 0; attempt < ATTEMPTS && (response == default || !response.IsSuccessStatusCode); attempt++)
{
var request = BuildRequest(method, url, content, contentType, headers);
try
{
response = httpClient.SendAsync(request).GetAwaiter().GetResult();
logger.Debug($"Completed request: {request.Method} '{request.RequestUri}' -> {response.ToLogString()}");
if (response.StatusCode == HttpStatusCode.Unauthorized && parser.IsTokenExpired(response.Content))
{
logger.Info("OAuth2 token has expired, attempting to retrieve new one...");
if (TryRetrieveOAuth2Token(out var message))
{
headers = UpdateOAuth2Token(headers);
}
}
}
catch (TaskCanceledException)
{
logger.Error($"Request {request.Method} '{request.RequestUri}' did not complete within {httpClient.Timeout}ms!");
break;
}
catch (Exception e)
{
logger.Error($"Request {request.Method} '{request.RequestUri}' has failed!", e);
}
}
return response != default && response.IsSuccessStatusCode;
}
protected bool TryRetrieveOAuth2Token(out string message)
{
var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("test:test"));
var authorization = (Header.AUTHORIZATION, $"Basic {secret}");
var content = "grant_type=client_credentials&scope=read write";
var success = TryExecute(HttpMethod.Post, api.AccessTokenEndpoint, out var response, content, ContentType.URL_ENCODED, authorization);
message = response.ToLogString();
if (success && parser.TryParseOauth2Token(response.Content, out oauth2Token))
{
logger.Info("Successfully retrieved OAuth2 token.");
}
else
{
logger.Error("Failed to retrieve OAuth2 token!");
}
return success;
}
private HttpRequestMessage BuildRequest(
HttpMethod method,
string url,
object content = default,
string contentType = default,
params (string name, string value)[] headers)
{
var request = new HttpRequestMessage(method, url);
if (content != default)
{
if (content is string)
{
request.Content = new StringContent(content as string, Encoding.UTF8);
}
if (content is byte[])
{
request.Content = new ByteArrayContent(content as byte[]);
}
if (contentType != default)
{
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
}
}
request.Headers.Add(Header.ACCEPT, "application/json, */*");
foreach (var (name, value) in headers)
{
request.Headers.Add(name, value);
}
return request;
}
private (string name, string value)[] UpdateOAuth2Token((string name, string value)[] headers)
{
var result = new List<(string name, string value)>();
foreach (var header in headers)
{
if (header.name == Header.AUTHORIZATION)
{
result.Add((Header.AUTHORIZATION, $"Bearer {oauth2Token}"));
}
else
{
result.Add(header);
}
}
return result.ToArray();
}
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.Net.Http;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Settings.Proctoring;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
internal class ScreenShotRequest : Request
{
internal ScreenShotRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser)
{
}
internal bool TryExecute(ScreenShot screenShot, string sessionId, out string message)
{
var imageFormat = ("imageFormat", ToString(screenShot.Format));
var timestamp = ("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, imageFormat, timestamp);
message = response.ToLogString();
return success;
}
private string ToString(ImageFormat format)
{
switch (format)
{
case ImageFormat.Bmp:
return "bmp";
case ImageFormat.Gif:
return "gif";
case ImageFormat.Jpg:
return "jpg";
case ImageFormat.Png:
return "png";
default:
throw new NotImplementedException($"Image format {format} is not yet implemented!");
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* 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 TerminateSessionRequest : Request
{
internal TerminateSessionRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser)
{
}
internal bool TryExecute(string sessionId, out string message)
{
var url = $"{api.SessionEndpoint}/{sessionId}";
var success = TryExecute(HttpMethod.Delete, url, out var response, contentType: ContentType.URL_ENCODED, headers: Authorization);
message = response.ToLogString();
return success;
}
}
}

View file

@ -0,0 +1,107 @@
/*
* 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.Net.Http;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service
{
internal class ServiceProxy
{
private readonly Api api;
private readonly ILogger logger;
private readonly Parser parser;
private HttpClient httpClient;
internal bool IsConnected => SessionId != default;
internal string SessionId { get; set; }
internal ServiceProxy(ILogger logger)
{
this.api = new Api();
this.logger = logger;
this.parser = new Parser(logger);
}
internal ServiceResponse Connect(string serviceUrl)
{
httpClient = new HttpClient { BaseAddress = new Uri(serviceUrl) };
var request = new OAuth2TokenRequest(api, httpClient, logger, parser);
var success = request.TryExecute(out var message);
if (success)
{
logger.Info("Successfully connected to service.");
}
else
{
logger.Error("Failed to connect to service!");
}
return new ServiceResponse(success, message);
}
internal ServiceResponse CreateSession(string groupId)
{
var request = new CreateSessionRequest(api, httpClient, logger, parser);
var success = request.TryExecute(groupId, out var message, out var sessionId);
if (success)
{
SessionId = sessionId;
logger.Info("Successfully created session.");
}
else
{
logger.Error("Failed to create session!");
}
return new ServiceResponse(success, message);
}
internal ServiceResponse SendScreenShot(ScreenShot screenShot)
{
var request = new ScreenShotRequest(api, httpClient, logger, parser);
var success = request.TryExecute(screenShot, SessionId, out var message);
if (success)
{
logger.Info($"Successfully sent screen shot ({screenShot}).");
}
else
{
logger.Error("Failed to send screen shot!");
}
return new ServiceResponse(success, message);
}
internal ServiceResponse TerminateSession()
{
var request = new TerminateSessionRequest(api, httpClient, logger, parser);
var success = request.TryExecute(SessionId, out var message);
if (success)
{
SessionId = default;
logger.Info("Successfully terminated session.");
}
else
{
logger.Error("Failed to terminate session!");
}
return new ServiceResponse(success, message);
}
}
}

View file

@ -0,0 +1,32 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service
{
internal class ServiceResponse
{
internal string Message { get; }
internal bool Success { get; }
internal ServiceResponse(bool success, string message = default)
{
Message = message;
Success = success;
}
}
internal class ServiceResponse<T> : ServiceResponse
{
internal T Value { get; }
internal ServiceResponse(bool success, T value, string message = default) : base(success, message)
{
Value = value;
}
}
}

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="KGySoft.CoreLibraries" version="8.0.0" targetFramework="net48" />
<package id="KGySoft.Drawing" version="8.0.0" targetFramework="net48" />
<package id="KGySoft.Drawing.Core" version="8.0.0" targetFramework="net48" />
<package id="Microsoft.Web.WebView2" version="1.0.2088.41" targetFramework="net48" />
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>

View file

@ -31,9 +31,14 @@ namespace SafeExamBrowser.Runtime.Operations
{
var result = OperationResult.Success;
if (Context.Next.Settings.Proctoring.Enabled)
if (Context.Next.Settings.Proctoring.JitsiMeet.Enabled)
{
result = ShowDisclaimer();
result = ShowVideoProctoringDisclaimer();
}
else if (Context.Next.Settings.Proctoring.ScreenProctoring.Enabled)
{
// TODO: Implement disclaimer!
// result = ShowScreenProctoringDisclaimer();
}
else if (Context.Next.Settings.Proctoring.Zoom.Enabled)
{
@ -51,9 +56,14 @@ namespace SafeExamBrowser.Runtime.Operations
{
var result = OperationResult.Success;
if (Context.Next.Settings.Proctoring.Enabled)
if (Context.Next.Settings.Proctoring.JitsiMeet.Enabled)
{
result = ShowDisclaimer();
result = ShowVideoProctoringDisclaimer();
}
else if (Context.Next.Settings.Proctoring.ScreenProctoring.Enabled)
{
// TODO: Implement disclaimer!
// result = ShowScreenProctoringDisclaimer();
}
else if (Context.Next.Settings.Proctoring.Zoom.Enabled)
{
@ -72,7 +82,7 @@ namespace SafeExamBrowser.Runtime.Operations
return OperationResult.Success;
}
private OperationResult ShowDisclaimer()
private OperationResult ShowVideoProctoringDisclaimer()
{
var args = new MessageEventArgs
{

View file

@ -27,7 +27,7 @@ namespace SafeExamBrowser.Runtime.Operations
public override OperationResult Perform()
{
if (Context.Next.Settings.Proctoring.Enabled && Context.Next.Settings.Security.KioskMode == KioskMode.CreateNewDesktop)
if (Context.Next.Settings.Proctoring.JitsiMeet.Enabled && Context.Next.Settings.Security.KioskMode == KioskMode.CreateNewDesktop)
{
Context.Next.Settings.Security.KioskMode = KioskMode.DisableExplorerShell;
logger.Info("Switched kiosk mode to Disable Explorer Shell due to remote proctoring being enabled.");

View file

@ -23,7 +23,7 @@ namespace SafeExamBrowser.Server.Contracts.Data
/// </summary>
public bool Success { get; }
public ServerResponse(bool success, string message = default(string))
public ServerResponse(bool success, string message = default)
{
Message = message;
Success = success;
@ -41,7 +41,7 @@ namespace SafeExamBrowser.Server.Contracts.Data
/// </summary>
public T Value { get; }
public ServerResponse(bool success, T value, string message = default(string)) : base(success, message)
public ServerResponse(bool success, T value, string message = default) : base(success, message)
{
Value = value;
}

View file

@ -0,0 +1,18 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Defines all parameters for a proctoring instruction received by the <see cref="IServerProxy"/>.
/// </summary>
public abstract class InstructionEventArgs
{
public InstructionMethod Method { get; set; }
}
}

View file

@ -0,0 +1,26 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Defines all possible methods for a proctoring instruction.
/// </summary>
public enum InstructionMethod
{
/// <summary>
/// Instructs to start proctoring resp. join a proctoring event or session.
/// </summary>
Join,
/// <summary>
/// Instructs to stop proctoring resp. leave a proctoring event or session.
/// </summary>
Leave
}
}

View file

@ -0,0 +1,20 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Defines the parameters of a proctoring instruction for provider Jitsi Meet.
/// </summary>
public class JitsiMeetInstruction : InstructionEventArgs
{
public string RoomName { get; set; }
public string ServerUrl { get; set; }
public string Token { get; set; }
}
}

View file

@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
namespace SafeExamBrowser.Server.Contracts.Events
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Event handler used to indicate that proctoring configuration data has been received.

View file

@ -6,10 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
namespace SafeExamBrowser.Server.Contracts.Events
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Event handler used to indicate that a proctoring instruction has been received.
/// </summary>
public delegate void ProctoringInstructionReceivedEventHandler(ProctoringInstructionEventArgs args);
public delegate void ProctoringInstructionReceivedEventHandler(InstructionEventArgs args);
}

View file

@ -0,0 +1,22 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Defines the parameters of a proctoring instruction for the screen proctoring implementation.
/// </summary>
public class ScreenProctoringInstruction : InstructionEventArgs
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string GroupId { get; set; }
public string ServiceUrl { get; set; }
public string SessionId { get; set; }
}
}

View file

@ -0,0 +1,23 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Server.Contracts.Events.Proctoring
{
/// <summary>
/// Defines the parameters of a proctoring instruction for provider Zoom.
/// </summary>
public class ZoomInstruction : InstructionEventArgs
{
public string MeetingNumber { get; set; }
public string Password { get; set; }
public string SdkKey { get; set; }
public string Signature { get; set; }
public string Subject { get; set; }
public string UserName { get; set; }
}
}

View file

@ -1,26 +0,0 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Server.Contracts.Events
{
/// <summary>
/// Defines all parameters for a proctoring instruction received by the <see cref="IServerProxy"/>.
/// </summary>
public class ProctoringInstructionEventArgs
{
public string JitsiMeetRoomName { get; set; }
public string JitsiMeetServerUrl { get; set; }
public string JitsiMeetToken { get; set; }
public string ZoomMeetingNumber { get; set; }
public string ZoomPassword { get; set; }
public string ZoomSdkKey { get; set; }
public string ZoomSignature { get; set; }
public string ZoomSubject { get; set; }
public string ZoomUserName { get; set; }
}
}

View file

@ -10,6 +10,7 @@ using System;
using System.Collections.Generic;
using SafeExamBrowser.Server.Contracts.Data;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Server;
namespace SafeExamBrowser.Server.Contracts

View file

@ -58,9 +58,13 @@
<Compile Include="Data\ConnectionInfo.cs" />
<Compile Include="Data\Exam.cs" />
<Compile Include="Events\LockScreenRequestedEventHandler.cs" />
<Compile Include="Events\ProctoringConfigurationReceivedEventHandler.cs" />
<Compile Include="Events\ProctoringInstructionEventArgs.cs" />
<Compile Include="Events\ProctoringInstructionReceivedEventHandler.cs" />
<Compile Include="Events\Proctoring\InstructionMethod.cs" />
<Compile Include="Events\Proctoring\JitsiMeetInstruction.cs" />
<Compile Include="Events\Proctoring\ProctoringConfigurationReceivedEventHandler.cs" />
<Compile Include="Events\Proctoring\InstructionEventArgs.cs" />
<Compile Include="Events\Proctoring\ProctoringInstructionReceivedEventHandler.cs" />
<Compile Include="Events\Proctoring\ScreenProctoringInstruction.cs" />
<Compile Include="Events\Proctoring\ZoomInstruction.cs" />
<Compile Include="Events\ServerEventHandler.cs" />
<Compile Include="Events\TerminationRequestedEventHandler.cs" />
<Compile Include="IServerProxy.cs" />

View file

@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
namespace SafeExamBrowser.Server.Data
{
@ -14,15 +14,10 @@ namespace SafeExamBrowser.Server.Data
{
internal bool AllowChat { get; set; }
internal int Id { get; set; }
internal ProctoringInstructionEventArgs Instruction { get; set; }
internal InstructionEventArgs Instruction { get; set; }
internal string Message { get; set; }
internal bool ReceiveAudio { get; set; }
internal bool ReceiveVideo { get; set; }
internal AttributeType Type { get; set; }
internal Attributes()
{
Instruction = new ProctoringInstructionEventArgs();
}
}
}

View file

@ -16,6 +16,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Server.Contracts.Data;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Server.Data;
using SafeExamBrowser.Server.Requests;
@ -314,19 +315,55 @@ namespace SafeExamBrowser.Server
switch (provider)
{
case "JITSI_MEET":
attributes.Instruction.JitsiMeetRoomName = attributesJson["jitsiMeetRoom"].Value<string>();
attributes.Instruction.JitsiMeetServerUrl = attributesJson["jitsiMeetServerURL"].Value<string>();
attributes.Instruction.JitsiMeetToken = attributesJson["jitsiMeetToken"].Value<string>();
attributes.Instruction = ParseJitsiMeetInstruction(attributesJson);
break;
case "SCREEN_PROCTORING":
attributes.Instruction = ParseScreenProctoringInstruction(attributesJson);
break;
case "ZOOM":
attributes.Instruction.ZoomMeetingNumber = attributesJson["zoomRoom"].Value<string>();
attributes.Instruction.ZoomPassword = attributesJson["zoomMeetingKey"].Value<string>();
attributes.Instruction.ZoomSdkKey = attributesJson["zoomAPIKey"].Value<string>();
attributes.Instruction.ZoomSignature = attributesJson["zoomToken"].Value<string>();
attributes.Instruction.ZoomSubject = attributesJson["zoomSubject"].Value<string>();
attributes.Instruction.ZoomUserName = attributesJson["zoomUserName"].Value<string>();
attributes.Instruction = ParseZoomInstruction(attributesJson);
break;
}
if (attributes.Instruction != default)
{
attributes.Instruction.Method = attributesJson["method"].Value<string>() == "JOIN" ? InstructionMethod.Join : InstructionMethod.Leave;
}
}
private JitsiMeetInstruction ParseJitsiMeetInstruction(JObject attributesJson)
{
return new JitsiMeetInstruction
{
RoomName = attributesJson["jitsiMeetRoom"].Value<string>(),
ServerUrl = attributesJson["jitsiMeetServerURL"].Value<string>(),
Token = attributesJson["jitsiMeetToken"].Value<string>()
};
}
private ScreenProctoringInstruction ParseScreenProctoringInstruction(JObject attributesJson)
{
return new ScreenProctoringInstruction
{
ClientId = attributesJson["screenProctoringClientId"].Value<string>(),
ClientSecret = attributesJson["screenProctoringClientSecret"].Value<string>(),
GroupId = attributesJson["screenProctoringGroupId"].Value<string>(),
ServiceUrl = attributesJson["screenProctoringServiceURL"].Value<string>(),
SessionId = attributesJson["screenProctoringClientSessionId"].Value<string>()
};
}
private ZoomInstruction ParseZoomInstruction(JObject attributesJson)
{
return new ZoomInstruction
{
MeetingNumber = attributesJson["zoomRoom"].Value<string>(),
Password = attributesJson["zoomMeetingKey"].Value<string>(),
SdkKey = attributesJson["zoomAPIKey"].Value<string>(),
Signature = attributesJson["zoomToken"].Value<string>(),
Subject = attributesJson["zoomSubject"].Value<string>(),
UserName = attributesJson["zoomUserName"].Value<string>()
};
}
private void ParseReconfigurationInstruction(Attributes attributes, JObject attributesJson)

View file

@ -20,6 +20,7 @@ using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Server.Contracts;
using SafeExamBrowser.Server.Contracts.Data;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Server.Data;
using SafeExamBrowser.Server.Requests;
using SafeExamBrowser.Settings.Server;

View file

@ -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/.
*/
namespace SafeExamBrowser.Settings.Proctoring
{
/// <summary>
/// Defines all possible image formats for the screen proctoring.
/// </summary>
public enum ImageFormat
{
/// <summary>
/// An image with the Windows Bitmap format.
/// </summary>
Bmp,
/// <summary>
/// An image with the Graphics Interchange Format format.
/// </summary>
Gif,
/// <summary>
/// An image with the Joint Photographic Experts Group format.
/// </summary>
Jpg,
/// <summary>
/// An image with the Portable Network Graphics format.
/// </summary>
Png
}
}

View file

@ -0,0 +1,51 @@
/*
* 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/.
*/
namespace SafeExamBrowser.Settings.Proctoring
{
/// <summary>
/// Defines all possible image quantization algorithms for the screen proctoring.
/// </summary>
public enum ImageQuantization
{
/// <summary>
/// Reduces an image to a black and white image with 1 bit per pixel.
/// </summary>
BlackAndWhite1bpp,
/// <summary>
/// Reduces an image to a colored image with 8 bits per pixel (256 colors).
/// </summary>
Color8bpp,
/// <summary>
/// Reduces an image to a colored image with 16 bits per pixel (65'536 colors).
/// </summary>
Color16bpp,
/// <summary>
/// Reduces an image to a colored image with 24 bits per pixel (16'777'216 colors).
/// </summary>
Color24bpp,
/// <summary>
/// Reduces an image to a grayscale image with 2 bits per pixel (4 shades).
/// </summary>
Grayscale2bpp,
/// <summary>
/// Reduces an image to a grayscale image with 4 bits per pixel (16 shades).
/// </summary>
Grayscale4bpp,
/// <summary>
/// Reduces an image to a grayscale image with 8 bits per pixel (256 shades).
/// </summary>
Grayscale8bpp
}
}

View file

@ -31,6 +31,11 @@ namespace SafeExamBrowser.Settings.Proctoring
/// </summary>
public JitsiMeetSettings JitsiMeet { get; set; }
/// <summary>
/// All settings for the screen proctoring.
/// </summary>
public ScreenProctoringSettings ScreenProctoring { get; set; }
/// <summary>
/// Determines whether the raise hand notification will be shown in the shell.
/// </summary>
@ -54,6 +59,7 @@ namespace SafeExamBrowser.Settings.Proctoring
public ProctoringSettings()
{
JitsiMeet = new JitsiMeetSettings();
ScreenProctoring = new ScreenProctoringSettings();
Zoom = new ZoomSettings();
}
}

View file

@ -0,0 +1,84 @@
/*
* 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;
namespace SafeExamBrowser.Settings.Proctoring
{
/// <summary>
/// All settings for the screen proctoring.
/// </summary>
[Serializable]
public class ScreenProctoringSettings
{
/// <summary>
/// Determines whether the name of the active application shall be captured and transmitted as part of the image meta data.
/// </summary>
public bool CaptureApplicationName { get; set; }
/// <summary>
/// Determines whether the URL of the currently opened web page shall be captured and transmitted as part of the image meta data.
/// </summary>
public bool CaptureBrowserUrl { get; set; }
/// <summary>
/// Determines whether the title of the currently active window shall be captured and transmitted as part of the image meta data.
/// </summary>
public bool CaptureWindowTitle { get; set; }
/// <summary>
/// The client identifier used for authentication with the screen proctoring service.
/// </summary>
public string ClientId { get; set; }
/// <summary>
/// The client secret used for authentication with the screen proctoring service.
/// </summary>
public string ClientSecret { get; set; }
/// <summary>
/// Determines whether the screen proctoring is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// The identifier of the group to which the user belongs.
/// </summary>
public string GroupId { get; set; }
/// <summary>
/// Defines the factor to be used for downscaling of the screen shots.
/// </summary>
public double ImageDownscaling { get; set; }
/// <summary>
/// Defines the image format to be used for the screen shots.
/// </summary>
public ImageFormat ImageFormat { get; set; }
/// <summary>
/// Defines the algorithm to be used for quantization of the screen shots.
/// </summary>
public ImageQuantization ImageQuantization { get; set; }
/// <summary>
/// The maximum time interval in milliseconds between screen shot transmissions.
/// </summary>
public int MaxInterval { get; set; }
/// <summary>
/// The minimum time interval in milliseconds between screen shot transmissions.
/// </summary>
public int MinInterval { get; set; }
/// <summary>
/// The URL of the screen proctoring service.
/// </summary>
public string ServiceUrl { get; set; }
}
}

View file

@ -73,8 +73,11 @@
<Compile Include="Browser\Proxy\ProxyConfiguration.cs" />
<Compile Include="ConfigurationMode.cs" />
<Compile Include="Monitoring\DisplaySettings.cs" />
<Compile Include="Proctoring\ImageFormat.cs" />
<Compile Include="Proctoring\ImageQuantization.cs" />
<Compile Include="Proctoring\JitsiMeetSettings.cs" />
<Compile Include="Proctoring\ProctoringSettings.cs" />
<Compile Include="Proctoring\ScreenProctoringSettings.cs" />
<Compile Include="Proctoring\WindowVisibility.cs" />
<Compile Include="Proctoring\ZoomSettings.cs" />
<Compile Include="Security\ClipboardPolicy.cs" />