/ src / Ryujinx / UI / ViewModels / AmiiboWindowViewModel.cs
AmiiboWindowViewModel.cs
  1  using Avalonia;
  2  using Avalonia.Collections;
  3  using Avalonia.Media.Imaging;
  4  using Avalonia.Threading;
  5  using Ryujinx.Ava.Common.Locale;
  6  using Ryujinx.Ava.UI.Helpers;
  7  using Ryujinx.Ava.UI.Windows;
  8  using Ryujinx.Common;
  9  using Ryujinx.Common.Configuration;
 10  using Ryujinx.Common.Logging;
 11  using Ryujinx.Common.Utilities;
 12  using Ryujinx.UI.Common.Models.Amiibo;
 13  using System;
 14  using System.Collections.Generic;
 15  using System.Collections.ObjectModel;
 16  using System.IO;
 17  using System.Linq;
 18  using System.Net.Http;
 19  using System.Text;
 20  using System.Text.Json;
 21  using System.Threading.Tasks;
 22  
 23  namespace Ryujinx.Ava.UI.ViewModels
 24  {
 25      public class AmiiboWindowViewModel : BaseModel, IDisposable
 26      {
 27          private const string DefaultJson = "{ \"amiibo\": [] }";
 28          private const float AmiiboImageSize = 350f;
 29  
 30          private readonly string _amiiboJsonPath;
 31          private readonly byte[] _amiiboLogoBytes;
 32          private readonly HttpClient _httpClient;
 33          private readonly StyleableWindow _owner;
 34  
 35          private Bitmap _amiiboImage;
 36          private List<AmiiboApi> _amiiboList;
 37          private AvaloniaList<AmiiboApi> _amiibos;
 38          private ObservableCollection<string> _amiiboSeries;
 39  
 40          private int _amiiboSelectedIndex;
 41          private int _seriesSelectedIndex;
 42          private bool _enableScanning;
 43          private bool _showAllAmiibo;
 44          private bool _useRandomUuid;
 45          private string _usage;
 46  
 47          private static readonly AmiiboJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
 48  
 49          public AmiiboWindowViewModel(StyleableWindow owner, string lastScannedAmiiboId, string titleId)
 50          {
 51              _owner = owner;
 52  
 53              _httpClient = new HttpClient
 54              {
 55                  Timeout = TimeSpan.FromSeconds(30),
 56              };
 57  
 58              LastScannedAmiiboId = lastScannedAmiiboId;
 59              TitleId = titleId;
 60  
 61              Directory.CreateDirectory(Path.Join(AppDataManager.BaseDirPath, "system", "amiibo"));
 62  
 63              _amiiboJsonPath = Path.Join(AppDataManager.BaseDirPath, "system", "amiibo", "Amiibo.json");
 64              _amiiboList = new List<AmiiboApi>();
 65              _amiiboSeries = new ObservableCollection<string>();
 66              _amiibos = new AvaloniaList<AmiiboApi>();
 67  
 68              _amiiboLogoBytes = EmbeddedResources.Read("Ryujinx.UI.Common/Resources/Logo_Amiibo.png");
 69  
 70              _ = LoadContentAsync();
 71          }
 72  
 73          public AmiiboWindowViewModel() { }
 74  
 75          public string TitleId { get; set; }
 76          public string LastScannedAmiiboId { get; set; }
 77  
 78          public UserResult Response { get; private set; }
 79  
 80          public bool UseRandomUuid
 81          {
 82              get => _useRandomUuid;
 83              set
 84              {
 85                  _useRandomUuid = value;
 86  
 87                  OnPropertyChanged();
 88              }
 89          }
 90  
 91          public bool ShowAllAmiibo
 92          {
 93              get => _showAllAmiibo;
 94              set
 95              {
 96                  _showAllAmiibo = value;
 97  
 98                  ParseAmiiboData();
 99  
100                  OnPropertyChanged();
101              }
102          }
103  
104          public AvaloniaList<AmiiboApi> AmiiboList
105          {
106              get => _amiibos;
107              set
108              {
109                  _amiibos = value;
110  
111                  OnPropertyChanged();
112              }
113          }
114  
115          public ObservableCollection<string> AmiiboSeries
116          {
117              get => _amiiboSeries;
118              set
119              {
120                  _amiiboSeries = value;
121                  OnPropertyChanged();
122              }
123          }
124  
125          public int SeriesSelectedIndex
126          {
127              get => _seriesSelectedIndex;
128              set
129              {
130                  _seriesSelectedIndex = value;
131  
132                  FilterAmiibo();
133  
134                  OnPropertyChanged();
135              }
136          }
137  
138          public int AmiiboSelectedIndex
139          {
140              get => _amiiboSelectedIndex;
141              set
142              {
143                  _amiiboSelectedIndex = value;
144  
145                  EnableScanning = _amiiboSelectedIndex >= 0 && _amiiboSelectedIndex < _amiibos.Count;
146  
147                  SetAmiiboDetails();
148  
149                  OnPropertyChanged();
150              }
151          }
152  
153          public Bitmap AmiiboImage
154          {
155              get => _amiiboImage;
156              set
157              {
158                  _amiiboImage = value;
159  
160                  OnPropertyChanged();
161              }
162          }
163  
164          public string Usage
165          {
166              get => _usage;
167              set
168              {
169                  _usage = value;
170  
171                  OnPropertyChanged();
172              }
173          }
174  
175          public bool EnableScanning
176          {
177              get => _enableScanning;
178              set
179              {
180                  _enableScanning = value;
181  
182                  OnPropertyChanged();
183              }
184          }
185  
186          public void Dispose()
187          {
188              GC.SuppressFinalize(this);
189              _httpClient.Dispose();
190          }
191  
192          private static bool TryGetAmiiboJson(string json, out AmiiboJson amiiboJson)
193          {
194              if (string.IsNullOrEmpty(json))
195              {
196                  amiiboJson = JsonHelper.Deserialize(DefaultJson, _serializerContext.AmiiboJson);
197  
198                  return false;
199              }
200  
201              try
202              {
203                  amiiboJson = JsonHelper.Deserialize(json, _serializerContext.AmiiboJson);
204  
205                  return true;
206              }
207              catch (JsonException exception)
208              {
209                  Logger.Error?.Print(LogClass.Application, $"Unable to deserialize amiibo data: {exception}");
210                  amiiboJson = JsonHelper.Deserialize(DefaultJson, _serializerContext.AmiiboJson);
211  
212                  return false;
213              }
214          }
215  
216          private async Task<AmiiboJson> GetMostRecentAmiiboListOrDefaultJson()
217          {
218              bool localIsValid = false;
219              bool remoteIsValid = false;
220              AmiiboJson amiiboJson = new();
221  
222              try
223              {
224                  try
225                  {
226                      if (File.Exists(_amiiboJsonPath))
227                      {
228                          localIsValid = TryGetAmiiboJson(await File.ReadAllTextAsync(_amiiboJsonPath), out amiiboJson);
229                      }
230                  }
231                  catch (Exception exception)
232                  {
233                      Logger.Warning?.Print(LogClass.Application, $"Unable to read data from '{_amiiboJsonPath}': {exception}");
234                  }
235  
236                  if (!localIsValid || await NeedsUpdate(amiiboJson.LastUpdated))
237                  {
238                      remoteIsValid = TryGetAmiiboJson(await DownloadAmiiboJson(), out amiiboJson);
239                  }
240              }
241              catch (Exception exception)
242              {
243                  if (!(localIsValid || remoteIsValid))
244                  {
245                      Logger.Error?.Print(LogClass.Application, $"Couldn't get valid amiibo data: {exception}");
246  
247                      // Neither local or remote files are valid JSON, close window.
248                      ShowInfoDialog();
249                      Close();
250                  }
251                  else if (!remoteIsValid)
252                  {
253                      Logger.Warning?.Print(LogClass.Application, $"Couldn't update amiibo data: {exception}");
254  
255                      // Only the local file is valid, the local one should be used
256                      // but the user should be warned.
257                      ShowInfoDialog();
258                  }
259              }
260  
261              return amiiboJson;
262          }
263  
264          private async Task LoadContentAsync()
265          {
266              AmiiboJson amiiboJson = await GetMostRecentAmiiboListOrDefaultJson();
267  
268              _amiiboList = amiiboJson.Amiibo.OrderBy(amiibo => amiibo.AmiiboSeries).ToList();
269  
270              ParseAmiiboData();
271          }
272  
273          private void ParseAmiiboData()
274          {
275              _amiiboSeries.Clear();
276              _amiibos.Clear();
277  
278              for (int i = 0; i < _amiiboList.Count; i++)
279              {
280                  if (!_amiiboSeries.Contains(_amiiboList[i].AmiiboSeries))
281                  {
282                      if (!ShowAllAmiibo)
283                      {
284                          foreach (AmiiboApiGamesSwitch game in _amiiboList[i].GamesSwitch)
285                          {
286                              if (game != null)
287                              {
288                                  if (game.GameId.Contains(TitleId))
289                                  {
290                                      AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
291  
292                                      break;
293                                  }
294                              }
295                          }
296                      }
297                      else
298                      {
299                          AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
300                      }
301                  }
302              }
303  
304              if (LastScannedAmiiboId != "")
305              {
306                  SelectLastScannedAmiibo();
307              }
308              else
309              {
310                  SeriesSelectedIndex = 0;
311              }
312          }
313  
314          private void SelectLastScannedAmiibo()
315          {
316              AmiiboApi scanned = _amiiboList.Find(amiibo => amiibo.GetId() == LastScannedAmiiboId);
317  
318              SeriesSelectedIndex = AmiiboSeries.IndexOf(scanned.AmiiboSeries);
319              AmiiboSelectedIndex = AmiiboList.IndexOf(scanned);
320          }
321  
322          private void FilterAmiibo()
323          {
324              _amiibos.Clear();
325  
326              if (_seriesSelectedIndex < 0)
327              {
328                  return;
329              }
330  
331              List<AmiiboApi> amiiboSortedList = _amiiboList
332                  .Where(amiibo => amiibo.AmiiboSeries == _amiiboSeries[SeriesSelectedIndex])
333                  .OrderBy(amiibo => amiibo.Name).ToList();
334  
335              for (int i = 0; i < amiiboSortedList.Count; i++)
336              {
337                  if (!_amiibos.Contains(amiiboSortedList[i]))
338                  {
339                      if (!_showAllAmiibo)
340                      {
341                          foreach (AmiiboApiGamesSwitch game in amiiboSortedList[i].GamesSwitch)
342                          {
343                              if (game != null)
344                              {
345                                  if (game.GameId.Contains(TitleId))
346                                  {
347                                      _amiibos.Add(amiiboSortedList[i]);
348  
349                                      break;
350                                  }
351                              }
352                          }
353                      }
354                      else
355                      {
356                          _amiibos.Add(amiiboSortedList[i]);
357                      }
358                  }
359              }
360  
361              AmiiboSelectedIndex = 0;
362          }
363  
364          private void SetAmiiboDetails()
365          {
366              ResetAmiiboPreview();
367  
368              Usage = string.Empty;
369  
370              if (_amiiboSelectedIndex < 0)
371              {
372                  return;
373              }
374  
375              AmiiboApi selected = _amiibos[_amiiboSelectedIndex];
376  
377              string imageUrl = _amiiboList.Find(amiibo => amiibo.Equals(selected)).Image;
378  
379              StringBuilder usageStringBuilder = new();
380  
381              for (int i = 0; i < _amiiboList.Count; i++)
382              {
383                  if (_amiiboList[i].Equals(selected))
384                  {
385                      bool writable = false;
386  
387                      foreach (AmiiboApiGamesSwitch item in _amiiboList[i].GamesSwitch)
388                      {
389                          if (item.GameId.Contains(TitleId))
390                          {
391                              foreach (AmiiboApiUsage usageItem in item.AmiiboUsage)
392                              {
393                                  usageStringBuilder.Append($"{Environment.NewLine}- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}");
394  
395                                  writable = usageItem.Write;
396                              }
397                          }
398                      }
399  
400                      if (usageStringBuilder.Length == 0)
401                      {
402                          usageStringBuilder.Append($"{LocaleManager.Instance[LocaleKeys.Unknown]}.");
403                      }
404  
405                      Usage = $"{LocaleManager.Instance[LocaleKeys.Usage]} {(writable ? $" ({LocaleManager.Instance[LocaleKeys.Writable]})" : "")} : {usageStringBuilder}";
406                  }
407              }
408  
409              _ = UpdateAmiiboPreview(imageUrl);
410          }
411  
412          private async Task<bool> NeedsUpdate(DateTime oldLastModified)
413          {
414              try
415              {
416                  HttpResponseMessage response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "https://amiibo.ryujinx.org/"));
417  
418                  if (response.IsSuccessStatusCode)
419                  {
420                      return response.Content.Headers.LastModified != oldLastModified;
421                  }
422              }
423              catch (HttpRequestException exception)
424              {
425                  Logger.Error?.Print(LogClass.Application, $"Unable to check for amiibo data updates: {exception}");
426              }
427  
428              return false;
429          }
430  
431          private async Task<string> DownloadAmiiboJson()
432          {
433              try
434              {
435                  HttpResponseMessage response = await _httpClient.GetAsync("https://amiibo.ryujinx.org/");
436  
437                  if (response.IsSuccessStatusCode)
438                  {
439                      string amiiboJsonString = await response.Content.ReadAsStringAsync();
440  
441                      try
442                      {
443                          using FileStream dlcJsonStream = File.Create(_amiiboJsonPath, 4096, FileOptions.WriteThrough);
444                          dlcJsonStream.Write(Encoding.UTF8.GetBytes(amiiboJsonString));
445                      }
446                      catch (Exception exception)
447                      {
448                          Logger.Warning?.Print(LogClass.Application, $"Couldn't write amiibo data to file '{_amiiboJsonPath}: {exception}'");
449                      }
450  
451                      return amiiboJsonString;
452                  }
453  
454                  Logger.Error?.Print(LogClass.Application, $"Failed to download amiibo data. Response status code: {response.StatusCode}");
455              }
456              catch (HttpRequestException exception)
457              {
458                  Logger.Error?.Print(LogClass.Application, $"Failed to request amiibo data: {exception}");
459              }
460  
461              await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
462                  LocaleManager.Instance[LocaleKeys.DialogAmiiboApiFailFetchMessage],
463                  LocaleManager.Instance[LocaleKeys.InputDialogOk],
464                  "",
465                  LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
466  
467              return null;
468          }
469  
470          private void Close()
471          {
472              Dispatcher.UIThread.Post(_owner.Close);
473          }
474  
475          private async Task UpdateAmiiboPreview(string imageUrl)
476          {
477              HttpResponseMessage response = await _httpClient.GetAsync(imageUrl);
478  
479              if (response.IsSuccessStatusCode)
480              {
481                  byte[] amiiboPreviewBytes = await response.Content.ReadAsByteArrayAsync();
482                  using MemoryStream memoryStream = new(amiiboPreviewBytes);
483  
484                  Bitmap bitmap = new(memoryStream);
485  
486                  double ratio = Math.Min(AmiiboImageSize / bitmap.Size.Width,
487                          AmiiboImageSize / bitmap.Size.Height);
488  
489                  int resizeHeight = (int)(bitmap.Size.Height * ratio);
490                  int resizeWidth = (int)(bitmap.Size.Width * ratio);
491  
492                  AmiiboImage = bitmap.CreateScaledBitmap(new PixelSize(resizeWidth, resizeHeight));
493              }
494              else
495              {
496                  Logger.Error?.Print(LogClass.Application, $"Failed to get amiibo preview. Response status code: {response.StatusCode}");
497              }
498          }
499  
500          private void ResetAmiiboPreview()
501          {
502              using MemoryStream memoryStream = new(_amiiboLogoBytes);
503  
504              Bitmap bitmap = new(memoryStream);
505  
506              AmiiboImage = bitmap;
507          }
508  
509          private static async void ShowInfoDialog()
510          {
511              await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
512                  LocaleManager.Instance[LocaleKeys.DialogAmiiboApiConnectErrorMessage],
513                  LocaleManager.Instance[LocaleKeys.InputDialogOk],
514                  "",
515                  LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
516          }
517      }
518  }