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 }