/*
* Syncany, www.syncany.org
* Copyright (C) 2011-2013 Philipp C. Heckel <philipp.heckel@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.syncany.gui.tray;
import java.io.File;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.syncany.config.GuiConfigHelper;
import org.syncany.config.GuiEventBus;
import org.syncany.config.to.GuiConfigTO;
import org.syncany.gui.history.HistoryDialog;
import org.syncany.gui.preferences.PreferencesDialog;
import org.syncany.gui.util.DesktopUtil;
import org.syncany.gui.util.I18n;
import org.syncany.gui.wizard.WizardDialog;
import org.syncany.operations.ChangeSet;
import org.syncany.operations.daemon.ControlServer.ControlCommand;
import org.syncany.operations.daemon.Watch;
import org.syncany.operations.daemon.Watch.SyncStatus;
import org.syncany.operations.daemon.messages.CleanupEndSyncExternalEvent;
import org.syncany.operations.daemon.messages.CleanupStartCleaningSyncExternalEvent;
import org.syncany.operations.daemon.messages.ControlManagementRequest;
import org.syncany.operations.daemon.messages.DaemonReloadedExternalEvent;
import org.syncany.operations.daemon.messages.DownChangesDetectedSyncExternalEvent;
import org.syncany.operations.daemon.messages.DownDownloadFileSyncExternalEvent;
import org.syncany.operations.daemon.messages.DownEndSyncExternalEvent;
import org.syncany.operations.daemon.messages.ExitGuiInternalEvent;
import org.syncany.operations.daemon.messages.GenlinkFolderRequest;
import org.syncany.operations.daemon.messages.GenlinkFolderResponse;
import org.syncany.operations.daemon.messages.GuiConfigChangedGuiInternalEvent;
import org.syncany.operations.daemon.messages.ListWatchesManagementRequest;
import org.syncany.operations.daemon.messages.ListWatchesManagementResponse;
import org.syncany.operations.daemon.messages.LogFolderRequest;
import org.syncany.operations.daemon.messages.LogFolderResponse;
import org.syncany.operations.daemon.messages.PluginManagementResponse;
import org.syncany.operations.daemon.messages.RemoveWatchManagementRequest;
import org.syncany.operations.daemon.messages.RemoveWatchManagementResponse;
import org.syncany.operations.daemon.messages.UpEndSyncExternalEvent;
import org.syncany.operations.daemon.messages.UpIndexChangesDetectedSyncExternalEvent;
import org.syncany.operations.daemon.messages.UpIndexStartSyncExternalEvent;
import org.syncany.operations.daemon.messages.UpUploadFileInTransactionSyncExternalEvent;
import org.syncany.operations.daemon.messages.UpUploadFileSyncExternalEvent;
import org.syncany.operations.daemon.messages.UpdateManagementResponse;
import org.syncany.operations.daemon.messages.WatchEndSyncExternalEvent;
import org.syncany.operations.gui.UpdateChecker;
import org.syncany.operations.gui.UpdateChecker.UpdateCheckListener;
import org.syncany.operations.init.GenlinkOperationOptions;
import org.syncany.operations.log.LogOperationOptions;
import org.syncany.util.FileUtil;
import org.syncany.util.StringUtil;
import com.google.common.base.Predicate;
import com.google.common.collect.Maps;
import com.google.common.eventbus.Subscribe;
/**
* Represents the tray icon, showing the status of the application,
* a menu to control the application and the ability to display
* notifications. The tray icon is the central entry point for
* the application.
*
* @author Philipp C. Heckel <philipp.heckel@gmail.com>
* @author Vincent Wiencek <vwiencek@gmail.com>
*/
public abstract class TrayIcon {
protected static final Logger logger = Logger.getLogger(TrayIcon.class.getSimpleName());
protected static int ANIMATION_REFRESH_TIME = 800;
protected static String URL_REPORT_ISSUE = "https://www.syncany.org/r/issue";
protected static String URL_DONATE = "https://www.syncany.org/r/donate";
protected static String URL_HOMEPAGE = "https://www.syncany.org";
protected Shell trayShell;
protected TrayIconTheme theme;
protected WizardDialog wizard;
protected HistoryDialog history;
protected PreferencesDialog preferences;
protected GuiConfigTO guiConfig;
protected GuiEventBus eventBus;
protected UpdateChecker updateChecker;
protected Thread animationThread;
protected AtomicBoolean syncing;
protected Map<String, Boolean> clientSyncStatus;
protected Map<String, Long> clientUploadFileSize;
protected RecentFileChanges recentFileChanges;
public TrayIcon(Shell shell, TrayIconTheme theme) {
this.trayShell = shell;
this.theme = theme;
this.guiConfig = GuiConfigHelper.loadOrCreateGuiConfig();
this.eventBus = GuiEventBus.getInstance();
this.eventBus.register(this);
this.syncing = new AtomicBoolean(false);
this.clientSyncStatus = Maps.newConcurrentMap();
this.clientUploadFileSize = Maps.newConcurrentMap();
this.recentFileChanges = new RecentFileChanges(this);
initUpdateChecker();
initAnimationThread();
initTrayImage();
}
protected void showNew() {
Display.getDefault().asyncExec(new Runnable() {
@Override
public void run() {
if (wizard == null) {
wizard = new WizardDialog(trayShell);
wizard.open();
wizard = null;
}
else {
DesktopUtil.bringToFront(wizard.getWindowShell());
}
}
});
}
protected void showBrowseHistory() {
Display.getDefault().asyncExec(new Runnable() {
@Override
public void run() {
if (history == null) {
history = new HistoryDialog();
history.open();
history = null;
}
else {
DesktopUtil.bringToFront(history.getWindowShell());
}
}
});
}
protected void showPreferences() {
Display.getDefault().asyncExec(new Runnable() {
@Override
public void run() {
if (preferences == null) {
preferences = new PreferencesDialog(trayShell);
preferences.open();
preferences = null;
}
else {
DesktopUtil.bringToFront(preferences.getWindowShell());
}
}
});
}
protected void showFolder(File folder) {
DesktopUtil.launch(folder.getAbsolutePath());
}
protected void showRecentFile(File file) {
DesktopUtil.launch(file.getAbsolutePath());
}
protected void showReportIssue() {
DesktopUtil.launch(URL_REPORT_ISSUE);
}
protected void showDonate() {
DesktopUtil.launch(URL_DONATE);
}
protected void showWebsite() {
DesktopUtil.launch(URL_HOMEPAGE);
}
protected void exitApplication() {
dispose();
eventBus.post(new ExitGuiInternalEvent());
}
public TrayIconTheme getTheme() {
return theme;
}
protected void removeFolder(File folder) {
eventBus.post(new RemoveWatchManagementRequest(folder));
}
protected void copyLink(File folder) {
GenlinkOperationOptions genlinkOptions = new GenlinkOperationOptions();
genlinkOptions.setShortUrl(guiConfig.isShortLinks());
GenlinkFolderRequest genlinkRequest = new GenlinkFolderRequest();
genlinkRequest.setRoot(folder.getAbsolutePath());
genlinkRequest.setOptions(genlinkOptions);
eventBus.post(genlinkRequest);
}
@Subscribe
public void onGenlinkResponseReceived(GenlinkFolderResponse genlinkResponse) {
DesktopUtil.copyToClipboard(genlinkResponse.getResult().getShareLink());
if (guiConfig.isNotifications()) {
String subject = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.copied.subject");
String message = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.copied.message");
displayNotification(subject, message);
}
}
@Subscribe
public void onRemoveWatchResponseReceived(RemoveWatchManagementResponse removeWatchResponse) {
if (removeWatchResponse.getCode() == RemoveWatchManagementResponse.OKAY) {
logger.log(Level.INFO, "Watch removed successfully from daemon config. Now reloading daemon.");
eventBus.post(new ControlManagementRequest(ControlCommand.RELOAD));
}
else {
logger.log(Level.WARNING, "Watch NOT removed from daemon config. Doing nothing.");
}
}
@Subscribe
public void onDaemonReloadedEventReceived(DaemonReloadedExternalEvent daemonReloadedEvent) {
eventBus.post(new ListWatchesManagementRequest());
}
@Subscribe
public void onListWatchesResponseReceived(ListWatchesManagementResponse listWatchesResponse) {
logger.log(Level.FINE, "List watches response recevied: " + listWatchesResponse.getWatches().size() + " watch(es)");
cleanSyncStatus();
List<File> watchedFolders = new ArrayList<File>();
for (Watch watch : listWatchesResponse.getWatches()) {
watchedFolders.add(watch.getFolder());
boolean watchFolderIsSyncing = watch.getStatus() == SyncStatus.SYNCING;
updateSyncStatus(watch.getFolder().getAbsolutePath(), watchFolderIsSyncing);
}
// Update folders in menu
setWatchedFolders(watchedFolders);
// Update tray icon
if (!syncing.get()) {
setTrayImage(TrayIconImage.TRAY_IN_SYNC);
logger.log(Level.FINE, "Syncing image: Setting to image " + TrayIconImage.TRAY_IN_SYNC);
}
// Get recent changes
recentFileChanges.clear();
sendLogRequests(listWatchesResponse.getWatches());
}
private void sendLogRequests(final ArrayList<Watch> watchedFolders) {
if (watchedFolders.size() > 0) {
Timer logRequestTimer = new Timer();
logRequestTimer.schedule(new TimerTask() {
@Override
public void run() {
for (Watch watch : watchedFolders) {
LogOperationOptions logOptions = new LogOperationOptions();
logOptions.setMaxDatabaseVersionCount(RecentFileChanges.RECENT_CHANGES_COUNT);
logOptions.setMaxFileHistoryCount(RecentFileChanges.RECENT_CHANGES_COUNT);
LogFolderRequest logRequest = new LogFolderRequest();
logRequest.setRoot(watch.getFolder().getAbsolutePath());
logRequest.setOptions(logOptions);
eventBus.post(logRequest);
}
}
}, 2000);
}
}
@Subscribe
public void onDownChangesDetectedEvent(DownChangesDetectedSyncExternalEvent downChangesDetectedEvent) {
updateSyncStatus(downChangesDetectedEvent.getRoot(), true);
}
@Subscribe
public void onUpIndexChangesDetectedEvent(UpIndexChangesDetectedSyncExternalEvent upIndexChangesDetectedEvent) {
updateSyncStatus(upIndexChangesDetectedEvent.getRoot(), true);
}
@Subscribe
public void onWatchEndEventReceived(WatchEndSyncExternalEvent watchEndEvent) {
updateSyncStatus(watchEndEvent.getRoot(), false);
}
@Subscribe
public void onIndexStartEventReceived(UpIndexStartSyncExternalEvent syncEvent) {
if (syncEvent.getFileCount() > 0) {
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.up.indexStartWithFileCount", syncEvent.getFileCount()));
}
else {
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.up.indexStartWithoutFileCount"));
}
}
@Subscribe
public void onUploadFileEventReceived(UpUploadFileSyncExternalEvent syncEvent) {
if (syncEvent.getFilename() != null) {
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.up.uploadWithFilename", syncEvent.getFilename()));
}
else {
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.up.uploadWithoutFilename"));
}
}
@Subscribe
public void onUploadFileInTransactionEventReceived(UpUploadFileInTransactionSyncExternalEvent syncEvent) {
Long currentClientUploadFileSize = clientUploadFileSize.get(syncEvent.getRoot());
if (currentClientUploadFileSize == null || syncEvent.getCurrentFileIndex() <= 1) {
currentClientUploadFileSize = 0L;
}
String uploadedTotalStr = FileUtil.formatFileSize(currentClientUploadFileSize);
int uploadedPercent = (int) Math.round((double) currentClientUploadFileSize / syncEvent.getTotalFileSize() * 100);
String statusText = I18n.getText("org.syncany.gui.tray.TrayIcon.up.uploadFileInTransaction", syncEvent.getCurrentFileIndex(),
syncEvent.getTotalFileCount(), uploadedTotalStr, uploadedPercent);
setStatusText(syncEvent.getRoot(), statusText);
currentClientUploadFileSize += syncEvent.getCurrentFileSize();
clientUploadFileSize.put(syncEvent.getRoot(), currentClientUploadFileSize);
}
@Subscribe
public void onUpEndEventReceived(UpEndSyncExternalEvent syncEvent) {
recentFileChanges.updateRecentFiles(syncEvent.getRoot(), new Date(), syncEvent.getResult());
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.insync"));
}
@Subscribe
public void onDownDownloadFileSyncEventReceived(DownDownloadFileSyncExternalEvent syncEvent) {
String fileDescription = syncEvent.getFileDescription();
int currentFileIndex = syncEvent.getCurrentFileIndex();
int maxFileCount = syncEvent.getMaxFileCount();
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.down.downloadFile", fileDescription, currentFileIndex, maxFileCount));
}
@Subscribe
public void onDownEndEventReceived(DownEndSyncExternalEvent downEndSyncEvent) {
String root = downEndSyncEvent.getRoot();
ChangeSet changeSet = downEndSyncEvent.getChanges();
// Update recent changes entries
recentFileChanges.updateRecentFiles(root, new Date(), changeSet);
// Display notification (if enabled)
if (guiConfig.isNotifications() && changeSet.hasChanges()) {
String rootName = new File(root).getName();
int totalChangedFiles = changeSet.getNewFiles().size() + changeSet.getChangedFiles().size() + changeSet.getDeletedFiles().size();
String subject = "";
String message = "";
if (totalChangedFiles == 1) {
if (changeSet.getNewFiles().size() == 1) {
subject = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.added.subject", changeSet.getNewFiles().first());
message = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.added.message", changeSet.getNewFiles().first(), rootName);
}
if (changeSet.getChangedFiles().size() == 1) {
subject = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.changed.subject", changeSet.getChangedFiles().first());
message = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.changed.message", changeSet.getChangedFiles().first(), rootName);
}
if (changeSet.getDeletedFiles().size() == 1) {
subject = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.deleted.subject", changeSet.getDeletedFiles().first());
message = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.deleted.message", changeSet.getDeletedFiles().first(), rootName);
}
}
else {
List<String> messageParts = new ArrayList<>();
if (changeSet.getNewFiles().size() > 0) {
if (changeSet.getNewFiles().size() == 1) {
messageParts.add(I18n.getText("org.syncany.gui.tray.TrayIcon.notify.added.one"));
}
else {
messageParts.add(I18n.getText("org.syncany.gui.tray.TrayIcon.notify.added.many", changeSet.getNewFiles().size()));
}
}
if (changeSet.getChangedFiles().size() > 0) {
if (changeSet.getChangedFiles().size() == 1) {
messageParts.add(I18n.getText("org.syncany.gui.tray.TrayIcon.notify.changed.one"));
}
else {
messageParts.add(I18n.getText("org.syncany.gui.tray.TrayIcon.notify.changed.many", changeSet.getChangedFiles().size()));
}
}
if (changeSet.getDeletedFiles().size() > 0) {
if (changeSet.getDeletedFiles().size() == 1) {
messageParts.add(I18n.getText("org.syncany.gui.tray.TrayIcon.notify.deleted.one"));
}
else {
messageParts.add(I18n.getText("org.syncany.gui.tray.TrayIcon.notify.deleted.many", changeSet.getDeletedFiles().size()));
}
}
subject = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.synced.subject", rootName);
message = I18n.getText("org.syncany.gui.tray.TrayIcon.notify.synced.message", StringUtil.join(messageParts, ", "), rootName);
}
displayNotification(subject, message);
}
}
@Subscribe
public void onCleanupStartCleaningEventReceived(CleanupStartCleaningSyncExternalEvent syncEvent) {
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.cleanup.startcleaning"));
}
@Subscribe
public void onCleanupEndEventReceived(CleanupEndSyncExternalEvent syncEvent) {
setStatusText(syncEvent.getRoot(), I18n.getText("org.syncany.gui.tray.TrayIcon.insync"));
}
@Subscribe
public void onGuiConfigChanged(GuiConfigChangedGuiInternalEvent guiConfigChangedEvent) {
guiConfig = guiConfigChangedEvent.getNewGuiConfig();
}
@Subscribe
public void onLogResponse(LogFolderResponse logResponse) {
recentFileChanges.updateRecentFiles(logResponse.getRoot(), logResponse.getResult().getDatabaseVersions());
}
private void initAnimationThread() {
animationThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
while (!syncing.get()) {
try {
Thread.sleep(200);
}
catch (InterruptedException e) {
// Don't care
}
}
int trayImageIndex = 0;
while (syncing.get()) {
try {
TrayIconImage syncImage = TrayIconImage.getSyncImage(trayImageIndex);
setTrayImage(syncImage);
logger.log(Level.FINE, "Syncing image: Setting image to " + syncImage);
trayImageIndex = (trayImageIndex + 1) % TrayIconImage.MAX_SYNC_IMAGES;
Thread.sleep(ANIMATION_REFRESH_TIME);
}
catch (InterruptedException e) {
// Don't care
}
}
setTrayImage(TrayIconImage.TRAY_IN_SYNC);
setStatusText(null, I18n.getText("org.syncany.gui.tray.TrayIcon.insync"));
logger.log(Level.FINE, "Syncing image: Setting image to " + TrayIconImage.TRAY_IN_SYNC);
}
}
});
animationThread.start();
}
private void initTrayImage() {
setTrayImage(TrayIconImage.TRAY_NO_OVERLAY);
logger.log(Level.FINE, "Syncing image: Setting image to " + TrayIconImage.TRAY_NO_OVERLAY);
}
private void initUpdateChecker() {
if (guiConfig.isUpdateCheck()) {
logger.log(Level.INFO, "Regular update check ENABLED. Starting update checker thread ...");
UpdateChecker updateChecker = new UpdateChecker(new UpdateCheckListener() {
@Override
public void updateResponseReceived(UpdateManagementResponse appResponse, PluginManagementResponse pluginResponse,
String updateResponseText, boolean updatesAvailable) {
if (updatesAvailable) {
displayNotification(I18n.getText("org.syncany.gui.tray.TrayIcon.updatesAvailable"), updateResponseText);
}
}
});
updateChecker.start();
}
else {
logger.log(Level.INFO, "Regular update check not enabled.");
}
}
private void cleanSyncStatus() {
logger.log(Level.FINE, "Resetting sync status for clients.");
clientSyncStatus.clear();
}
private void updateSyncStatus(String root, boolean syncStatus) {
clientSyncStatus.put(root, syncStatus);
logger.log(Level.FINE, "Sync status for " + root + ": " + syncStatus);
// Update 'syncing' variable: Set true if any of the folders is syncing
Map<String, Boolean> syncingFolders = Maps.filterValues(clientSyncStatus, new Predicate<Boolean>() {
@Override
public boolean apply(Boolean syncStatus) {
return syncStatus;
}
});
syncing.set(syncingFolders.size() > 0);
}
// Abstract methods
protected abstract void setTrayImage(TrayIconImage image);
protected abstract void setWatchedFolders(List<File> folders);
protected abstract void setStatusText(String root, String statusText);
protected abstract void setRecentChanges(List<File> recentFiles);
protected abstract void displayNotification(String subject, String message);
protected abstract void dispose();
}