Fixed frequent timeout by always closing WebResponse objects
[pithos-ms-client] / trunk / Pithos.Client.WPF / Shell / ShellViewModel.cs
1 using System.Collections.Concurrent;
2 using System.ComponentModel;
3 using System.ComponentModel.Composition;
4 using System.Diagnostics;
5 using System.Diagnostics.Contracts;
6 using System.IO;
7 using System.Net;
8 using System.Reflection;
9 using System.Runtime.InteropServices;
10 using System.ServiceModel;
11 using System.ServiceModel.Description;
12 using System.Threading.Tasks;
13 using System.Windows;
14 using Caliburn.Micro;
15 using Hardcodet.Wpf.TaskbarNotification;
16 using Pithos.Client.WPF.Configuration;
17 using Pithos.Client.WPF.FileProperties;
18 using Pithos.Client.WPF.Properties;
19 using Pithos.Client.WPF.SelectiveSynch;
20 using Pithos.Client.WPF.Services;
21 using Pithos.Client.WPF.Shell;
22 using Pithos.Core;
23 using Pithos.Interfaces;
24 using System;
25 using System.Collections.Generic;
26 using System.Linq;
27 using System.Text;
28 using Pithos.Network;
29 using StatusService = Pithos.Client.WPF.Services.StatusService;
30
31 namespace Pithos.Client.WPF {
32     using System.ComponentModel.Composition;
33
34     
35         ///<summary>
36         /// The "shell" of the Pithos application displays the taskbar  icon, menu and notifications.
37         /// The shell also hosts the status service called by shell extensions to retrieve file info
38         ///</summary>
39         ///<remarks>
40         /// It is a strange "shell" as its main visible element is an icon instead of a window
41         /// The shell subscribes to the following events:
42         /// * Notification:  Raised by components that want to notify the user. Usually displayed in a balloon
43         /// * SelectiveSynchChanges: Notifies that the user made changes to the selective synch folders for an account. Raised by the Selective Synch dialog. Located here because the monitors are here
44         /// * ShowFilePropertiesEvent: Raised when a shell command requests the display of the file/container properties dialog
45         ///</remarks>           
46         //TODO: CODE SMELL Why does the shell handle the SelectiveSynchChanges?
47     [Export(typeof(IShell))]
48     public class ShellViewModel : Screen, IStatusNotification, IShell,
49         IHandle<Notification>, IHandle<SelectiveSynchChanges>, IHandle<ShowFilePropertiesEvent>
50     {
51                 //The Status Checker provides the current synch state
52                 //TODO: Could we remove the status checker and use events in its place?
53         private IStatusChecker _statusChecker;
54         private IEventAggregator _events;
55
56         public PithosSettings Settings { get; private set; }        
57
58                 
59         private Dictionary<string, PithosMonitor> _monitors = new Dictionary<string, PithosMonitor>();
60                 ///<summary>
61                 /// Dictionary of account monitors, keyed by account
62                 ///</summary>
63                 ///<remarks>
64                 /// One monitor class is created for each account. The Shell needs access to the monitors to execute start/stop/pause commands,
65                 /// retrieve account and boject info            
66                 ///</remarks>
67                 // TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design?
68                 // TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier
69         public Dictionary<string, PithosMonitor> Monitors
70         {
71             get { return _monitors; }
72         }
73
74
75                 ///<summary>
76                 /// The status service is used by Shell extensions to retrieve file status information
77                 ///</summary>
78                 //TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
79         private ServiceHost _statusService { get; set; }
80
81                 //Logging in the Pithos client is provided by log4net
82         private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos");
83
84                 ///<summary>
85                 /// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings
86                 ///</summary>
87                 ///<remarks>
88                 /// The PithosSettings class encapsulates the app's settings to abstract their storage mechanism (App settings, a database or registry)
89                 ///</remarks>
90         [ImportingConstructor]          
91         public ShellViewModel(IWindowManager windowManager, IEventAggregator events, IStatusChecker statusChecker, PithosSettings settings)
92         {
93             try
94             {
95
96                 _windowManager = windowManager;
97                                 //CHECK: Caliburn doesn't need explicit command construction
98                 //OpenPithosFolderCommand = new PithosCommand(OpenPithosFolder);
99                 _statusChecker = statusChecker;
100                                 //The event subst
101                 _events = events;
102                 _events.Subscribe(this);
103
104                 Settings = settings;
105
106                 StatusMessage = "In Synch";
107
108                 _accounts.CollectionChanged += (sender, e) =>
109                                                    {
110                                                        NotifyOfPropertyChange(() => OpenFolderCaption);
111                                                        NotifyOfPropertyChange(() => HasAccounts);
112                                                    };
113
114             }
115             catch (Exception exc)
116             {
117                 Log.Error("Error while starting the ShellViewModel",exc);
118                 throw;
119             }
120         }
121
122
123         protected override void OnActivate()
124         {
125             base.OnActivate();
126
127             StartMonitoring();                    
128         }
129
130
131         private async Task StartMonitoring()
132         {
133             try
134             {                  
135                 foreach (var account in Settings.Accounts)
136                 {
137                     await MonitorAccount(account);
138                 }
139                 _statusService = StatusService.Start();
140             }
141             catch (AggregateException exc)
142             {
143                 exc.Handle(e =>
144                 {
145                     Log.Error("Error while starting monitoring", e);
146                     return true;
147                 });
148                 throw;
149             }
150         }
151
152         protected override void OnDeactivate(bool close)
153         {
154             base.OnDeactivate(close);
155             if (close)
156             {
157                 StatusService.Stop(_statusService);
158                 _statusService = null;
159             }
160         }
161
162         public Task MonitorAccount(AccountSettings account)
163         {
164             return Task.Factory.StartNew(() =>
165             {                                                
166                 PithosMonitor monitor = null;
167                 var accountName = account.AccountName;
168
169                 if (_monitors.TryGetValue(accountName, out monitor))
170                 {
171                     //If the account is active
172                     if (account.IsActive)
173                         //Start the monitor. It's OK to start an already started monitor,
174                         //it will just ignore the call                        
175                         StartMonitor(monitor).Wait();                        
176                     else
177                     {
178                         //If the account is inactive
179                         //Stop and remove the monitor
180                         RemoveMonitor(accountName);
181                     }
182                     return;
183                 }
184
185                 //Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors
186                 monitor = new PithosMonitor
187                               {
188                                   UserName = accountName,
189                                   ApiKey = account.ApiKey,                                  
190                                   StatusNotification = this,
191                                   RootPath = account.RootPath
192                               };
193                 //PithosMonitor uses MEF so we need to resolve it
194                 IoC.BuildUp(monitor);
195
196                 var appSettings = Properties.Settings.Default;
197                 monitor.AuthenticationUrl = account.ServerUrl;
198
199                 _monitors[accountName] = monitor;
200
201                 if (account.IsActive)
202                 {
203                     //Don't start a monitor if it doesn't have an account and ApiKey
204                     if (String.IsNullOrWhiteSpace(monitor.UserName) ||
205                         String.IsNullOrWhiteSpace(monitor.ApiKey))
206                         return;
207                     StartMonitor(monitor);
208                 }
209             });
210         }
211
212
213         protected override void OnViewLoaded(object view)
214         {
215             UpdateStatus();
216             var window = (Window)view;            
217             TaskEx.Delay(1000).ContinueWith(t => Execute.OnUIThread(window.Hide));
218             base.OnViewLoaded(view);
219         }
220
221
222         #region Status Properties
223
224         private string _statusMessage;
225         public string StatusMessage
226         {
227             get { return _statusMessage; }
228             set
229             {
230                 _statusMessage = value;
231                 NotifyOfPropertyChange(() => StatusMessage);
232             }
233         }
234
235         private readonly ObservableConcurrentCollection<AccountInfo> _accounts = new ObservableConcurrentCollection<AccountInfo>();
236         public ObservableConcurrentCollection<AccountInfo> Accounts
237         {
238             get { return _accounts; }
239         }
240
241             public bool HasAccounts
242             {
243             get { return _accounts.Count > 0; }
244             }
245
246
247         public string OpenFolderCaption
248         {
249             get
250             {
251                 return (_accounts.Count == 0)
252                         ? "No Accounts Defined"
253                         : "Open Pithos Folder";
254             }
255         }
256
257         private string _pauseSyncCaption="Pause Synching";
258         public string PauseSyncCaption
259         {
260             get { return _pauseSyncCaption; }
261             set
262             {
263                 _pauseSyncCaption = value;
264                 NotifyOfPropertyChange(() => PauseSyncCaption);
265             }
266         }
267
268         private readonly ObservableConcurrentCollection<FileEntry> _recentFiles = new ObservableConcurrentCollection<FileEntry>();
269         public ObservableConcurrentCollection<FileEntry> RecentFiles
270         {
271             get { return _recentFiles; }
272         }
273
274
275         private string _statusIcon="../Images/Pithos.ico";
276         public string StatusIcon
277         {
278             get { return _statusIcon; }
279             set
280             {
281                 //_statusIcon = value;
282                 NotifyOfPropertyChange(() => StatusIcon);
283             }
284         }
285
286         #endregion
287
288         #region Commands
289
290         public void ShowPreferences()
291         {
292             Settings.Reload();
293             var preferences = new PreferencesViewModel(_windowManager,_events, this,Settings);            
294             _windowManager.ShowDialog(preferences);
295             
296         }
297
298         public void AboutPithos()
299         {
300             var about = new AboutViewModel();
301             _windowManager.ShowWindow(about);
302         }
303
304         public void SendFeedback()
305         {
306             var feedBack =  IoC.Get<FeedbackViewModel>();
307             _windowManager.ShowWindow(feedBack);
308         }
309
310         //public PithosCommand OpenPithosFolderCommand { get; private set; }
311
312         public void OpenPithosFolder()
313         {
314             var account = Settings.Accounts.FirstOrDefault(acc => acc.IsActive);
315             if (account == null)
316                 return;
317             Process.Start(account.RootPath);
318         }
319
320         public void OpenPithosFolder(AccountInfo account)
321         {
322             Process.Start(account.AccountPath);
323         }
324
325         
326 /*
327         public void GoToSite()
328         {            
329             var site = Properties.Settings.Default.PithosSite;
330             Process.Start(site);            
331         }
332 */
333
334         public void GoToSite(AccountInfo account)
335         {
336             /*var site = String.Format("{0}/ui/?token={1}&user={2}",
337                 account.SiteUri,account.Token,
338                 account.UserName);*/
339             Process.Start(account.SiteUri);
340         }
341
342         public void ShowFileProperties()
343         {
344             var account = Settings.Accounts.First(acc => acc.IsActive);            
345             var dir = new DirectoryInfo(account.RootPath + @"\pithos");
346             var files=dir.GetFiles();
347             var r=new Random();
348             var idx=r.Next(0, files.Length);
349             ShowFileProperties(files[idx].FullName);            
350         }
351
352         public void ShowFileProperties(string filePath)
353         {
354             if (String.IsNullOrWhiteSpace(filePath))
355                 throw new ArgumentNullException("filePath");
356             if (!File.Exists(filePath))
357                 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
358             Contract.EndContractBlock();
359
360             var pair=(from monitor in  Monitors
361                                where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
362                                    select monitor).FirstOrDefault();
363             var account = pair.Key;
364             var accountMonitor = pair.Value;
365
366             if (accountMonitor == null)
367                 return;
368
369             var infoTask=Task.Factory.StartNew(()=>accountMonitor.GetObjectInfo(filePath));
370
371             
372
373             var fileProperties = new FilePropertiesViewModel(this, infoTask,filePath);
374             _windowManager.ShowWindow(fileProperties);
375         } 
376         
377         public void ShowContainerProperties()
378         {
379             var account = Settings.Accounts.First(acc => acc.IsActive);            
380             var dir = new DirectoryInfo(account.RootPath);
381             var fullName = (from folder in dir.EnumerateDirectories()
382                             where (folder.Attributes & FileAttributes.Hidden) == 0
383                             select folder.FullName).First();
384             ShowContainerProperties(fullName);            
385         }
386
387         public void ShowContainerProperties(string filePath)
388         {
389             if (String.IsNullOrWhiteSpace(filePath))
390                 throw new ArgumentNullException("filePath");
391             if (!Directory.Exists(filePath))
392                 throw new ArgumentException(String.Format("Non existent file {0}",filePath),"filePath");
393             Contract.EndContractBlock();
394
395             var pair=(from monitor in  Monitors
396                                where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase)
397                                    select monitor).FirstOrDefault();
398             var account = pair.Key;
399             var accountMonitor = pair.Value;            
400             var info = accountMonitor.GetContainerInfo(filePath);
401
402             
403
404             var containerProperties = new ContainerPropertiesViewModel(this, info,filePath);
405             _windowManager.ShowWindow(containerProperties);
406         }
407
408         public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo)
409         {
410             if (currentInfo==null)
411                 throw new ArgumentNullException("currentInfo");
412             Contract.EndContractBlock();
413
414             var monitor = Monitors[currentInfo.Account];
415             var newInfo=monitor.CloudClient.GetObjectInfo(currentInfo.Account, currentInfo.Container, currentInfo.Name);
416             return newInfo;
417         }
418
419         public ContainerInfo RefreshContainerInfo(ContainerInfo container)
420         {
421             if (container == null)
422                 throw new ArgumentNullException("container");
423             Contract.EndContractBlock();
424
425             var monitor = Monitors[container.Account];
426             var newInfo = monitor.CloudClient.GetContainerInfo(container.Account, container.Name);
427             return newInfo;
428         }
429
430
431         public void ToggleSynching()
432         {
433             bool isPaused=false;
434             foreach (var pair in Monitors)
435             {
436                 var monitor = pair.Value;
437                 monitor.Pause = !monitor.Pause;
438                 isPaused = monitor.Pause;
439             }
440
441             PauseSyncCaption = isPaused ? "Resume syncing" : "Pause syncing";
442             var iconKey = isPaused? "TraySyncPaused" : "TrayInSynch";
443             StatusIcon = String.Format(@"../Images/{0}.ico", iconKey);
444         }
445
446         public void ExitPithos()
447         {
448             foreach (var pair in Monitors)
449             {
450                 var monitor = pair.Value;
451                 monitor.Stop();
452             }
453
454             ((Window)GetView()).Close();
455         }
456         #endregion
457
458
459         private Dictionary<PithosStatus, StatusInfo> iconNames = new List<StatusInfo>
460             {
461                 new StatusInfo(PithosStatus.InSynch, "All files up to date", "TrayInSynch"),
462                 new StatusInfo(PithosStatus.Syncing, "Syncing Files", "TraySynching"),
463                 new StatusInfo(PithosStatus.SyncPaused, "Sync Paused", "TraySyncPaused")
464             }.ToDictionary(s => s.Status);
465
466         readonly IWindowManager _windowManager;
467
468
469                 ///<summary>
470                 /// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat                
471                 ///</summary>
472         public void UpdateStatus()
473         {
474             var pithosStatus = _statusChecker.GetPithosStatus();
475
476             if (iconNames.ContainsKey(pithosStatus))
477             {
478                 var info = iconNames[pithosStatus];
479                 StatusIcon = String.Format(@"../Images/{0}.ico", info.IconName);
480
481                 Assembly assembly = Assembly.GetExecutingAssembly();                               
482                 var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
483
484
485                 StatusMessage = String.Format("Pithos {0}\r\n{1}", fileVersion.FileVersion,info.StatusText);
486             }
487             
488             _events.Publish(new Notification { Title = "Start", Message = "Start Monitoring", Level = TraceLevel.Info});
489         }
490
491
492        
493         private Task StartMonitor(PithosMonitor monitor,int retries=0)
494         {
495             return Task.Factory.StartNew(() =>
496             {
497                 using (log4net.ThreadContext.Stacks["Monitor"].Push("Start"))
498                 {
499                     try
500                     {
501                         Log.InfoFormat("Start Monitoring {0}", monitor.UserName);
502
503                         monitor.Start();
504                     }
505                     catch (WebException exc)
506                     {
507                         if (AbandonRetry(monitor, retries))
508                             return;
509
510                         if (IsUnauthorized(exc))
511                         {
512                             var message = String.Format("API Key Expired for {0}. Starting Renewal",monitor.UserName);                            
513                             Log.Error(message,exc);
514                             TryAuthorize(monitor,retries).Wait();
515                         }
516                         else
517                         {
518                             TryLater(monitor, exc,retries);
519                         }
520                     }
521                     catch (Exception exc)
522                     {
523                         if (AbandonRetry(monitor, retries)) 
524                             return;
525
526                         TryLater(monitor,exc,retries);
527                     }
528                 }
529             });
530         }
531
532         private bool AbandonRetry(PithosMonitor monitor, int retries)
533         {
534             if (retries > 1)
535             {
536                 var message = String.Format("Monitoring of account {0} has failed too many times. Will not retry",
537                                             monitor.UserName);
538                 _events.Publish(new Notification
539                                     {Title = "Account monitoring failed", Message = message, Level = TraceLevel.Error});
540                 return true;
541             }
542             return false;
543         }
544
545
546         private async Task TryAuthorize(PithosMonitor monitor,int retries)
547         {
548             _events.Publish(new Notification { Title = "Authorization failed", Message = "Your API Key has probably expired. You will be directed to a page where you can renew it", Level = TraceLevel.Error });
549
550             try
551             {
552
553                 var credentials = await PithosAccount.RetrieveCredentials(Settings.PithosLoginUrl);
554
555                 var account = Settings.Accounts.FirstOrDefault(act => act.AccountName == credentials.UserName);
556                 account.ApiKey = credentials.Password;
557                 monitor.ApiKey = credentials.Password;
558                 Settings.Save();
559                 await TaskEx.Delay(10000);
560                 StartMonitor(monitor, retries + 1);
561                 NotifyOfPropertyChange(()=>Accounts);
562             }
563             catch (AggregateException exc)
564             {
565                 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
566                 Log.Error(message, exc.InnerException);
567                 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
568                 return;
569             }
570             catch (Exception exc)
571             {
572                 string message = String.Format("API Key retrieval for {0} failed", monitor.UserName);
573                 Log.Error(message, exc);
574                 _events.Publish(new Notification { Title = "Authorization failed", Message = message, Level = TraceLevel.Error });
575                 return;
576                 
577             }
578
579         }
580
581         private static bool IsUnauthorized(WebException exc)
582         {
583             if (exc==null)
584                 throw new ArgumentNullException("exc");
585             Contract.EndContractBlock();
586
587             var response = exc.Response as HttpWebResponse;
588             if (response == null)
589                 return false;
590             return (response.StatusCode == HttpStatusCode.Unauthorized);
591         }
592
593         private void TryLater(PithosMonitor monitor, Exception exc,int retries)
594         {
595             var message = String.Format("An exception occured. Can't start monitoring\nWill retry in 10 seconds");
596             Task.Factory.StartNewDelayed(10000, () => StartMonitor(monitor,retries+1));
597             _events.Publish(new Notification
598                                 {Title = "Error", Message = message, Level = TraceLevel.Error});
599             Log.Error(message, exc);
600         }
601
602
603         public void NotifyChange(string status, TraceLevel level=TraceLevel.Info)
604         {
605             this.StatusMessage = status;
606             
607             _events.Publish(new Notification { Title = "Pithos", Message = status, Level = level });
608         }
609
610         public void NotifyChangedFile(string filePath)
611         {
612             var entry = new FileEntry {FullPath=filePath};
613             IProducerConsumerCollection<FileEntry> files=this.RecentFiles;
614             FileEntry popped;
615             while (files.Count > 5)
616                 files.TryTake(out popped);
617             files.TryAdd(entry);
618         }
619
620         public void NotifyAccount(AccountInfo account)
621         {
622             if (account== null)
623                 return;
624             //TODO: What happens to an existing account whose Token has changed?
625             account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}",
626                 account.SiteUri, account.Token,
627                 account.UserName);
628
629             if (Accounts.All(item => item.UserName != account.UserName))
630                 Accounts.TryAdd(account);
631
632         }
633
634
635         public void RemoveMonitor(string accountName)
636         {
637             if (String.IsNullOrWhiteSpace(accountName))
638                 return;
639
640             var accountInfo=_accounts.FirstOrDefault(account => account.UserName == accountName);
641             _accounts.TryRemove(accountInfo);
642
643             PithosMonitor monitor;
644             if (Monitors.TryGetValue(accountName, out monitor))
645             {
646                 Monitors.Remove(accountName);
647                 monitor.Stop();
648             }
649         }
650
651         public void RefreshOverlays()
652         {
653             foreach (var pair in Monitors)
654             {
655                 var monitor = pair.Value;
656
657                 var path = monitor.RootPath;
658
659                 if (String.IsNullOrWhiteSpace(path))
660                     continue;
661
662                 if (!Directory.Exists(path) && !File.Exists(path))
663                     continue;
664
665                 IntPtr pathPointer = Marshal.StringToCoTaskMemAuto(path);
666
667                 try
668                 {
669                     NativeMethods.SHChangeNotify(HChangeNotifyEventID.SHCNE_UPDATEITEM,
670                                                  HChangeNotifyFlags.SHCNF_PATHW | HChangeNotifyFlags.SHCNF_FLUSHNOWAIT,
671                                                  pathPointer, IntPtr.Zero);
672                 }
673                 finally
674                 {
675                     Marshal.FreeHGlobal(pathPointer);
676                 }
677             }
678         }
679
680         #region Event Handlers
681         
682         public void Handle(SelectiveSynchChanges message)
683         {
684             var accountName = message.Account.AccountName;
685             PithosMonitor monitor;
686             if (_monitors.TryGetValue(accountName, out monitor))
687             {
688                 monitor.AddSelectivePaths(message.Added);
689                 monitor.RemoveSelectivePaths(message.Removed);
690
691             }
692             
693         }
694
695
696         public void Handle(Notification notification)
697         {
698             if (!Settings.ShowDesktopNotifications)
699                 return;
700             BalloonIcon icon = BalloonIcon.None;
701             switch (notification.Level)
702             {
703                 case TraceLevel.Error:
704                     icon = BalloonIcon.Error;
705                     break;
706                 case TraceLevel.Info:
707                 case TraceLevel.Verbose:
708                     icon = BalloonIcon.Info;
709                     break;
710                 case TraceLevel.Warning:
711                     icon = BalloonIcon.Warning;
712                     break;
713                 default:
714                     icon = BalloonIcon.None;
715                     break;
716             }
717
718             if (Settings.ShowDesktopNotifications)
719             {
720                 var tv = (ShellView) this.GetView();
721                 tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon);
722             }
723         }
724         #endregion
725
726         public void Handle(ShowFilePropertiesEvent message)
727         {
728             if (message == null)
729                 throw new ArgumentNullException("message");
730             if (String.IsNullOrWhiteSpace(message.FileName) )
731                 throw new ArgumentException("message");
732             Contract.EndContractBlock();
733
734             var fileName = message.FileName;
735
736             if (File.Exists(fileName))
737                 this.ShowFileProperties(fileName);
738             else if (Directory.Exists(fileName))
739                 this.ShowContainerProperties(fileName);
740         }
741     }
742 }