From cf6a25d17993ce689d71b6502757e82a6ce47b13 Mon Sep 17 00:00:00 2001 From: Tim Elmer Date: Fri, 9 Oct 2020 15:16:58 -0700 Subject: [PATCH] Ringtones, documentation, cleanup - VoIPainter can now apply ringtones! (Fix #1) - Better handling of log file I/O - Cleaner documents - Minor UI improvements --- LICENSE.md | 18 +- README.md | 50 +++-- VoIPainter/ABOUT.md | 21 ++ VoIPainter/App.xaml | 24 ++- VoIPainter/Control/ImageReformatController.cs | 12 +- VoIPainter/Control/ImageServerController.cs | 124 ----------- VoIPainter/Control/LogController.cs | 31 ++- VoIPainter/Control/MainController.cs | 10 +- VoIPainter/Control/RequestController.cs | 88 +++++++- .../Control/RingtoneReformatController.cs | 130 ++++++++++++ VoIPainter/Control/ServerController.cs | 193 ++++++++++++++++++ VoIPainter/Control/SettingsController.cs | 29 ++- VoIPainter/Model/FadeOutToSampleProvider.cs | 81 ++++++++ VoIPainter/Model/FileFilter.cs | 189 +++++++++++++++++ VoIPainter/Model/Phone.cs | 26 ++- VoIPainter/Model/RequestMode.cs | 11 + VoIPainter/NOTICE.md | 167 +++++++++++++++ VoIPainter/Settings.Designer.cs | 16 +- VoIPainter/Settings.settings | 5 +- VoIPainter/Strings.Designer.cs | 154 +++++++++++++- VoIPainter/Strings.resx | 60 +++++- VoIPainter/View/AboutWindow.xaml | 34 --- VoIPainter/View/AboutWindow.xaml.cs | 24 --- .../IsStringNotNullOrWhitespaceConverter.cs | 28 +++ VoIPainter/View/MainWindow.xaml | 32 ++- VoIPainter/View/MainWindow.xaml.cs | 102 +++++++-- VoIPainter/View/MdWindow.xaml | 20 ++ VoIPainter/View/MdWindow.xaml.cs | 77 +++++++ VoIPainter/View/SettingsWindow.xaml | 12 +- VoIPainter/View/SettingsWindow.xaml.cs | 4 +- VoIPainter/View/UICommon.cs | 17 +- VoIPainter/VoIPainter.csproj | 36 ++-- 32 files changed, 1531 insertions(+), 294 deletions(-) create mode 100644 VoIPainter/ABOUT.md delete mode 100644 VoIPainter/Control/ImageServerController.cs create mode 100644 VoIPainter/Control/RingtoneReformatController.cs create mode 100644 VoIPainter/Control/ServerController.cs create mode 100644 VoIPainter/Model/FadeOutToSampleProvider.cs create mode 100644 VoIPainter/Model/FileFilter.cs create mode 100644 VoIPainter/Model/RequestMode.cs create mode 100644 VoIPainter/NOTICE.md delete mode 100644 VoIPainter/View/AboutWindow.xaml delete mode 100644 VoIPainter/View/AboutWindow.xaml.cs create mode 100644 VoIPainter/View/IsStringNotNullOrWhitespaceConverter.cs create mode 100644 VoIPainter/View/MdWindow.xaml create mode 100644 VoIPainter/View/MdWindow.xaml.cs diff --git a/LICENSE.md b/LICENSE.md index 04f4b2f..fbb5013 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,20 +2,8 @@ MIT License Copyright (c) 2020 Timothy Elmer -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 7cbd1e1..66d61bb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,41 @@ # VoIPainter - ## Description +VoIPainter is a simple Windows utility designed to change the background on a user's Cisco VoIP phone, without requiring access to the CUCM administrative backend. -VoIPainter is a simple Windows utility designed to change the background on a user's Cisco VoIP phone, without requiring access to the CUCM backend. +## Requirements / Caveats +- The phone must be _owned by_ the executing user in CUCM. +- _Personalization must be enabled_ on the phone in CUCM. +- _Web access must be enabled_ on the phone in CUCM (the commands are sent via HTTP). +- The executing user must have _local administrative privileges_; VoIPainter will open a socket on TCP port 80, which requires administrative rights. +- _caveat:_ Key Expansion Modules / Sidecars are currently not supported. They are listed in the specification, but it's not trivially obvious how to specify deployment of a KEM over the phone's wallpaper itself, if possible at all, and I don't have one to test with. -## **Requirements** +## Use +> Note that you may apply a background image, a ringtone, or both simultaneously. -- The phone must be owned by the executing user in CUCM. -- Personalization must be enabled on the phone in CUCM. -- The executing user must have *at least* local administrative privileges; VoIPainter will open a socket on TCP port 80, which requires administrative rights. +### Applying a Background Image +> Note that uncluttered, lower contrast images work best. +1. Click the "Browse Image" button to select an image. +1. Enter [required settings](#required-settings) +1. Click "Apply" -## Use -1. Open VoIPainter. -1. Use the "Browse For Image" button to navigate to and select the desired image. Keep in mind that uncluttered, lower contrast images work best. -1. Find your phone's IP address.[^1] Enter the address in the "Phone IP" field. -[^1]: This can typically be acquired from the phone's settings menu under "Phone Information", "Status", or similar. It should be in the form `0.0.0.0`. -1. Enter your domain username (e.g. Timothy Elmer -> telmer) in the "Username" field. -1. Enter your domain password in the "Password" field. -1. Select your phone model from the "Phone Model" dropdown. If you are unsure, the model is typically labeled on the back/bottom of the phone. -1. Click "Apply". +### Applying a Ringtone +> Note that the chosen media file will automatically be trimmed to the first 20 seconds (2 seconds on older phones). If you wish to use a specific part of a longer file, you can first cut it down using an audio editor such as [Audacity](https://www.audacityteam.org/). +1. Click the "Browse Ringtone" button to select an audio file. +1. Enter [required settings](#required-settings) +1. Click "Apply" + +### Required Settings +| Setting | Explanation | +| ---: | --- | +| Phone IP | The IP address of your phone. This can typically be acquired from the phone's settings menu under "Phone Information", "Status", or similar. It should be in the form `0.0.0.0`. This can be acquired from the phone's settings menu under "Phone Information", "Status", or similar. | +| Username | Your domain username | +| Password | Your domain password | +| Phone Model | The model of your phone. If you are unsure, the model is typically labeled on the back/bottom of the device. | + +## Settings +| Setting | Explanation | +| ---: | --- | +| Resize Mode | How under/oversized images will be treated: Stretch: The image will be stretched to fit the screen; **Crop (recommended)**: The largest dimension will be cropped on the center of the image to fit the screen; Center: The image will be scaled to fit, and centered on the screen.| +| Target Contrast | The maximum contrast that images should have. **The default value of `0.6` is recommended.** | +| Automatically Duck Contrast | If images with excessive contrast should have their contrast lowered. +| Ringtone Fade Out | How long the ringtone will fade out at the end. To not fade out, set to `0`. Must be between `0` and `20` seconds. | \ No newline at end of file diff --git a/VoIPainter/ABOUT.md b/VoIPainter/ABOUT.md new file mode 100644 index 0000000..7ff5994 --- /dev/null +++ b/VoIPainter/ABOUT.md @@ -0,0 +1,21 @@ +VoIPainter developed by Timothy Elmer under the MIT License: + +Copyright (c) 2020 Timothy Elmer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For convenience, the licenses for associated works are linked below. They are also listed, in order of inclusion on this page, in the NOTICES.md file distributed with this Software. + +VoIPainter uses code based on the work of Gareth Palmer; [how to request CGI execution on a Cisco VoIP phone](https://usecallmanager.nz/cgi-execute-xml.html), [specifications for Cisco VoIP phone background image requirements](https://usecallmanager.nz/image-list-xml.html), and [specifications for Cisco VoIP phone ringtone requirements](https://usecallmanager.nz/ring-list-xml.html). These works are unlicensed, but have been used for reference rather than reproduction. + +VoIPainter uses the [ImageSharp](https://github.com/SixLabors/ImageSharp) library for image manipulation. ImageSharp licensed under the [Apache License 2.0](https://github.com/SixLabors/ImageSharp/blob/master/LICENSE). + +VoIPainter uses the [NAudio](https://github.com/naudio/NAudio) library for audio manipulation. NAudio is licensed under the [MS-PL](https://github.com/naudio/NAudio/blob/master/license.txt). + +VoIPainter uses the [Markdig](https://github.com/lunet-io/markdig) and [Neo.Markdig.Xaml](https://github.com/neolithos/NeoMarkdigXaml) libraries for Markdown rendering. These libraries are licensed under the [BSD 2-Clause](https://github.com/lunet-io/markdig/blob/master/license.txt) and [Apache-2.0](https://github.com/neolithos/NeoMarkdigXaml/blob/master/LICENSE.md) respectively. + +VoIPainter is written in [.NET core](https://github.com/dotnet/core). .NET core is licensed under the [MIT license](https://github.com/dotnet/core/blob/master/LICENSE.TXT). \ No newline at end of file diff --git a/VoIPainter/App.xaml b/VoIPainter/App.xaml index 79336c9..dae19e2 100644 --- a/VoIPainter/App.xaml +++ b/VoIPainter/App.xaml @@ -1,6 +1,7 @@  + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:view="clr-namespace:VoIPainter.View"> - + + + + diff --git a/VoIPainter/Control/ImageReformatController.cs b/VoIPainter/Control/ImageReformatController.cs index fbb0ef9..e621561 100644 --- a/VoIPainter/Control/ImageReformatController.cs +++ b/VoIPainter/Control/ImageReformatController.cs @@ -21,16 +21,26 @@ public class ImageReformatController : INotifyPropertyChanged private readonly LogController _logController; private Image _original; + /// /// The path to the image /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "General exceptions caught for display to user")] public string Path { get => _path; set { _path = value; - _original = Image.Load(Path); + try + { + _original = Image.Load(Path); + } + catch (Exception e) + { + _logController.Log(new Entry(LogSeverity.Error, e.Message)); + return; + } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Path))); Format(); } diff --git a/VoIPainter/Control/ImageServerController.cs b/VoIPainter/Control/ImageServerController.cs deleted file mode 100644 index 55c8695..0000000 --- a/VoIPainter/Control/ImageServerController.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Globalization; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using VoIPainter.Model.Logging; - -namespace VoIPainter.Control -{ - /// - /// Handles serving images to phone - /// - public class ImageServerController : IDisposable - { - private CancellationTokenSource _cancellationTokenSource; - private readonly LogController _logController; - private readonly MainController _mainController; - - public ImageServerController(MainController mainController) - { - _mainController = mainController ?? throw new ArgumentNullException(nameof(mainController)); - - _logController = _mainController.LogController; - } - - public void Run() - { - if (!(_cancellationTokenSource is null)) - _cancellationTokenSource.Dispose(); - _cancellationTokenSource = new CancellationTokenSource(); - _ = Task.Run(() => Worker(_cancellationTokenSource.Token)); - } - - private void Worker(object cancellationToken) - { - using var httpListener = new HttpListener(); - - _logController.Log(new Entry(LogSeverity.Info, Strings.StatusImageServerStarting)); - - var token = (CancellationToken)cancellationToken; - - httpListener.Prefixes.Clear(); - httpListener.Prefixes.Add($"http://{GetIp()}/"); - httpListener.Start(); - - while (true) - { - if (token.IsCancellationRequested) - break; - - var context = httpListener.GetContext(); - var request = context.Request; - var response = context.Response; - - if (token.IsCancellationRequested) - break; - - if (_mainController.ImageReformatController is null || _mainController.ImageReformatController.Image is null) - { - _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestNotReady, request.RemoteEndPoint.Address))); - response.StatusCode = (int)HttpStatusCode.InternalServerError; - response.Close(); - continue; - } - - var thumbnail = request.Url.ToString().Equals($"http://{GetIp()}/bg-tn.png", StringComparison.InvariantCultureIgnoreCase); - - if (thumbnail) - _logController.Log(new Entry(LogSeverity.Info, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestThumbnail, request.RemoteEndPoint.Address))); - else if (request.Url.ToString().Equals($"http://{GetIp()}/bg.png", StringComparison.InvariantCultureIgnoreCase)) - _logController.Log(new Entry(LogSeverity.Info, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestImage, request.RemoteEndPoint.Address))); - else - { - _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestInvalid, request.RemoteEndPoint.Address, request.Url.AbsolutePath))); - response.StatusCode = (int)HttpStatusCode.NotFound; - response.Close(); - continue; - } - - response.StatusCode = (int)HttpStatusCode.OK; - - var outputStream = response.OutputStream; - - if (!token.IsCancellationRequested) - _mainController.ImageReformatController.Stream(outputStream, thumbnail); - - outputStream.Close(); - response.Close(); - } - - httpListener.Stop(); - httpListener.Close(); - _logController.Log(new Entry(LogSeverity.Info, Strings.StatusImageServerStopped)); - } - - public void Stop() - { - _cancellationTokenSource.Cancel(); - _logController.Log(new Entry(LogSeverity.Info, Strings.StatusImageServerStopping)); - } - - public static string GetIp() - { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.IP); - // Bounce off Google to get our IP. Our local IP. Yes, it's silly, but it works. We want a valid socket to grab the local endpoint from, and this gets us one. - socket.Connect("8.8.8.8", 65530); - var endPoint = socket.LocalEndPoint as IPEndPoint; - return endPoint.Address.ToString(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposeManaged) - { - if (disposeManaged && !(_cancellationTokenSource is null)) - _cancellationTokenSource.Dispose(); - } - } -} diff --git a/VoIPainter/Control/LogController.cs b/VoIPainter/Control/LogController.cs index 433d119..330e8fe 100644 --- a/VoIPainter/Control/LogController.cs +++ b/VoIPainter/Control/LogController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Globalization; +using System.IO; using VoIPainter.Model.Logging; namespace VoIPainter.Control @@ -8,17 +9,43 @@ namespace VoIPainter.Control /// /// Handles logging /// - public class LogController + public class LogController : IDisposable { + private readonly StreamWriter _logStreamWriter; + + /// + /// Entries in the log + /// public ObservableCollection LogEntries { get; private set; } = new ObservableCollection(); + public LogController() + { + _logStreamWriter = new StreamWriter(File.OpenWrite(".\\VoIPainter.log")); + } + + /// + /// Log an entry + /// + /// The entry public void Log(Entry entry) { if (entry is null) throw new ArgumentNullException(nameof(entry)); System.Windows.Application.Current.Dispatcher.Invoke(() => LogEntries.Add(entry)); - System.IO.File.AppendAllText(".\\VoIPainter.log", string.Format(CultureInfo.InvariantCulture, $"{Strings.LogFormat}\r\n", entry.Time, entry.Severity, entry.Message)); + _logStreamWriter.WriteLine(string.Format(CultureInfo.InvariantCulture, $"{Strings.LogFormat}\r\n", entry.Time, entry.Severity, entry.Message)); + } + + protected virtual void Dispose(bool disposeManaged) + { + if (disposeManaged) + _logStreamWriter.Dispose(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); } } } diff --git a/VoIPainter/Control/MainController.cs b/VoIPainter/Control/MainController.cs index 005f89a..370b3a5 100644 --- a/VoIPainter/Control/MainController.cs +++ b/VoIPainter/Control/MainController.cs @@ -10,12 +10,12 @@ public class MainController private static MainController _instance; public SettingsController SettingsController { get; } - public LogController LogController { get; } = new LogController(); - public ImageServerController ImageServerController { get; } + public ServerController ImageServerController { get; } public RequestController RequestController { get; } public UpdateCheckController UpdateCheckController { get; } public ImageReformatController ImageReformatController { get; } + public RingtoneReformatController RingtoneReformatController { get; } public View.MainWindow MainWindow { get; } public MainController() @@ -24,12 +24,16 @@ public MainController() throw new InvalidOperationException(); _instance = this; + #region Initialize SettingsController = new SettingsController(LogController); ImageReformatController = new ImageReformatController(this); - ImageServerController = new ImageServerController(this); + RingtoneReformatController = new RingtoneReformatController(this); + ImageServerController = new ServerController(this); RequestController = new RequestController(this); UpdateCheckController = new UpdateCheckController(LogController); MainWindow = new View.MainWindow(this); + #endregion Initialize + MainWindow.Show(); } } diff --git a/VoIPainter/Control/RequestController.cs b/VoIPainter/Control/RequestController.cs index 5625edd..c386277 100644 --- a/VoIPainter/Control/RequestController.cs +++ b/VoIPainter/Control/RequestController.cs @@ -5,6 +5,7 @@ using VoIPainter.Model.Logging; using System.Threading.Tasks; using System.Globalization; +using System.Xml.Linq; namespace VoIPainter.Control { @@ -18,14 +19,24 @@ public class RequestController : IDisposable private readonly MainController _mainController; private readonly LogController _logController; - //public event EventHandler LoggingEvent; + /// + /// Map for phone error descriptions + /// + private static readonly Dictionary _phoneErrorDescriptions = new Dictionary() + { + { 1, Strings.PhoneErrorParsingRequest }, + { 2, Strings.PhoneErrorFramingResponse }, + { 3, Strings.PhoneErrorInternalFile }, + { 4, Strings.PhoneErrorAuthentication } + }; public RequestController(MainController mainController) { _mainController = mainController ?? throw new ArgumentNullException(nameof(mainController)); _logController = _mainController.LogController; - // Ignore bad certs + // Ignore bad certs(!) + // This is unfortunately necessary, as the chances of an IP phone having a valid cert are fairly low. We use HTTPS anyways though, because it still provides better security than sending our credential across the network in plaintext. _httpClientHandler = new HttpClientHandler() { ClientCertificateOptions = ClientCertificateOption.Manual, @@ -37,7 +48,7 @@ public RequestController(MainController mainController) /// Send request /// /// Password - public async Task Send(string password) + public async Task Send(string password, Model.RequestMode requestMode) { if (string.IsNullOrWhiteSpace(password)) throw new ArgumentNullException(nameof(password), Strings.ValidationPassword); @@ -53,11 +64,17 @@ public async Task Send(string password) using var request = new HttpRequestMessage(HttpMethod.Post, "/CGI/Execute"); // The command - var keyValues = new List> + var keyValues = new List>(); + + switch (requestMode) { - // Set the background and thumbnail to images on our computer - new KeyValuePair("XML", $"http://{ImageServerController.GetIp()}/bg.pnghttp://{ImageServerController.GetIp()}/bg-tn.png") - }; + case Model.RequestMode.Background: + keyValues.Add(new KeyValuePair("XML", $"http://{ServerController.GetIp()}/bg.pnghttp://{ServerController.GetIp()}/bg-tn.png")); + break; + case Model.RequestMode.Ringtone: + keyValues.Add(new KeyValuePair("XML", $"http://{ServerController.GetIp()}/rt.raw")); + break; + } // Properly escape the command request.Content = new FormUrlEncodedContent(keyValues); @@ -70,13 +87,68 @@ public async Task Send(string password) // Handle response var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - _logController.Log(new Entry(responseContent.Contains("success", StringComparison.OrdinalIgnoreCase) ? LogSeverity.Info : LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.TGotResponse, response.StatusCode, responseContent))); + if (responseContent.Contains("success", StringComparison.OrdinalIgnoreCase)) + _logController.Log(new Entry(LogSeverity.Info, Strings.StatusSuccess)); + else + { + var phoneError = ParsePhoneError(XElement.Parse(responseContent)); + if (string.IsNullOrWhiteSpace(phoneError.Message)) + _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.TRequestFailed, phoneError.Description))); + else + _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.TRequestFailedMessage, phoneError.Description, phoneError.Message))); + + } // Clear credentials _httpClient.DefaultRequestHeaders.Authorization = null; GC.Collect(); } + /// + /// Represents a CiscoIPPhoneError + /// + private struct PhoneError + { + /// + /// The error number + /// + public int ErrorNumber { get; } + + /// + /// The error description + /// + public string Description { get; } + + /// + /// The error message + /// + public string Message { get; } + + public PhoneError(int errorNumber, string message, string detail) + { + ErrorNumber = errorNumber; + Description = message; + Message = detail; + } + } + + /// + /// Parse a CiscoIPPhoneError + /// + /// The raw response from the phone + /// The parsed PhoneError + private static PhoneError ParsePhoneError(XElement responseContent) + { + if (!(responseContent.Element("type") is null)) + return new PhoneError(0, "", responseContent.Element("data").Value); + else + { + // Expects ` optional error message ` + var errorNumber = int.Parse(responseContent.Attribute("Number").Value, NumberStyles.Integer, CultureInfo.InvariantCulture); + return new PhoneError(errorNumber, _phoneErrorDescriptions[errorNumber], responseContent.Value); + } + } + protected virtual void Dispose(bool disposeManaged) { if (disposeManaged) diff --git a/VoIPainter/Control/RingtoneReformatController.cs b/VoIPainter/Control/RingtoneReformatController.cs new file mode 100644 index 0000000..3897e3a --- /dev/null +++ b/VoIPainter/Control/RingtoneReformatController.cs @@ -0,0 +1,130 @@ +using NAudio.Wave; +using System; +using System.ComponentModel; +using System.IO; +using VoIPainter.Model; + +namespace VoIPainter.Control +{ + /// + /// Handles formatting of ringtones + /// + public class RingtoneReformatController : INotifyPropertyChanged + { + private string _path; + private readonly MainController _mainController; + private readonly LogController _logController; + private byte[] _audio; + public event PropertyChangedEventHandler PropertyChanged; + + internal static readonly WaveFormat WAVE_FORMAT = WaveFormat.CreateMuLawFormat(8_000, 1); + + /// + /// The path to the ringtone + /// + public string Path + { + get => _path; + set + { + _path = value; + Format(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Path))); + } + } + + /// + /// The formatted ringtone as a memory stream + /// + public Stream Ringtone { get; private set; } + + public RingtoneReformatController(MainController mainController) + { + _mainController = mainController ?? throw new ArgumentNullException(nameof(mainController)); + + _mainController.SettingsController.PropertyChanged += (s, e) => + { + switch (e.PropertyName) + { + case nameof(SettingsController.LastModel): + case nameof(SettingsController.FadeOutTime): + Format(); + break; + } + }; + _logController = _mainController.LogController; + } + + + /// + /// Read and format the ringtone + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exceptions caught generally for display to user")] + public void Format() + { + if (string.IsNullOrWhiteSpace(Path)) + return; + + _logController.Log(new Model.Logging.Entry(Model.Logging.LogSeverity.Info, Strings.StatusFormattingTone)); + try + { + if (!(Ringtone is null)) + Ringtone = null; + + // Load file + using var reader = new AudioFileReader(Path); + + // Conform to 8KHz 16b x1 + var conform = new WaveFormat(8_000, 16, 1); + using var resampler = new MediaFoundationResampler(reader, conform); + + // Fade out + var fader = new FadeOutToSampleProvider(resampler.ToSampleProvider()); + fader.FadeOutTo(Phone.RingLength(_mainController.SettingsController.LastModel), _mainController.SettingsController.FadeOutTime); + var fadeBuffer = new float[Phone.RingLength(_mainController.SettingsController.LastModel)]; + fader.Read(fadeBuffer, 0, fadeBuffer.Length); + + // Convert from samples to PCM + var streamBuffer = GetSamplesWaveData(fadeBuffer, fadeBuffer.Length); + using var stream = new RawSourceWaveStream(new MemoryStream(streamBuffer), conform); + + // Encode to MuLaw + using var encoder = new WaveFormatConversionStream(WAVE_FORMAT, stream); + _audio = new byte[Phone.RingLength(_mainController.SettingsController.LastModel)]; + encoder.Read(_audio, 0, _audio.Length); + + // Apply to property + Ringtone = new RawSourceWaveStream(new MemoryStream(_audio, false), WAVE_FORMAT); + + _logController.Log(new Model.Logging.Entry(Model.Logging.LogSeverity.Info, Strings.StatusFormattingToneDone)); + } + catch (Exception e) + { + _logController.Log(new Model.Logging.Entry(Model.Logging.LogSeverity.Error, e.Message)); + } + } + + // Adapted from https://stackoverflow.com/a/42151979 + private static byte[] GetSamplesWaveData(float[] samples, int samplesCount) + { + if (samples is null) + throw new ArgumentNullException(nameof(samples)); + + var pcm = new byte[samplesCount * 2]; + int sampleIndex = 0, + pcmIndex = 0; + + while (sampleIndex < samplesCount) + { + var outsample = (short)(samples[sampleIndex] * short.MaxValue); + pcm[pcmIndex] = (byte)(outsample & 0xff); + pcm[pcmIndex + 1] = (byte)((outsample >> 8) & 0xff); + + sampleIndex++; + pcmIndex += 2; + } + + return pcm; + } + } +} diff --git a/VoIPainter/Control/ServerController.cs b/VoIPainter/Control/ServerController.cs new file mode 100644 index 0000000..056a3c6 --- /dev/null +++ b/VoIPainter/Control/ServerController.cs @@ -0,0 +1,193 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using VoIPainter.Model.Logging; + +namespace VoIPainter.Control +{ + /// + /// Handles serving files to phone + /// + public class ServerController : IDisposable + { + private CancellationTokenSource _cancellationTokenSource; + private readonly LogController _logController; + private readonly MainController _mainController; + + public ServerController(MainController mainController) + { + _mainController = mainController ?? throw new ArgumentNullException(nameof(mainController)); + + _logController = _mainController.LogController; + } + + /// + /// Run the server + /// + public void Run() + { + if (!(_cancellationTokenSource is null)) + _cancellationTokenSource.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + _ = Task.Run(() => Worker(_cancellationTokenSource.Token)); + } + + /// + /// Worker thread + /// + /// Token for canceling server operation + private void Worker(object cancellationToken) + { + using var httpListener = new HttpListener(); + + _logController.Log(new Entry(LogSeverity.Info, Strings.StatusImageServerStarting)); + + var token = (CancellationToken)cancellationToken; + + httpListener.Prefixes.Clear(); + httpListener.Prefixes.Add($"http://{GetIp()}/"); + httpListener.Start(); + + while (true) + { + if (token.IsCancellationRequested) + break; + + var context = httpListener.GetContext(); + var request = context.Request; + var response = context.Response; + + if (token.IsCancellationRequested) + break; + + // Handle thumbnail + if (request.Url.ToString().Equals($"http://{GetIp()}/bg-tn.png", StringComparison.InvariantCultureIgnoreCase)) + ServeImage(request, response, token, true); + + // Handle image + else if (request.Url.ToString().Equals($"http://{GetIp()}/bg.png", StringComparison.InvariantCultureIgnoreCase)) + ServeImage(request, response, token); + + // Handle ringtone + else if (request.Url.ToString().Equals($"http://{GetIp()}/rt.raw", StringComparison.InvariantCultureIgnoreCase)) + ServeTone(request, response, token); + + // Handle invalid + else + { + _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestInvalid, request.RemoteEndPoint.Address, request.Url.AbsolutePath))); + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + continue; + } + } + + httpListener.Stop(); + httpListener.Close(); + _logController.Log(new Entry(LogSeverity.Info, Strings.StatusImageServerStopped)); + } + + /// + /// Serve an image to the phone + /// + /// The request + /// The response + /// Cancelation token + /// Request is for thumbnail + private void ServeImage(HttpListenerRequest request, HttpListenerResponse response, CancellationToken token, bool thumbnail = false) + { + _logController.Log(new Entry(LogSeverity.Info, string.Format(CultureInfo.InvariantCulture, thumbnail ? Strings.StatusPhoneRequestThumbnail : Strings.StatusPhoneRequestImage, request.RemoteEndPoint.Address))); + + // Check for image availability + if (_mainController.ImageReformatController is null || _mainController.ImageReformatController.Image is null) + { + _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestNotReady, request.RemoteEndPoint.Address))); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + else + { + response.StatusCode = (int)HttpStatusCode.OK; + + if (!token.IsCancellationRequested) + _mainController.ImageReformatController.Stream(response.OutputStream, thumbnail); + else + response.StatusCode = (int)HttpStatusCode.InternalServerError; + response.OutputStream.Close(); + } + response.Close(); + } + + /// + /// Serve a ringtone to the phone + /// + /// The request + /// The response + /// Cancelation token + private void ServeTone(HttpListenerRequest request, HttpListenerResponse response, CancellationToken token) + { + _logController.Log(new Entry(LogSeverity.Info, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestRingtone, request.RemoteEndPoint.Address))); + + // Check for ringtone availability + if (_mainController.RingtoneReformatController is null || _mainController.RingtoneReformatController.Ringtone is null) + { + _logController.Log(new Entry(LogSeverity.Error, string.Format(CultureInfo.InvariantCulture, Strings.StatusPhoneRequestNotReady, request.RemoteEndPoint.Address))); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + else + { + response.StatusCode = (int)HttpStatusCode.OK; + + if (!token.IsCancellationRequested) + { + _mainController.RingtoneReformatController.Ringtone.Position = 0; + + var buffer = new byte[_mainController.RingtoneReformatController.Ringtone.Length]; + _mainController.RingtoneReformatController.Ringtone.Read(buffer); + response.OutputStream.Write(buffer, 0, buffer.Length); + } + else + response.StatusCode = (int)HttpStatusCode.InternalServerError; + response.OutputStream.Close(); + } + response.Close(); + } + + /// + /// Stop the server + /// + public void Stop() + { + _cancellationTokenSource.Cancel(); + _logController.Log(new Entry(LogSeverity.Info, Strings.StatusImageServerStopping)); + } + + /// + /// Get this host's local IP + /// + /// + public static string GetIp() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.IP); + // Bounce off CloudFlare to get our IP. Our local IP. Yes, it's silly, but it works. We want a valid socket to grab the local endpoint from, and this gets us one. + + socket.Connect("1.1.1.1", 65530); + var endPoint = socket.LocalEndPoint as IPEndPoint; + return endPoint.Address.ToString(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposeManaged) + { + if (disposeManaged && !(_cancellationTokenSource is null)) + _cancellationTokenSource.Dispose(); + } + } +} diff --git a/VoIPainter/Control/SettingsController.cs b/VoIPainter/Control/SettingsController.cs index 04aba65..ee51f39 100644 --- a/VoIPainter/Control/SettingsController.cs +++ b/VoIPainter/Control/SettingsController.cs @@ -35,6 +35,9 @@ public string LastTarget } } + /// + /// The username + /// public string LastUser { get => Settings.Default.LastUser; @@ -82,7 +85,7 @@ public ImageResizeMode.Mode ResizeMode set { if (!Enum.GetNames(typeof(ImageResizeMode.Mode)).Contains(value.ToString())) - throw new ArgumentOutOfRangeException(nameof(ResizeMode)); + throw new ArgumentOutOfRangeException(nameof(ResizeMode), Strings.ValidationResizeMode); Settings.Default.ResizeMode = value.ToString(); OnPropertyChanged(nameof(ResizeMode)); Settings.Default.Save(); @@ -108,24 +111,43 @@ public bool AutoDuckContrast } } + /// + /// The desired contrast level + /// public float TargetContrast { get => Settings.Default.TargetContrast; set { if (value < 0 || value > 1) - throw new ArgumentOutOfRangeException(nameof(TargetContrast)); + throw new ArgumentOutOfRangeException(nameof(TargetContrast), Strings.ValdidationTargetContrast); Settings.Default.TargetContrast = value; OnPropertyChanged(nameof(TargetContrast)); Settings.Default.Save(); } } + /// + /// The time taken to fade the ringtone out, in seconds. + /// + public double FadeOutTime + { + get => Settings.Default.FadeOutTime; + set + { + if (value < 0 || value > 20) + throw new ArgumentOutOfRangeException(nameof(FadeOutTime), Strings.ValidationFadeOutTime); + Settings.Default.FadeOutTime = value; + OnPropertyChanged(nameof(FadeOutTime)); + Settings.Default.Save(); + } + } + public SettingsController(LogController logController) { _logController = logController ?? throw new ArgumentNullException(nameof(logController)); - // Borrowed from https://stackoverflow.com/a/2698338 + // Borrowed from https://stackoverflow.com/a/2698338 to handle version upgrades if (Settings.Default.UpgradeRequired) { Settings.Default.Upgrade(); @@ -135,6 +157,7 @@ public SettingsController(LogController logController) _logController.Log(new Entry(LogSeverity.Info, Strings.StatusUpgradeSettings)); } + // Borrow the username from the user's session if not present in the settings if (string.IsNullOrWhiteSpace(LastUser)) LastUser = Environment.UserName; } diff --git a/VoIPainter/Model/FadeOutToSampleProvider.cs b/VoIPainter/Model/FadeOutToSampleProvider.cs new file mode 100644 index 0000000..c59d370 --- /dev/null +++ b/VoIPainter/Model/FadeOutToSampleProvider.cs @@ -0,0 +1,81 @@ +using NAudio.Wave; +using System; + +namespace VoIPainter.Model +{ + /// + /// Fades out a sample stream + /// + /// Adapted from https://gist.github.com/markheath/8fb396a5fe4bf117f361 + public class FadeOutToSampleProvider : ISampleProvider + { + private readonly object _lock = new object(); + private readonly ISampleProvider _source; + private int _fadeOutSampleCount; + private int _fadeOutDelaySamples; + + /// + /// Creates a new FadeOutToSampleProvider + /// + /// The source stream with the audio to be faded in or out + public FadeOutToSampleProvider(ISampleProvider source) => + _source = source ?? throw new ArgumentNullException(nameof(source)); + + /// + /// Requests that a fade-out begins to the given target, from the given duration earlier + /// + /// The sample to end the fade at + /// How long in seconds before to start the fade + public void FadeOutTo(int fadeToSamples, double fadeDuration) + { + lock (_lock) + { + _fadeOutSampleCount = (int)(fadeDuration * WaveFormat.SampleRate); + _fadeOutDelaySamples = fadeToSamples - _fadeOutSampleCount; + } + } + + /// + public WaveFormat WaveFormat => _source.WaveFormat; + + /// + public int Read(float[] buffer, int offset, int count) + { + if (buffer is null) + throw new ArgumentNullException(nameof(buffer)); + + int sourceSamplesRead = 0; + var stride = WaveFormat.Channels; + + + for (var sample = offset; sample * stride + offset < count; sample += stride) + { + sourceSamplesRead += _source.Read(buffer, offset + sample, stride); + + lock (_lock) + { + if (_fadeOutSampleCount <= 0 || sample * stride + offset < _fadeOutDelaySamples) ; + else if (sample * stride + offset > _fadeOutDelaySamples + _fadeOutSampleCount) + buffer[offset] = 0; + else + Fade(buffer, sample * stride + offset); + } + } + return sourceSamplesRead; + } + + /// + /// Fade a sample based on its position in the fade + /// + /// Buffer of samples + /// Sample to fade + private void Fade(float[] buffer, int offset) + { + if (offset > _fadeOutSampleCount + _fadeOutDelaySamples) + buffer[offset] = 0; + else + for (int channel = 0; channel < _source.WaveFormat.Channels; channel++) + buffer[offset] *= 1f - (offset - _fadeOutDelaySamples) / (float)_fadeOutSampleCount; + } + } +} diff --git a/VoIPainter/Model/FileFilter.cs b/VoIPainter/Model/FileFilter.cs new file mode 100644 index 0000000..6a84ee2 --- /dev/null +++ b/VoIPainter/Model/FileFilter.cs @@ -0,0 +1,189 @@ +// Currently unused + +//using System; +//using System.Collections.Generic; +//using System.Linq; +//using System.Globalization; + +//namespace VoIPainter.Model +//{ +// public class FileFilter +// { +// private static readonly string[] +// #region Audio Formats +// RAW = new string[] { "*.f32", "*.f64", "*.s8", "*.s16", "*.s24", "*.s32", "*.u8", "*.u16", "*.u24", "*.u32", "*.ul", "*.al", "*.lu", "*.la", "*.f4", "*.f8", "*.s1", "*.s2", "*.s3", "*.s4", "*.u2", "*.u3", "*.u4", "*.sb", "*.sw", "*.sl", "*.ub", "*.uw", "*.fssd", "*.sou" }, +// AMIGA = new string[] { "*.8svx" }, +// AIFF = new string[] { "*.aiff", "*.aif", "*.aiffc", "*.aifc" }, +// AMB = new string[] { "*.amb" }, +// SUN = new string[] { "*.au", "*.snd" }, +// AVR = new string[] { "*.avr" }, +// RED_CD = new string[] { "*.cdda", "*.cdr" }, +// CVSDM = new string[] { "*.cvsd", "*.cvs", "*.cvsu" }, +// DATA = new string[] { "*.dat" }, +// DE_VM = new string[] { "*.dvms", "*.vms" }, +// AMR = new string[] { "*.amr-nb", "*.amr-wb" }, +// APPLE_CORE = new string[] { "*.caf" }, +// PARIS = new string[] { "*.paf", "*.fap" }, +// FLAC = new string[] { "*.flac" }, +// GRANDSTREAM = new string[] { "*.gsrt" }, +// GSM = new string[] { "*.gsm" }, +// HCOM = new string[] { "*.hcom" }, +// HTK = new string[] { "*.htk" }, +// IRCAM = new string[] { "*.sf", "*.ircam" }, +// IMA = new string[] { "*.ima" }, +// LPC = new string[] { "*.lpc", "*.lpc10" }, +// MATLAB = new string[] { "*.mat", "*.mat4", "*.mat5" }, +// MACROSYS = new string[] { "*.maud" }, +// SPHERE = new string[] { "*.sph", "*.nist" }, +// OGG = new string[] { "*.ogg", "*.vorbis" }, +// OPUS = new string[] { "*.opus" }, +// PSION = new string[] { "*.prc", "*.wve" }, +// PORTABLE_VOICE = new string[] { "*.pvf" }, +// SOUND_DESIGN = new string[] { "*.sd2" }, +// MIDI = new string[] { "*.sds" }, +// ASTERISK = new string[] { "*.sln" }, +// TURTLE_BEACH = new string[] { "*.smp" }, +// SOUNDER = new string[] { "*.sndr", "*.snd" }, +// SOUND_TOOL = new string[] { "*.sndt", "*.snd" }, +// SOX = new string[] { "*.sox" }, +// YAMAHA = new string[] { "*.txw" }, +// SOUND_BLASTER = new string[] { "*.voc" }, +// DIALOGIC = new string[] { "*.vox" }, +// SONIC_FOUNDRY = new string[] { "*.w64" }, +// WAV = new string[] { "*.wav", "*.wavpcm" }, +// WAVPAK = new string[] { "*.wv" }, +// MAXIS = new string[] { "*.xa" }, +// FASTTRACKER = new string[] { "*.xi" }, +// MPEG = new string[] { "*.mp3", "*.mp2" }, +// #endregion Audio Formats +// #region Image Formats +// JPEG = new string[] { "*.jpg", "*.jpeg", "*.jpe", "*.jfif", "*.jif" }, +// PNG = new string[] { "*.png" }, +// BMP = new string[] { "*.bmp", "*.dib" }, +// GIF = new string[] { "*.gif" }, +// TGA = new string[] { "*.tga", "*.icb", "*.vda", "*.vst" }; +// #endregion Image Formats + +// private static readonly List AudioFileFamilies = new List() +// { +// new FilterItem("Raw files", RAW), +// new FilterItem("Amiga 8SVX", AMIGA), +// new FilterItem("AIFF", AIFF), +// new FilterItem("Ambisonic B", AMB), +// new FilterItem("Sun Microsystems", SUN), +// new FilterItem("Audio Visual Research", AVR), +// new FilterItem("Red Book Compact Disk", RED_CD), +// new FilterItem("Continuously Variable Slope Delta Modulation", CVSDM), +// new FilterItem("Data", DATA), +// new FilterItem("German Voicemail", DE_VM), +// new FilterItem("Adaptive Multi Rate", AMR), +// new FilterItem("Apple Core Audio", APPLE_CORE), +// new FilterItem("Ensoniq PARIS", PARIS), +// new FilterItem("FLAC", FLAC, true), +// new FilterItem("Grandstream", GRANDSTREAM), +// new FilterItem("GSM", GSM), +// new FilterItem("HCOM", HCOM), +// new FilterItem("HTK", HTK), +// new FilterItem("IRCAM SDIF", IRCAM), +// new FilterItem("IMA", IMA), +// new FilterItem("LPC-10", LPC), +// new FilterItem("MatLab", MATLAB), +// new FilterItem("MS MacroSystem", MACROSYS), +// new FilterItem("SPHERE", SPHERE), +// new FilterItem("Ogg Vorbis", OGG, true), +// new FilterItem("Opus", OPUS), +// new FilterItem("Psion", PSION), +// new FilterItem("Portable Voice", PORTABLE_VOICE), +// new FilterItem("Sound Designer 2", SOUND_DESIGN), +// new FilterItem("MIDI Sample", MIDI), +// new FilterItem("Asterisk PBX", ASTERISK), +// new FilterItem("Turtle Beach SampleVision", TURTLE_BEACH), +// new FilterItem("Sounder", SOUNDER), +// new FilterItem("SoundTool", SOUND_TOOL), +// new FilterItem("SoX Native", SOX), +// new FilterItem("Yamaha TX-16W", YAMAHA), +// new FilterItem("Sound Blaster", SOUND_BLASTER), +// new FilterItem("Dialogic / OKI", DIALOGIC), +// new FilterItem("Sonic Foundry", SONIC_FOUNDRY), +// new FilterItem("Waveform", WAV, true), +// new FilterItem("WavPack", WAVPAK), +// new FilterItem("Maxis", MAXIS), +// new FilterItem("Fasttracker 2", FASTTRACKER), +// new FilterItem("MPEG", MPEG, true) +// }; +// private static readonly List ImageFileFamilies = new List() +// { +// new FilterItem("JPEG", JPEG, true), +// new FilterItem("PNG", PNG, true), +// new FilterItem("BMP", BMP), +// new FilterItem("GIF", GIF), +// new FilterItem("TGA", TGA) +// }; + +// private struct FilterItem +// { +// public string Name { get; private set; } +// public string[] Extensions { get; private set; } +// public bool Common { get; private set; } +// public string FilterString { get; private set; } + +// public FilterItem(string name, string[] extensions, bool common = false) +// { +// Name = name; +// Extensions = extensions; +// Common = common; +// FilterString = string.Format(CultureInfo.CurrentCulture, "{0} ({1})|{2}", Name, string.Format(CultureInfo.CurrentCulture, Extensions.Length > 10 ? "{0}..." : "{0}", string.Join(", ", Extensions.Take(10))), string.Join(';', Extensions)); +// } +// } + +// private enum FilterType +// { +// Audio, +// Image +// } + +// public FileFilter(/*Control.SoxInterop soxInterop*/) +// { +// _soxInterop = soxInterop ?? throw new ArgumentNullException(nameof(soxInterop)); +// _soxInterop.PropertyChanged += (s, e) => +// { +// if (e.PropertyName.Equals(nameof(Control.SoxInterop.HasSox), StringComparison.Ordinal)) +// AudioFilter = GenerateFilter(FilterType.Audio); +// }; +// AudioFileFamilies.Sort((l, r) => string.Compare(l.Name, r.Name, StringComparison.CurrentCultureIgnoreCase)); +// AudioFilter = GenerateFilter(FilterType.Audio); +// ImageFilter = GenerateFilter(FilterType.Image); +// } +// private readonly Control.SoxInterop _soxInterop; + +// public string AudioFilter { get; private set; } +// public string ImageFilter { get; private set; } + +// private string GenerateFilter(FilterType filterType) +// { +// List workingList; + +// switch (filterType) +// { +// case FilterType.Audio: +// workingList = AudioFileFamilies.Select(filterItem => filterItem).ToList(); +// if (!_soxInterop.HasSox) +// workingList.RemoveAll(filterItem => filterItem.Extensions.Equals(MPEG)); +// break; +// case FilterType.Image: +// workingList = ImageFileFamilies.Select(filterItem => filterItem).ToList(); +// break; +// default: +// throw new InvalidOperationException(); +// } + +// var individuals = string.Join('|', workingList.Select(filterItem => filterItem.FilterString)); +// var all = string.Join(';', workingList.Select(filterItem => filterItem.Extensions).SelectMany(extensions => extensions)); +// var allDescriptor = string.Join(" ,", workingList.Select(filterItem => filterItem.Extensions).SelectMany(extensions => extensions).Take(10)); +// var common = string.Join(';', workingList.Where(filterItem => filterItem.Common).Select(commonFilterItem => commonFilterItem.Extensions).SelectMany(extensions => extensions)); +// var commonDescriptor = string.Join(" ,", workingList.Where(filterItem => filterItem.Common).Select(commonFilterItem => commonFilterItem.Extensions).SelectMany(extensions => extensions).Take(10)); + +// return string.Format(CultureInfo.CurrentCulture, "Common formats ({0})|{1}|All supported formats ({2}...)|{3}|All files|*.*|{4}", commonDescriptor, common, allDescriptor, all, individuals); +// } +// } +//} diff --git a/VoIPainter/Model/Phone.cs b/VoIPainter/Model/Phone.cs index 23393f2..04f538f 100644 --- a/VoIPainter/Model/Phone.cs +++ b/VoIPainter/Model/Phone.cs @@ -1,4 +1,5 @@ using SixLabors.ImageSharp; +using System; using System.Collections.Generic; namespace VoIPainter.Model @@ -11,8 +12,8 @@ public static class Phone private static readonly ScreenInfo SI_C_240_320_117_117 = new ScreenInfo(new Size(250, 320), new Size(117, 117)); private static readonly ScreenInfo SI_C_800_480_139_109 = new ScreenInfo(new Size(800, 480), new Size(139, 109)); private static readonly ScreenInfo SI_C_640_480_123_111 = new ScreenInfo(new Size(640, 480), new Size(123, 111)); - private static readonly ScreenInfo SI_C_272_480_139_109 = new ScreenInfo(new Size(272, 480), new Size(139, 109)); - private static readonly ScreenInfo SI_C_320_480_139_109 = new ScreenInfo(new Size(320, 480), new Size(139, 109)); + private static readonly ScreenInfo SI_C_272_480_139_109 = new ScreenInfo(new Size(272, 480), new Size(139, 109)); // KEM + private static readonly ScreenInfo SI_C_320_480_139_109 = new ScreenInfo(new Size(320, 480), new Size(139, 109)); // KEM /// /// Specifies known models and their required image dimensions @@ -36,7 +37,7 @@ public static class Phone { "8865", SI_C_800_480_139_109 }, { "8941", SI_C_640_480_123_111 }, { "8945", SI_C_640_480_123_111 }, - // Not sure that this'll work as-is with KEMs. + // KEMs //{ "8800 BEKEM", SI_C_272_480_139_109 }, //{ "8851 BEKEM", SI_C_320_480_139_109 }, //{ "8861 BEKEM", SI_C_320_480_139_109 }, @@ -45,8 +46,25 @@ public static class Phone { "9971", SI_C_640_480_123_111 } }; + /// + /// Get the length of the ringtone for the specified model + /// + /// Phone model + /// Length in samples + public static int RingLength(string model) + { + if (string.IsNullOrWhiteSpace(model)) + throw new ArgumentNullException(nameof(model)); + if (!System.Text.RegularExpressions.Regex.IsMatch(model, "^\\d{4}$")) + throw new ArgumentException(Strings.ValidationModel, nameof(model)); + return model.StartsWith("78", StringComparison.InvariantCultureIgnoreCase) || model.StartsWith("88", StringComparison.InvariantCultureIgnoreCase) ? 160_800 : 16_080; + } + + /// + /// Represents a screen geometry + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "I don't care.")] - public struct ScreenInfo : System.IEquatable + public struct ScreenInfo : IEquatable { public bool Color { get; } public Size ImageSize { get; } diff --git a/VoIPainter/Model/RequestMode.cs b/VoIPainter/Model/RequestMode.cs new file mode 100644 index 0000000..f288bcf --- /dev/null +++ b/VoIPainter/Model/RequestMode.cs @@ -0,0 +1,11 @@ +namespace VoIPainter.Model +{ + /// + /// The type of request + /// + public enum RequestMode + { + Background, + Ringtone + } +} diff --git a/VoIPainter/NOTICE.md b/VoIPainter/NOTICE.md new file mode 100644 index 0000000..7eba2c8 --- /dev/null +++ b/VoIPainter/NOTICE.md @@ -0,0 +1,167 @@ +# NOTICE +Copyright (c) 2020 Timothy Elmer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## Other Licenses +The licenses reproduced below are for convenience, and are not authoritative. The authoritative versions are those distributed with the original source code, which are linked under the appropriate heading for your convenience. + +### [ImageSharp](https://github.com/SixLabors/ImageSharp/blob/master/LICENSE) + +Copyright (c) Six Labors + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + + + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +#### Apache License +Version 2.0, January 2004 + + +##### TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. + Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + 1. You must give any other recipients of the Work or Derivative Works a copy of this License; and + 2. You must cause any modified files to carry prominent notices stating that You changed the files; and + 3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + 4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +##### APPENDIX: How to apply the Apache License to your work. +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + + Copyright (c) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +### [NAudio](https://github.com/naudio/NAudio/blob/master/license.txt) +#### Microsoft Public License (Ms-PL) + +This license governs use of the accompanying software. If you use the software, you accept this license. If you do not accept the license, do not use the software. + +1. Definitions + + The terms "reproduce," "reproduction," "derivative works," and "distribution" have the same meaning here as under U.S. copyright law. + + A "contribution" is the original software, or any additions or changes to the software. + + A "contributor" is any person that distributes its contribution under this license. + + "Licensed patents" are a contributor's patent claims that read directly on its contribution. + +1. Grant of Rights + 1. Copyright Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free copyright license to reproduce its contribution, prepare derivative works of its contribution, and distribute its contribution or any derivative works that you create. + 1. Patent Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free license under its licensed patents to make, have made, use, sell, offer for sale, import, and/or otherwise dispose of its contribution in the software or derivative works of the contribution in the software. + +1. Conditions and Limitations + 1. No Trademark License- This license does not grant you rights to use any contributors' name, logo, or trademarks. + 2. If you bring a patent claim against any contributor over patents that you claim are infringed by the software, your patent license from such contributor to the software ends automatically. + 3. If you distribute any portion of the software, you must retain all copyright, patent, trademark, and attribution notices that are present in the software. + 4. If you distribute any portion of the software in source code form, you may do so only under this license by including a complete copy of this license with your distribution. If you distribute any portion of the software in compiled or object code form, you may only do so under a license that complies with this license. + 5. The software is licensed "as-is." You bear the risk of using it. The contributors give no express warranties, guarantees or conditions. You may have additional consumer rights under your local laws which this license cannot change. To the extent permitted under your local laws, the contributors exclude the implied warranties of merchantability, fitness for a particular purpose and non-infringement. + +### [Markdig](https://github.com/lunet-io/markdig/blob/master/license.txt) +#### BSD 2-Clause "Simplified" License +Copyright (c) 2018-2019, Alexandre Mutel + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +1. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +### [NeoMarkdigXaml](https://github.com/neolithos/NeoMarkdigXaml/blob/master/LICENSE.md) +#### The MIT License (MIT) +Copyright (c) 2016-2017 Nicolas Musset + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +### [.NET Core](https://github.com/dotnet/core/blob/master/LICENSE.TXT) +#### The MIT License (MIT) +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/VoIPainter/Settings.Designer.cs b/VoIPainter/Settings.Designer.cs index 8810bb0..b324dd1 100644 --- a/VoIPainter/Settings.Designer.cs +++ b/VoIPainter/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace VoIPainter { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.6.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.7.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -85,7 +85,7 @@ public bool UpgradeRequired { [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("False")] + [global::System.Configuration.DefaultSettingValueAttribute("True")] public bool AutoDuckContrast { get { return ((bool)(this["AutoDuckContrast"])); @@ -106,5 +106,17 @@ public float TargetContrast { this["TargetContrast"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("0.5")] + public double FadeOutTime { + get { + return ((double)(this["FadeOutTime"])); + } + set { + this["FadeOutTime"] = value; + } + } } } diff --git a/VoIPainter/Settings.settings b/VoIPainter/Settings.settings index 29c02ff..db9f61d 100644 --- a/VoIPainter/Settings.settings +++ b/VoIPainter/Settings.settings @@ -18,10 +18,13 @@ True - False + True 0.06 + + 0.5 + \ No newline at end of file diff --git a/VoIPainter/Strings.Designer.cs b/VoIPainter/Strings.Designer.cs index b303a9f..fdb239c 100644 --- a/VoIPainter/Strings.Designer.cs +++ b/VoIPainter/Strings.Designer.cs @@ -60,6 +60,15 @@ internal Strings() { } } + /// + /// Looks up a localized string similar to . + /// + internal static string AudioFilter { + get { + return ResourceManager.GetString("AudioFilter", resourceCulture); + } + } + /// /// Looks up a localized string similar to Image contrast automatically lowered. /// @@ -69,6 +78,15 @@ internal static string AutoDuckedContrast { } } + /// + /// Looks up a localized string similar to File not found. + /// + internal static string ErrorNoFile { + get { + return ResourceManager.GetString("ErrorNoFile", resourceCulture); + } + } + /// /// Looks up a localized string similar to Center. /// @@ -171,6 +189,42 @@ internal static string MessageBoxWarnContrastText { } } + /// + /// Looks up a localized string similar to Authentication error. + /// + internal static string PhoneErrorAuthentication { + get { + return ResourceManager.GetString("PhoneErrorAuthentication", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error framing CiscoIPPhoneResponse object. + /// + internal static string PhoneErrorFramingResponse { + get { + return ResourceManager.GetString("PhoneErrorFramingResponse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Internal file error. + /// + internal static string PhoneErrorInternalFile { + get { + return ResourceManager.GetString("PhoneErrorInternalFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error parsing CiscoIPPhoneExecute object. + /// + internal static string PhoneErrorParsingRequest { + get { + return ResourceManager.GetString("PhoneErrorParsingRequest", resourceCulture); + } + } + /// /// Looks up a localized string similar to Operation canceled. /// @@ -216,6 +270,24 @@ internal static string StatusFormattingImageDone { } } + /// + /// Looks up a localized string similar to Formatting ringtone.... + /// + internal static string StatusFormattingTone { + get { + return ResourceManager.GetString("StatusFormattingTone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Done formatting ringtone. + /// + internal static string StatusFormattingToneDone { + get { + return ResourceManager.GetString("StatusFormattingToneDone", resourceCulture); + } + } + /// /// Looks up a localized string similar to Starting image server.... /// @@ -262,7 +334,7 @@ internal static string StatusPhoneRequestInvalid { } /// - /// Looks up a localized string similar to {0} made a request, but the image formatter was not ready. + /// Looks up a localized string similar to {0} made a request, but VoIPainter was not ready. /// internal static string StatusPhoneRequestNotReady { get { @@ -270,6 +342,15 @@ internal static string StatusPhoneRequestNotReady { } } + /// + /// Looks up a localized string similar to {0} requested ringtone. + /// + internal static string StatusPhoneRequestRingtone { + get { + return ResourceManager.GetString("StatusPhoneRequestRingtone", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} requested thumbnail. /// @@ -297,6 +378,15 @@ internal static string StatusReady { } } + /// + /// Looks up a localized string similar to Success. + /// + internal static string StatusSuccess { + get { + return ResourceManager.GetString("StatusSuccess", resourceCulture); + } + } + /// /// Looks up a localized string similar to Checking for updates.... /// @@ -352,11 +442,56 @@ internal static string StatusUpgradeSettings { } /// - /// Looks up a localized string similar to Got response: {0}: {1}. + /// Looks up a localized string similar to Select Image. + /// + internal static string TitleSelectImage { + get { + return ResourceManager.GetString("TitleSelectImage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select Ringtone. + /// + internal static string TitleSelectRingtone { + get { + return ResourceManager.GetString("TitleSelectRingtone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Request failed: {0}. /// - internal static string TGotResponse { + internal static string TRequestFailed { get { - return ResourceManager.GetString("TGotResponse", resourceCulture); + return ResourceManager.GetString("TRequestFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Request failed: {0}: {1}. + /// + internal static string TRequestFailedMessage { + get { + return ResourceManager.GetString("TRequestFailedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The target contrast must be between 0 and 1. + /// + internal static string ValdidationTargetContrast { + get { + return ResourceManager.GetString("ValdidationTargetContrast", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The fade out time must be between 0 and 20 seconds. + /// + internal static string ValidationFadeOutTime { + get { + return ResourceManager.GetString("ValidationFadeOutTime", resourceCulture); } } @@ -379,7 +514,7 @@ internal static string ValidationIp { } /// - /// Looks up a localized string similar to A phone model is required. + /// Looks up a localized string similar to Invalid phone model. /// internal static string ValidationModel { get { @@ -396,6 +531,15 @@ internal static string ValidationPassword { } } + /// + /// Looks up a localized string similar to Invalid resize mode. + /// + internal static string ValidationResizeMode { + get { + return ResourceManager.GetString("ValidationResizeMode", resourceCulture); + } + } + /// /// Looks up a localized string similar to A username is required. /// diff --git a/VoIPainter/Strings.resx b/VoIPainter/Strings.resx index 03983ef..540b6f1 100644 --- a/VoIPainter/Strings.resx +++ b/VoIPainter/Strings.resx @@ -117,9 +117,15 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Image contrast automatically lowered + + File not found + Center @@ -162,6 +168,18 @@ Release Notes: {2} The selected image has a high contrast. It may be difficult to read UI elements. Attempt to lower contrast? + + Authentication error + + + Error framing CiscoIPPhoneResponse object + + + Internal file error + + + Error parsing CiscoIPPhoneExecute object + Operation canceled @@ -177,6 +195,12 @@ Release Notes: {2} Done formatting image + + Formatting ringtone... + + + Done formatting ringtone + Starting image server... @@ -196,7 +220,11 @@ Release Notes: {2} 1: Request - {0} made a request, but the image formatter was not ready + {0} made a request, but VoIPainter was not ready + 0: IP + + + {0} requested ringtone 0: IP @@ -209,6 +237,9 @@ Release Notes: {2} Ready + + Success + Checking for updates... @@ -228,11 +259,27 @@ Release Notes: {2} Upgraded settings to current version - - Got response: {0}: {1} - 0: Status code + + Select Image + + + Select Ringtone + + + Request failed: {0} + 0: Phone error + + + Request failed: {0}: {1} + 0: Phone error 1: Response content + + The target contrast must be between 0 and 1 + + + The fade out time must be between 0 and 20 seconds + An image is required @@ -240,11 +287,14 @@ Release Notes: {2} A valid target IP address is required - A phone model is required + Invalid phone model A password is required + + Invalid resize mode + A username is required diff --git a/VoIPainter/View/AboutWindow.xaml b/VoIPainter/View/AboutWindow.xaml deleted file mode 100644 index 6c9ac36..0000000 --- a/VoIPainter/View/AboutWindow.xaml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - how to request CGI execution on a Cisco VoIP phone specifications for Cisco VoIP phone background image requirements - - ImageSharp - - .NET core - - - - diff --git a/VoIPainter/View/AboutWindow.xaml.cs b/VoIPainter/View/AboutWindow.xaml.cs deleted file mode 100644 index d62e026..0000000 --- a/VoIPainter/View/AboutWindow.xaml.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Windows; - -namespace VoIPainter.View -{ - /// - /// Interaction logic for AboutWindow.xaml - /// - public partial class AboutWindow : Window - { - public AboutWindow() - { - InitializeComponent(); - } - - private void CGIExecute_Click(object sender, RoutedEventArgs e) => UICommon.OpenLink(new Uri("https://usecallmanager.nz/cgi-execute-xml.html")); - - private void BackgroundImages_Click(object sender, RoutedEventArgs e) => UICommon.OpenLink(new Uri("https://usecallmanager.nz/image-list-xml.html")); - - private void ImageSharp_Click(object sender, RoutedEventArgs e) => UICommon.OpenLink(new Uri("https://github.com/SixLabors/ImageSharp")); - - private void NetCore_Click(object sender, RoutedEventArgs e) => UICommon.OpenLink(new Uri("https://github.com/dotnet/core")); - } -} diff --git a/VoIPainter/View/IsStringNotNullOrWhitespaceConverter.cs b/VoIPainter/View/IsStringNotNullOrWhitespaceConverter.cs new file mode 100644 index 0000000..de48fef --- /dev/null +++ b/VoIPainter/View/IsStringNotNullOrWhitespaceConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace VoIPainter.View +{ + /// + /// Return if a string is not null or whitespace + /// + [ValueConversion(typeof(string), typeof(bool))] + public class IsStringNotNullOrWhitespaceConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (targetType != typeof(bool)) + return null; + else if (value is null) + return false; + else if (value is string) + return !string.IsNullOrWhiteSpace(value as string); + else + return !string.IsNullOrWhiteSpace(value.ToString()); + + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } +} diff --git a/VoIPainter/View/MainWindow.xaml b/VoIPainter/View/MainWindow.xaml index 4c4bfe8..16848df 100644 --- a/VoIPainter/View/MainWindow.xaml +++ b/VoIPainter/View/MainWindow.xaml @@ -5,7 +5,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" Title="VoIPainter" - Height="450px" + Height="500px" Width="800px" MinHeight="100px" MinWidth="200px" @@ -26,13 +26,15 @@ VerticalAlignment="Center"/> + VerticalContentAlignment="Center" + Style="{StaticResource textBoxInvalidated}"/> - public partial class MainWindow : Window + public partial class MainWindow : Window, IDisposable { private readonly Control.MainController _mainController; private readonly Control.LogController _logController; - - private readonly OpenFileDialog _openFileDialog = new OpenFileDialog(); - - - public ObservableCollection> LogEntries { get; private set; } = new ObservableCollection>(); - - public string Error { get; private set; } + private readonly OpenFileDialog _imageOpenFileDialog = new OpenFileDialog() + { + CheckFileExists = true, + CheckPathExists = true, + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), + Multiselect = false, + Title = Strings.TitleSelectImage + }; + private readonly OpenFileDialog _ringtoneOpenFileDialog = new OpenFileDialog() + { + CheckFileExists = true, + CheckPathExists = true, + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), + Multiselect = false, + Title = Strings.TitleSelectRingtone + }; + private readonly WaveOutEvent _waveOutEvent = new WaveOutEvent + { + DeviceNumber = -1 + }; public MainWindow(Control.MainController mainController) { @@ -36,16 +49,28 @@ public MainWindow(Control.MainController mainController) PhoneModelComboBox.ItemsSource = Phone.Models; LogListBox.ItemsSource = _logController.LogEntries; ImageHost.DataContext = _mainController.ImageReformatController; + RingtonePathTextBlock.DataContext = _mainController.RingtoneReformatController; + //_ringtoneOpenFileDialog.Filter = _mainController.FileFilter.AudioFilter; + //_imageOpenFileDialog.Filter = _mainController.FileFilter.ImageFilter; _logController.LogEntries.CollectionChanged += (s, e) => Dispatcher.Invoke(() => LogListBox.ScrollIntoView(_mainController.LogController.LogEntries.Last())); + _mainController.RingtoneReformatController.PropertyChanged += (s, e) => + { + if (e.PropertyName.Equals(nameof(Control.RingtoneReformatController.Ringtone), StringComparison.Ordinal)) + _waveOutEvent.Stop(); + }; + _mainController.ImageServerController.Run(); _logController.Log(new Entry(LogSeverity.Info, Strings.StatusReady)); CheckForUpdates(); } - public MessageBoxResult ShowMessageBox(string text, string caption, MessageBoxButton button, MessageBoxImage icon, MessageBoxResult defaultResult, MessageBoxOptions options) => MessageBox.Show(this, text, caption, button, icon, defaultResult, options); + /// + /// Show a message box. This is mostly a pass-thru for non UI elements that need one. + /// + public MessageBoxResult ShowMessageBox(string text, string caption, MessageBoxButton button, MessageBoxImage icon, MessageBoxResult defaultResult, MessageBoxOptions options = MessageBoxOptions.None) => MessageBox.Show(this, text, caption, button, icon, defaultResult, options); private void CheckForUpdates() { @@ -58,10 +83,10 @@ private void CheckForUpdates() Dispatcher.Invoke(() => { - switch (MessageBox.Show(string.Format(CultureInfo.InvariantCulture, Strings.MessageBoxUpdateAvailableText, response.TagName, response.PublishedAt.ToLocalTime(), response.Body), Strings.MessageBoxUpdateAvailableCaption, MessageBoxButton.YesNo, MessageBoxImage.Information, MessageBoxResult.Yes)) + switch (ShowMessageBox(string.Format(CultureInfo.InvariantCulture, Strings.MessageBoxUpdateAvailableText, response.TagName, response.PublishedAt.ToLocalTime(), response.Body), Strings.MessageBoxUpdateAvailableCaption, MessageBoxButton.YesNo, MessageBoxImage.Information, MessageBoxResult.Yes)) { case MessageBoxResult.Yes: - UICommon.OpenLink(response.HtmlUrl); + UICommon.OpenLink(response.HtmlUrl.AbsoluteUri); break; case MessageBoxResult.No: break; @@ -70,14 +95,23 @@ private void CheckForUpdates() }, TaskScheduler.Default); } - private void BrowseButton_Click(object sender, RoutedEventArgs e) => Browse(); + private void BrowseImageButton_Click(object sender, RoutedEventArgs e) => Browse(RequestMode.Background, _imageOpenFileDialog); + private void BrowseToneButton_Click(object sender, RoutedEventArgs e) => Browse(RequestMode.Ringtone, _ringtoneOpenFileDialog); private void ApplyButton_Click(object sender, RoutedEventArgs e) => Apply(); - private void Browse() + private void Browse(RequestMode mode, OpenFileDialog dialog) { - if (_openFileDialog.ShowDialog().GetValueOrDefault()) - _mainController.ImageReformatController.Path = _openFileDialog.FileName; + if (dialog.ShowDialog().GetValueOrDefault()) + switch (mode) + { + case RequestMode.Background: + _mainController.ImageReformatController.Path = dialog.FileName; + break; + case RequestMode.Ringtone: + _mainController.RingtoneReformatController.Path = dialog.FileName; + break; + } } [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "General exceptions caught for user feedback")] @@ -85,8 +119,11 @@ private async void Apply() { try - { - await _mainController.RequestController.Send(passwordBox.Password).ConfigureAwait(false); + { + if (!(_mainController.ImageReformatController.Image is null)) + await _mainController.RequestController.Send(passwordBox.Password, RequestMode.Background).ConfigureAwait(false); + if (!(_mainController.RingtoneReformatController.Ringtone is null)) + await _mainController.RequestController.Send(passwordBox.Password, RequestMode.Ringtone).ConfigureAwait(false); } catch (Exception e) { @@ -101,12 +138,37 @@ private void Window_Closing(object sender, CancelEventArgs e) _mainController.ImageServerController.Stop(); } - private void AboutMenuItem_Click(object sender, RoutedEventArgs e) => new AboutWindow().ShowDialog(); + private void AboutMenuItem_Click(object sender, RoutedEventArgs e) => new MdWindow("ABOUT.md").ShowDialog(); - private void HelpMenuItem_Click(object sender, RoutedEventArgs e) => UICommon.OpenLink(new Uri("https://github.com/tim-elmer/VoIPainter/wiki")); + private void HelpMenuItem_Click(object sender, RoutedEventArgs e) => new MdWindow("README.md").Show(); private void SettingsMenuItem_Click(object sender, RoutedEventArgs e) => new SettingsWindow(_mainController.SettingsController).ShowDialog(); private void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e) => CheckForUpdates(); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Disposed in anonymous callback.")] + private void PlayRingtoneButton_Click(object sender, RoutedEventArgs e) + { + if (!(_waveOutEvent is null)) + _waveOutEvent.Stop(); + if (_mainController.RingtoneReformatController.Ringtone is null) + return; + + _mainController.RingtoneReformatController.Ringtone.Position = 0; + _waveOutEvent.Init(new RawSourceWaveStream(_mainController.RingtoneReformatController.Ringtone, Control.RingtoneReformatController.WAVE_FORMAT)); + _waveOutEvent.Play(); + } + + protected virtual void Dispose(bool disposeManaged) + { + if (!(_waveOutEvent is null)) + _waveOutEvent.Dispose(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } } } diff --git a/VoIPainter/View/MdWindow.xaml b/VoIPainter/View/MdWindow.xaml new file mode 100644 index 0000000..2e35a44 --- /dev/null +++ b/VoIPainter/View/MdWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/VoIPainter/View/MdWindow.xaml.cs b/VoIPainter/View/MdWindow.xaml.cs new file mode 100644 index 0000000..c7b72b8 --- /dev/null +++ b/VoIPainter/View/MdWindow.xaml.cs @@ -0,0 +1,77 @@ +using Markdig; +using Neo.Markdig.Xaml; +using System; +using System.ComponentModel; +using System.Windows; +using System.Linq; + +namespace VoIPainter.View +{ + /// + /// Interaction logic for AboutWindow.xaml + /// + public partial class MdWindow : Window, INotifyPropertyChanged + { + private string _document; + + public event PropertyChangedEventHandler PropertyChanged; + + public string Document + { + get => _document; + set + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentNullException(nameof(Document)); + + _document = value; + + if (!IsLoaded) + Loaded += (s, e) => LoadDocument(); + else + LoadDocument(); + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Document))); + } + } + + public MdWindow(string document) + { + InitializeComponent(); + Document = document; + } + + private void LoadDocument() + { + using var stream = new System.IO.StreamReader(GetType().Assembly.GetManifestResourceStream($"VoIPainter.{Document}")); + FlowDocumentScrollViewer.Document = MarkdownXaml.ToFlowDocument(stream.ReadToEnd(), new MarkdownPipelineBuilder().UseXamlSupportedExtensions().Build()); + } + + private void OpenHyperlink(object sender, System.Windows.Input.ExecutedRoutedEventArgs e) + { + if (e.Parameter.ToString().StartsWith("#", StringComparison.OrdinalIgnoreCase)) + { + var search = e.Parameter.ToString().Remove(0, 1); + + var block = FlowDocumentScrollViewer.Document.Blocks.FirstBlock; + do + { + if (block.ContentStart.Paragraph != null) + { + var inline = block.ContentStart.Paragraph.Inlines.FirstInline; + + if (new System.Windows.Documents.TextRange(inline.ContentStart, inline.ContentEnd).Text.Replace(' ', '-').Contains(search, StringComparison.OrdinalIgnoreCase)) + { + inline.BringIntoView(); + return; + } + } + block = block.NextBlock; + } + while (!(block is null)); + + } + UICommon.OpenLink(e.Parameter.ToString()); + } + } +} diff --git a/VoIPainter/View/SettingsWindow.xaml b/VoIPainter/View/SettingsWindow.xaml index 840a639..4a1f197 100644 --- a/VoIPainter/View/SettingsWindow.xaml +++ b/VoIPainter/View/SettingsWindow.xaml @@ -16,7 +16,7 @@ +