1
1
using System ;
2
2
using System . Diagnostics ;
3
+ using System . IO ;
4
+ using System . Net . Http ;
3
5
using System . Net . WebSockets ;
4
6
using System . Reactive ;
5
7
using System . Threading ;
8
+ using System . Threading . Tasks ;
9
+
10
+ using Avalonia . Media . Imaging ;
6
11
7
12
using log4net ;
8
13
13
18
14
19
using ReactiveUI ;
15
20
21
+ using TwitchLib . Api . Helix . Models . Users . GetUsers ;
22
+
16
23
using TwitchStreamingTools . Services ;
24
+ using TwitchStreamingTools . Utilities ;
17
25
18
26
namespace TwitchStreamingTools . ViewModels . Pages ;
19
27
20
28
/// <summary>
21
29
/// Handles binding your account to the application.
22
30
/// </summary>
23
31
public class AccountViewModel : PageViewModelBase , IDisposable {
32
+ /// <summary>
33
+ /// The path to the folder containing cached profile images.
34
+ /// </summary>
35
+ private static readonly string PROFILE_IMAGE_FOLDER = Path . Combine ( Constants . SAVE_FOLDER ,
36
+ "twitch-profile-image-cache" ) ;
37
+
38
+ /// <summary>
39
+ /// The template for a profile image filename.
40
+ /// </summary>
41
+ private static readonly string PROFILE_IMAGE_FILENAME = "twitch_profile_{0}.png" ;
42
+
43
+ /// <summary>
44
+ /// The configuration.
45
+ /// </summary>
46
+ private readonly IConfiguration _configuration ;
47
+
24
48
/// <summary>
25
49
/// The logger.
26
50
/// </summary>
@@ -36,6 +60,11 @@ public class AccountViewModel : PageViewModelBase, IDisposable {
36
60
/// </summary>
37
61
private bool _hasValidOAuthToken ;
38
62
63
+ /// <summary>
64
+ /// The profile image of the logged in user.
65
+ /// </summary>
66
+ private Bitmap ? _profileImage ;
67
+
39
68
/// <summary>
40
69
/// The authenticated user's twitch username.
41
70
/// </summary>
@@ -45,24 +74,35 @@ public class AccountViewModel : PageViewModelBase, IDisposable {
45
74
/// Initializes a new instance of the <see cref="AccountViewModel" /> class.
46
75
/// </summary>
47
76
/// <param name="twitchAccountService">Manages the account OAuth information.</param>
48
- public AccountViewModel ( ITwitchAccountService twitchAccountService ) {
77
+ /// <param name="configuration">The configuration.</param>
78
+ public AccountViewModel ( ITwitchAccountService twitchAccountService , IConfiguration configuration ) {
49
79
_twitchAccountService = twitchAccountService ;
50
80
_twitchAccountService . OnCredentialsStatusChanged += OnCredentialsStatusChanged ;
51
- OnLaunchBrowser = ReactiveCommand . Create ( LaunchBrowser ) ;
81
+ _twitchAccountService . OnCredentialsChanged += OnCredentialsChanged ;
82
+ _configuration = configuration ;
83
+ OnPerformLogin = ReactiveCommand . Create ( PerformLogin ) ;
52
84
OnLogout = ReactiveCommand . Create ( ClearCredentials ) ;
53
85
54
86
// Set the initial state of the ui
55
87
HasValidOAuthToken = _twitchAccountService . CredentialsAreValid ;
56
88
TwitchUsername = _twitchAccountService . TwitchUsername ;
57
89
}
58
90
91
+ /// <summary>
92
+ /// The profile image of the logged in user.
93
+ /// </summary>
94
+ public Bitmap ? ProfileImage {
95
+ get => _profileImage ;
96
+ set => this . RaiseAndSetIfChanged ( ref _profileImage , value ) ;
97
+ }
98
+
59
99
/// <inheritdoc />
60
100
public override string IconResourceKey { get ; } = "InprivateAccountRegular" ;
61
101
62
102
/// <summary>
63
- /// Called when toggling the menu open and close .
103
+ /// Called when the user clicks the login button .
64
104
/// </summary>
65
- public ReactiveCommand < Unit , Unit > OnLaunchBrowser { get ; }
105
+ public ReactiveCommand < Unit , Unit > OnPerformLogin { get ; }
66
106
67
107
/// <summary>
68
108
/// Called when logging out the current user.
@@ -92,35 +132,91 @@ public string? TwitchUsername {
92
132
93
133
/// <inheritdoc />
94
134
public void Dispose ( ) {
95
- OnLaunchBrowser . Dispose ( ) ;
135
+ OnPerformLogin . Dispose ( ) ;
96
136
OnLogout . Dispose ( ) ;
97
137
}
98
138
139
+ /// <summary>
140
+ /// Loads the profile image when the UI loads.
141
+ /// </summary>
142
+ public override async void OnLoaded ( ) {
143
+ base . OnLoaded ( ) ;
144
+
145
+ try {
146
+ await LoadProfileImage ( ) ;
147
+ }
148
+ catch ( Exception ex ) {
149
+ _logger . Error ( "Failed to load profile image" , ex ) ;
150
+ }
151
+ }
152
+
153
+ /// <summary>
154
+ /// Finds the profile image locally or downloads it.
155
+ /// </summary>
156
+ private async Task LoadProfileImage ( ) {
157
+ // Try to get the file locally.
158
+ string ? profileImagePath = string . Format ( PROFILE_IMAGE_FILENAME , _configuration . TwitchUsername ) ;
159
+ if ( File . Exists ( profileImagePath ) ) {
160
+ ProfileImage = new Bitmap ( profileImagePath ) ;
161
+ return ;
162
+ }
163
+
164
+ // If we couldn't find the file, download it.
165
+ profileImagePath = await DownloadUserImage ( ) ;
166
+ if ( null == profileImagePath ) {
167
+ return ;
168
+ }
169
+
170
+ ProfileImage = new Bitmap ( profileImagePath ) ;
171
+ }
172
+
173
+ /// <summary>
174
+ /// Called when the credentials are changed to load the new profile image.
175
+ /// </summary>
176
+ /// <param name="token"></param>
177
+ private async void OnCredentialsChanged ( TwitchAccessToken ? token ) {
178
+ try {
179
+ if ( string . IsNullOrWhiteSpace ( token ? . AccessToken ) ) {
180
+ return ;
181
+ }
182
+
183
+ await LoadProfileImage ( ) ;
184
+ }
185
+ catch ( Exception ex ) {
186
+ _logger . Error ( "Failed to download user profile image" , ex ) ;
187
+ }
188
+ }
189
+
99
190
/// <summary>
100
191
/// Invoked when the status of the credentials changes from the <seealso cref="ITwitchAccountService" />.
101
192
/// </summary>
102
193
/// <param name="valid">True if the credentials are valid, false otherwise.</param>
103
194
private void OnCredentialsStatusChanged ( bool valid ) {
104
- if ( ! valid ) {
105
- HasValidOAuthToken = false ;
106
- TwitchUsername = null ;
107
- return ;
108
- }
195
+ try {
196
+ if ( ! valid ) {
197
+ HasValidOAuthToken = false ;
198
+ TwitchUsername = null ;
199
+ return ;
200
+ }
109
201
110
- HasValidOAuthToken = true ;
111
- TwitchUsername = _twitchAccountService . TwitchUsername ;
202
+ HasValidOAuthToken = true ;
203
+ TwitchUsername = _twitchAccountService . TwitchUsername ;
204
+ }
205
+ catch ( Exception ex ) {
206
+ _logger . Error ( "Failed to update credentials status" , ex ) ;
207
+ }
112
208
}
113
209
114
210
/// <summary>
115
211
/// Launches the computer's default browser to generate an OAuth token.
116
212
/// </summary>
117
- private async void LaunchBrowser ( ) {
213
+ private async void PerformLogin ( ) {
118
214
try {
119
- var token = CancellationToken . None ;
120
-
215
+ CancellationToken token = CancellationToken . None ;
216
+
121
217
// Create an identifier for this credential request.
122
218
var guid = Guid . NewGuid ( ) ;
123
-
219
+
124
220
// Create a web socket connection to the api which will provide us with the credentials from twitch.
125
221
ClientWebSocket webSocket = new ( ) ;
126
222
await webSocket . ConnectAsync ( new Uri ( $ "ws://{ Constants . DOMAIN } /api/v1/user/twitch-login/twitch-streaming-tools/ws") , token ) ;
@@ -138,10 +234,10 @@ private async void LaunchBrowser() {
138
234
// Wait for the user to finish giving us permission on the website. Once they provide us access we will receive
139
235
// a response on the web socket containing a JSON with our OAuth information.
140
236
string json = await webSocket . ReceiveTextAsync ( token ) ;
141
-
237
+
142
238
// Close the connection, both sides will be waiting to do this so we do it immediately.
143
239
await webSocket . CloseAsync ( WebSocketCloseStatus . NormalClosure , "Completed Successfully!" , token ) ;
144
-
240
+
145
241
// Update the oauth token in the twitch account service.
146
242
var oauthResp = JsonConvert . DeserializeObject < TwitchAccessToken > ( json ) ;
147
243
if ( null == oauthResp || null == oauthResp . AccessToken || null == oauthResp . RefreshToken || null == oauthResp . ExpiresUtc ) {
@@ -162,4 +258,53 @@ private async void LaunchBrowser() {
162
258
private void ClearCredentials ( ) {
163
259
_twitchAccountService . DeleteCredentials ( ) ;
164
260
}
261
+
262
+ /// <summary>
263
+ /// Downloads the user's profile image and adds it to the cache.
264
+ /// </summary>
265
+ /// <returns>The path to the saved file.</returns>
266
+ private async Task < string ? > DownloadUserImage ( ) {
267
+ // The user object from the API will tell us the download link on twitch for the image.
268
+ var api = new TwitchApiWrapper ( ) ;
269
+ if ( string . IsNullOrWhiteSpace ( api . OAuth ? . AccessToken ) ) {
270
+ return null ;
271
+ }
272
+
273
+ User ? user = await api . GetUser ( ) ;
274
+ if ( string . IsNullOrWhiteSpace ( user ? . ProfileImageUrl ) ) {
275
+ return null ;
276
+ }
277
+
278
+ // Download the image via http.
279
+ using var http = new HttpClient ( ) ;
280
+ byte [ ] imageBytes = await http . GetByteArrayAsync ( user . ProfileImageUrl ) ;
281
+
282
+ // If the directory doesn't exist, create it.
283
+ if ( ! Directory . Exists ( PROFILE_IMAGE_FOLDER ) ) {
284
+ Directory . CreateDirectory ( PROFILE_IMAGE_FOLDER ) ;
285
+ }
286
+
287
+ // I don't think twitch usernames can have non-filepath friendly characters but might as well sanitize it anyway.
288
+ string filename = SanitizeFilename ( string . Format ( PROFILE_IMAGE_FILENAME , user . Login ) ) ;
289
+ string imagePath = Path . Combine ( PROFILE_IMAGE_FOLDER , filename ) ;
290
+
291
+ // Save to disk
292
+ await File . WriteAllBytesAsync ( imagePath , imageBytes ) ;
293
+
294
+ // Return path to file, even though everyone already knows it.
295
+ return imagePath ;
296
+ }
297
+
298
+ /// <summary>
299
+ /// Removes invalid characters from the passed in string.
300
+ /// </summary>
301
+ /// <param name="input">The filename to sanitize.</param>
302
+ /// <returns>The sanitized filename.</returns>
303
+ private static string SanitizeFilename ( string input ) {
304
+ foreach ( char c in Path . GetInvalidFileNameChars ( ) ) {
305
+ input = input . Replace ( c , '_' ) ;
306
+ }
307
+
308
+ return input ;
309
+ }
165
310
}
0 commit comments