Browse Source

Merge pull request #3362 from cryptomator/feature/update-checker-refactoring

Feature: Expansion of Preferences Update Tab with UI Elements and Refactoring of UpdateChecker
mindmonk 1 năm trước cách đây
mục cha
commit
863e9bbcb3

+ 7 - 0
pom.xml

@@ -158,11 +158,18 @@
 			<artifactId>nimbus-jose-jwt</artifactId>
 			<version>${nimbus-jose.version}</version>
 		</dependency>
+
+		<!-- Jackson -->
 		<dependency>
 			<groupId>com.fasterxml.jackson.core</groupId>
 			<artifactId>jackson-databind</artifactId>
 			<version>${jackson.version}</version>
 		</dependency>
+		<dependency>
+			<groupId>com.fasterxml.jackson.datatype</groupId>
+			<artifactId>jackson-datatype-jsr310</artifactId>
+			<version>${jackson.version}</version>
+		</dependency>
 
 		<!-- EasyBind -->
 		<dependency>

+ 1 - 0
src/main/java/module-info.java

@@ -38,6 +38,7 @@ open module org.cryptomator.desktop {
 	requires com.auth0.jwt;
 	requires com.google.common;
 	requires com.fasterxml.jackson.databind;
+	requires com.fasterxml.jackson.datatype.jsr310;
 	requires com.nimbusds.jose.jwt;
 	requires com.nulabinc.zxcvbn;
 	requires com.tobiasdiez.easybind;

+ 6 - 6
src/main/java/org/cryptomator/common/settings/Settings.java

@@ -25,6 +25,7 @@ import javafx.beans.property.StringProperty;
 import javafx.collections.FXCollections;
 import javafx.collections.ObservableList;
 import javafx.geometry.NodeOrientation;
+import java.time.Instant;
 import java.util.function.Consumer;
 
 public class Settings {
@@ -44,8 +45,7 @@ public class Settings {
 	static final String DEFAULT_KEYCHAIN_PROVIDER = SystemUtils.IS_OS_WINDOWS ? "org.cryptomator.windows.keychain.WindowsProtectedKeychainAccess" : SystemUtils.IS_OS_MAC ? "org.cryptomator.macos.keychain.MacSystemKeychainAccess" : "org.cryptomator.linux.keychain.SecretServiceKeychainAccess";
 	static final String DEFAULT_USER_INTERFACE_ORIENTATION = NodeOrientation.LEFT_TO_RIGHT.name();
 	static final boolean DEFAULT_SHOW_MINIMIZE_BUTTON = false;
-	static final String DEFAULT_LAST_UPDATE_CHECK = "2000-01-01";
-
+	public static final Instant DEFAULT_TIMESTAMP = Instant.parse("2000-01-01T00:00:00Z");
 	public final ObservableList<VaultSettings> directories;
 	public final BooleanProperty askedForUpdateCheck;
 	public final BooleanProperty checkForUpdates;
@@ -67,7 +67,7 @@ public class Settings {
 	public final IntegerProperty windowHeight;
 	public final StringProperty language;
 	public final StringProperty mountService;
-	public final StringProperty lastUpdateCheck;
+	public final ObjectProperty<Instant> lastSuccessfulUpdateCheck;
 
 	private Consumer<Settings> saveCmd;
 
@@ -104,7 +104,7 @@ public class Settings {
 		this.windowHeight = new SimpleIntegerProperty(this, "windowHeight", json.windowHeight);
 		this.language = new SimpleStringProperty(this, "language", json.language);
 		this.mountService = new SimpleStringProperty(this, "mountService", json.mountService);
-		this.lastUpdateCheck = new SimpleStringProperty(this, "lastUpdateCheck", json.lastUpdateCheck);
+		this.lastSuccessfulUpdateCheck = new SimpleObjectProperty<>(this, "lastSuccessfulUpdateCheck", json.lastSuccessfulUpdateCheck);
 
 		this.directories.addAll(json.directories.stream().map(VaultSettings::new).toList());
 
@@ -131,7 +131,7 @@ public class Settings {
 		windowHeight.addListener(this::somethingChanged);
 		language.addListener(this::somethingChanged);
 		mountService.addListener(this::somethingChanged);
-		lastUpdateCheck.addListener(this::somethingChanged);
+		lastSuccessfulUpdateCheck.addListener(this::somethingChanged);
 	}
 
 	@SuppressWarnings("deprecation")
@@ -185,7 +185,7 @@ public class Settings {
 		json.windowHeight = windowHeight.get();
 		json.language = language.get();
 		json.mountService = mountService.get();
-		json.lastUpdateCheck = lastUpdateCheck.get();
+		json.lastSuccessfulUpdateCheck = lastSuccessfulUpdateCheck.get();
 		return json;
 	}
 

+ 5 - 2
src/main/java/org/cryptomator/common/settings/SettingsJson.java

@@ -1,9 +1,11 @@
 package org.cryptomator.common.settings;
 
+import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 
+import java.time.Instant;
 import java.util.List;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -80,7 +82,8 @@ class SettingsJson {
 	@JsonProperty(value = "preferredVolumeImpl", access = JsonProperty.Access.WRITE_ONLY) // WRITE_ONLY means value is "written" into the java object during deserialization. Upvote this: https://github.com/FasterXML/jackson-annotations/issues/233
 	String preferredVolumeImpl;
 
-	@JsonProperty("lastUpdateCheck")
-	String lastUpdateCheck = Settings.DEFAULT_LAST_UPDATE_CHECK;
+	@JsonProperty("lastSuccessfulUpdateCheck")
+	@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
+	Instant lastSuccessfulUpdateCheck = Settings.DEFAULT_TIMESTAMP;
 
 }

+ 2 - 1
src/main/java/org/cryptomator/common/settings/SettingsProvider.java

@@ -10,6 +10,7 @@ package org.cryptomator.common.settings;
 
 import com.fasterxml.jackson.core.JacksonException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
 import com.google.common.base.Suppliers;
 import org.cryptomator.common.Environment;
 import org.slf4j.Logger;
@@ -36,7 +37,7 @@ import java.util.stream.Stream;
 @Singleton
 public class SettingsProvider implements Supplier<Settings> {
 
-	private static final ObjectMapper JSON = new ObjectMapper().setDefaultLeniency(true);
+	private static final ObjectMapper JSON = new ObjectMapper().setDefaultLeniency(true).registerModule(new JavaTimeModule());
 	private static final Logger LOG = LoggerFactory.getLogger(SettingsProvider.class);
 	private static final long SAVE_DELAY_MS = 1000;
 

+ 62 - 21
src/main/java/org/cryptomator/ui/fxapp/UpdateChecker.java

@@ -1,45 +1,57 @@
 package org.cryptomator.ui.fxapp;
 
 import org.cryptomator.common.Environment;
+import org.cryptomator.common.SemVerComparator;
 import org.cryptomator.common.settings.Settings;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import javax.inject.Inject;
-import javax.inject.Named;
+import javafx.beans.binding.Bindings;
 import javafx.beans.binding.BooleanBinding;
+import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.ReadOnlyStringProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.property.SimpleStringProperty;
 import javafx.beans.property.StringProperty;
 import javafx.concurrent.ScheduledService;
 import javafx.concurrent.Worker;
 import javafx.concurrent.WorkerStateEvent;
 import javafx.util.Duration;
+import java.time.Instant;
 import java.util.Comparator;
 
 @FxApplicationScoped
 public class UpdateChecker {
 
 	private static final Logger LOG = LoggerFactory.getLogger(UpdateChecker.class);
-	private static final Duration AUTOCHECK_DELAY = Duration.seconds(5);
+	private static final Duration AUTO_CHECK_DELAY = Duration.seconds(5);
 
 	private final Environment env;
 	private final Settings settings;
-	private final StringProperty latestVersionProperty;
-	private final Comparator<String> semVerComparator;
+	private final StringProperty latestVersion = new SimpleStringProperty();
 	private final ScheduledService<String> updateCheckerService;
+	private final ObjectProperty<UpdateCheckState> state = new SimpleObjectProperty<>(UpdateCheckState.NOT_CHECKED);
+	private final ObjectProperty<Instant> lastSuccessfulUpdateCheck;
+	private final Comparator<String> versionComparator = new SemVerComparator();
+	private final BooleanBinding updateAvailable;
+	private final BooleanBinding checkFailed;
 
 	@Inject
-	UpdateChecker(Settings settings, Environment env, @Named("latestVersion") StringProperty latestVersionProperty, @Named("SemVer") Comparator<String> semVerComparator, ScheduledService<String> updateCheckerService) {
+	UpdateChecker(Settings settings, //
+				  Environment env, //
+				  ScheduledService<String> updateCheckerService) {
 		this.env = env;
 		this.settings = settings;
-		this.latestVersionProperty = latestVersionProperty;
-		this.semVerComparator = semVerComparator;
 		this.updateCheckerService = updateCheckerService;
+		this.lastSuccessfulUpdateCheck = settings.lastSuccessfulUpdateCheck;
+		this.updateAvailable = Bindings.createBooleanBinding(this::isUpdateAvailable, latestVersion);
+		this.checkFailed = Bindings.equal(UpdateCheckState.CHECK_FAILED, state);
 	}
 
 	public void automaticallyCheckForUpdatesIfEnabled() {
 		if (!env.disableUpdateCheck() && settings.checkForUpdates.get()) {
-			startCheckingForUpdates(AUTOCHECK_DELAY);
+			startCheckingForUpdates(AUTO_CHECK_DELAY);
 		}
 	}
 
@@ -59,36 +71,65 @@ public class UpdateChecker {
 
 	private void checkStarted(WorkerStateEvent event) {
 		LOG.debug("Checking for updates...");
+		state.set(UpdateCheckState.IS_CHECKING);
 	}
 
 	private void checkSucceeded(WorkerStateEvent event) {
-		String latestVersion = updateCheckerService.getValue();
-		LOG.info("Current version: {}, lastest version: {}", getCurrentVersion(), latestVersion);
-
-		if (semVerComparator.compare(getCurrentVersion(), latestVersion) < 0) {
-			// update is available
-			latestVersionProperty.set(latestVersion);
-		} else {
-			latestVersionProperty.set(null);
-		}
+		var latestVersionString = updateCheckerService.getValue();
+		LOG.info("Current version: {}, latest version: {}", getCurrentVersion(), latestVersionString);
+		lastSuccessfulUpdateCheck.set(Instant.now());
+		latestVersion.set(latestVersionString);
+		state.set(UpdateCheckState.CHECK_SUCCESSFUL);
 	}
 
 	private void checkFailed(WorkerStateEvent event) {
-		LOG.warn("Error checking for updates", event.getSource().getException());
+		state.set(UpdateCheckState.CHECK_FAILED);
 	}
 
-	/* Observable Properties */
+	public enum UpdateCheckState {
+		NOT_CHECKED,
+		IS_CHECKING,
+		CHECK_SUCCESSFUL,
+		CHECK_FAILED;
+	}
 
+	/* Observable Properties */
 	public BooleanBinding checkingForUpdatesProperty() {
 		return updateCheckerService.stateProperty().isEqualTo(Worker.State.RUNNING);
 	}
 
 	public ReadOnlyStringProperty latestVersionProperty() {
-		return latestVersionProperty;
+		return latestVersion;
+	}
+
+	public BooleanBinding updateAvailableProperty() {
+		return updateAvailable;
+	}
+
+	public BooleanBinding checkFailedProperty() {
+		return checkFailed;
+	}
+
+	public boolean isUpdateAvailable() {
+		String currentVersion = getCurrentVersion();
+		String latestVersionString = latestVersion.get();
+
+		if (currentVersion == null || latestVersionString == null) {
+			return false;
+		} else {
+			return versionComparator.compare(currentVersion, latestVersionString) < 0;
+		}
+	}
+
+	public ObjectProperty<Instant> lastSuccessfulUpdateCheckProperty() {
+		return lastSuccessfulUpdateCheck;
+	}
+
+	public ObjectProperty<UpdateCheckState> updateCheckStateProperty() {
+		return state;
 	}
 
 	public String getCurrentVersion() {
 		return env.getAppVersion();
 	}
-
 }

+ 0 - 9
src/main/java/org/cryptomator/ui/fxapp/UpdateCheckerModule.java

@@ -11,8 +11,6 @@ import org.slf4j.LoggerFactory;
 import javax.inject.Named;
 import javafx.beans.binding.Bindings;
 import javafx.beans.binding.ObjectBinding;
-import javafx.beans.property.SimpleStringProperty;
-import javafx.beans.property.StringProperty;
 import javafx.concurrent.ScheduledService;
 import javafx.concurrent.Task;
 import javafx.util.Duration;
@@ -32,13 +30,6 @@ public abstract class UpdateCheckerModule {
 	private static final Duration UPDATE_CHECK_INTERVAL = Duration.hours(3);
 	private static final Duration DISABLED_UPDATE_CHECK_INTERVAL = Duration.hours(100000); // Duration.INDEFINITE leads to overflows...
 
-	@Provides
-	@Named("latestVersion")
-	@FxApplicationScoped
-	static StringProperty provideLatestVersion() {
-		return new SimpleStringProperty();
-	}
-
 	@Provides
 	@FxApplicationScoped
 	static Optional<HttpClient> provideHttpClient() {

+ 1 - 1
src/main/java/org/cryptomator/ui/mainwindow/MainWindowTitleController.java

@@ -46,7 +46,7 @@ public class MainWindowTitleController implements FxController {
 		this.appWindows = appWindows;
 		this.trayMenuInitialized = trayMenu.isInitialized();
 		this.updateChecker = updateChecker;
-		this.updateAvailable = updateChecker.latestVersionProperty().isNotNull();
+		this.updateAvailable = updateChecker.updateAvailableProperty();
 		this.licenseHolder = licenseHolder;
 		this.settings = settings;
 		this.showMinimizeButton = Bindings.createBooleanBinding(this::isShowMinimizeButton, settings.showMinimizeButton, settings.showTrayIcon);

+ 97 - 2
src/main/java/org/cryptomator/ui/preferences/UpdatesPreferencesController.java

@@ -1,18 +1,33 @@
 package org.cryptomator.ui.preferences;
 
+import org.cryptomator.common.Environment;
 import org.cryptomator.common.settings.Settings;
 import org.cryptomator.ui.common.FxController;
 import org.cryptomator.ui.fxapp.UpdateChecker;
 
 import javax.inject.Inject;
+import javafx.animation.PauseTransition;
 import javafx.application.Application;
 import javafx.beans.binding.Bindings;
 import javafx.beans.binding.BooleanBinding;
 import javafx.beans.binding.ObjectBinding;
+import javafx.beans.binding.StringBinding;
+import javafx.beans.property.BooleanProperty;
 import javafx.beans.property.ReadOnlyStringProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ObservableValue;
 import javafx.fxml.FXML;
 import javafx.scene.control.CheckBox;
 import javafx.scene.control.ContentDisplay;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.Locale;
+import java.util.ResourceBundle;
+
 
 @PreferencesScoped
 public class UpdatesPreferencesController implements FxController {
@@ -20,29 +35,55 @@ public class UpdatesPreferencesController implements FxController {
 	private static final String DOWNLOADS_URI = "https://cryptomator.org/downloads";
 
 	private final Application application;
+	private final Environment environment;
+	private final ResourceBundle resourceBundle;
 	private final Settings settings;
 	private final UpdateChecker updateChecker;
 	private final ObjectBinding<ContentDisplay> checkForUpdatesButtonState;
 	private final ReadOnlyStringProperty latestVersion;
+	private final ObservableValue<Instant> lastSuccessfulUpdateCheck;
+	private final StringBinding lastUpdateCheckMessage;
+	private final ObservableValue<String> timeDifferenceMessage;
 	private final String currentVersion;
 	private final BooleanBinding updateAvailable;
+	private final BooleanBinding checkFailed;
+	private final BooleanProperty upToDateLabelVisible = new SimpleBooleanProperty(false);
+	private final DateTimeFormatter formatter;
+	private final BooleanBinding upToDate;
 
 	/* FXML */
 	public CheckBox checkForUpdatesCheckbox;
 
 	@Inject
-	UpdatesPreferencesController(Application application, Settings settings, UpdateChecker updateChecker) {
+	UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker) {
 		this.application = application;
+		this.environment = environment;
+		this.resourceBundle = resourceBundle;
 		this.settings = settings;
 		this.updateChecker = updateChecker;
 		this.checkForUpdatesButtonState = Bindings.when(updateChecker.checkingForUpdatesProperty()).then(ContentDisplay.LEFT).otherwise(ContentDisplay.TEXT_ONLY);
 		this.latestVersion = updateChecker.latestVersionProperty();
-		this.updateAvailable = latestVersion.isNotNull();
+		this.lastSuccessfulUpdateCheck = updateChecker.lastSuccessfulUpdateCheckProperty();
+		this.timeDifferenceMessage = Bindings.createStringBinding(this::getTimeDifferenceMessage, lastSuccessfulUpdateCheck);
 		this.currentVersion = updateChecker.getCurrentVersion();
+		this.updateAvailable = updateChecker.updateAvailableProperty();
+		this.formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
+		this.upToDate = updateChecker.updateCheckStateProperty().isEqualTo(UpdateChecker.UpdateCheckState.CHECK_SUCCESSFUL).and(latestVersion.isEqualTo(currentVersion));
+		this.checkFailed = updateChecker.checkFailedProperty();
+		this.lastUpdateCheckMessage = Bindings.createStringBinding(this::getLastUpdateCheckMessage, lastSuccessfulUpdateCheck);
 	}
 
 	public void initialize() {
 		checkForUpdatesCheckbox.selectedProperty().bindBidirectional(settings.checkForUpdates);
+
+		upToDate.addListener((_, _, newVal) -> {
+			if (newVal) {
+				upToDateLabelVisible.set(true);
+				PauseTransition delay = new PauseTransition(javafx.util.Duration.seconds(5));
+				delay.setOnFinished(_ -> upToDateLabelVisible.set(false));
+				delay.play();
+			}
+		});
 	}
 
 	@FXML
@@ -55,6 +96,11 @@ public class UpdatesPreferencesController implements FxController {
 		application.getHostServices().showDocument(DOWNLOADS_URI);
 	}
 
+	@FXML
+	public void showLogfileDirectory() {
+		environment.getLogDir().ifPresent(logDirPath -> application.getHostServices().showDocument(logDirPath.toUri().toString()));
+	}
+
 	/* Observable Properties */
 
 	public ObjectBinding<ContentDisplay> checkForUpdatesButtonStateProperty() {
@@ -77,6 +123,46 @@ public class UpdatesPreferencesController implements FxController {
 		return currentVersion;
 	}
 
+	public StringBinding lastUpdateCheckMessageProperty() {
+		return lastUpdateCheckMessage;
+	}
+
+	public String getLastUpdateCheckMessage() {
+		Instant lastCheck = lastSuccessfulUpdateCheck.getValue();
+		if (lastCheck != null && !lastCheck.equals(Settings.DEFAULT_TIMESTAMP)) {
+			return formatter.format(LocalDateTime.ofInstant(lastCheck, ZoneId.systemDefault()));
+		} else {
+			return "-";
+		}
+	}
+
+	public ObservableValue<String> timeDifferenceMessageProperty() {
+		return timeDifferenceMessage;
+	}
+
+	public String getTimeDifferenceMessage() {
+		var lastSuccessCheck = lastSuccessfulUpdateCheck.getValue();
+		var duration = Duration.between(lastSuccessCheck, Instant.now());
+		var hours = duration.toHours();
+		if (lastSuccessCheck.equals(Settings.DEFAULT_TIMESTAMP)) {
+			return resourceBundle.getString("preferences.updates.lastUpdateCheck.never");
+		} else if (hours < 1) {
+			return resourceBundle.getString("preferences.updates.lastUpdateCheck.recently");
+		} else if (hours < 24) {
+			return String.format(resourceBundle.getString("preferences.updates.lastUpdateCheck.hoursAgo"), hours);
+		} else {
+			return String.format(resourceBundle.getString("preferences.updates.lastUpdateCheck.daysAgo"), duration.toDays());
+		}
+	}
+
+	public BooleanProperty upToDateLabelVisibleProperty() {
+		return upToDateLabelVisible;
+	}
+
+	public boolean isUpToDateLabelVisible() {
+		return upToDateLabelVisible.get();
+	}
+
 	public BooleanBinding updateAvailableProperty() {
 		return updateAvailable;
 	}
@@ -84,4 +170,13 @@ public class UpdatesPreferencesController implements FxController {
 	public boolean isUpdateAvailable() {
 		return updateAvailable.get();
 	}
+
+	public BooleanBinding checkFailedProperty() {
+		return checkFailed;
+	}
+
+	public boolean isCheckFailed() {
+		return checkFailed.getValue();
+	}
+
 }

+ 5 - 2
src/main/java/org/cryptomator/ui/updatereminder/UpdateReminderComponent.java

@@ -8,7 +8,8 @@ import org.cryptomator.ui.common.FxmlScene;
 
 import javafx.scene.Scene;
 import javafx.stage.Stage;
-import java.time.LocalDate;
+import java.time.Duration;
+import java.time.Instant;
 
 @UpdateReminderScoped
 @Subcomponent(modules = {UpdateReminderModule.class})
@@ -23,7 +24,8 @@ public interface UpdateReminderComponent {
 	Settings settings();
 
 	default void checkAndShowUpdateReminderWindow() {
-		if (LocalDate.parse(settings().lastUpdateCheck.get()).isBefore(LocalDate.now().minusDays(14)) && !settings().checkForUpdates.getValue()) {
+		var now = Instant.now();
+		if (!settings().checkForUpdates.getValue() && settings().lastSuccessfulUpdateCheck.get().isBefore(now.minus(Duration.ofDays(14)))) {
 			Stage stage = window();
 			stage.setScene(updateReminderScene().get());
 			stage.sizeToScene();
@@ -33,6 +35,7 @@ public interface UpdateReminderComponent {
 
 	@Subcomponent.Factory
 	interface Factory {
+
 		UpdateReminderComponent create();
 	}
 }

+ 0 - 5
src/main/java/org/cryptomator/ui/updatereminder/UpdateReminderController.java

@@ -7,8 +7,6 @@ import org.cryptomator.ui.fxapp.UpdateChecker;
 import javax.inject.Inject;
 import javafx.fxml.FXML;
 import javafx.stage.Stage;
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
 
 @UpdateReminderScoped
 public class UpdateReminderController implements FxController {
@@ -27,20 +25,17 @@ public class UpdateReminderController implements FxController {
 
 	@FXML
 	public void cancel() {
-		settings.lastUpdateCheck.set(LocalDate.now().format(DateTimeFormatter.ISO_DATE));
 		window.close();
 	}
 
 	@FXML
 	public void once() {
-		settings.lastUpdateCheck.set(LocalDate.now().format(DateTimeFormatter.ISO_DATE));
 		updateChecker.checkForUpdatesNow();
 		window.close();
 	}
 
 	@FXML
 	public void automatically() {
-		settings.lastUpdateCheck.set(LocalDate.now().format(DateTimeFormatter.ISO_DATE));
 		updateChecker.checkForUpdatesNow();
 		settings.checkForUpdates.set(true);
 		window.close();

+ 34 - 13
src/main/resources/fxml/preferences_updates.fxml

@@ -1,13 +1,19 @@
 <?xml version="1.0" encoding="UTF-8"?>
 
+<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
+<?import org.cryptomator.ui.controls.FontAwesome5Spinner?>
 <?import org.cryptomator.ui.controls.FormattedLabel?>
 <?import org.cryptomator.ui.controls.FormattedString?>
 <?import javafx.geometry.Insets?>
 <?import javafx.scene.control.Button?>
 <?import javafx.scene.control.CheckBox?>
 <?import javafx.scene.control.Hyperlink?>
+<?import javafx.scene.control.Label?>
+<?import javafx.scene.layout.HBox?>
 <?import javafx.scene.layout.VBox?>
-<?import org.cryptomator.ui.controls.FontAwesome5Spinner?>
+<?import javafx.scene.control.Tooltip?>
+<?import javafx.scene.text.TextFlow?>
+<?import javafx.scene.text.Text?>
 <VBox xmlns:fx="http://javafx.com/fxml"
 	  xmlns="http://javafx.com/javafx"
 	  fx:controller="org.cryptomator.ui.preferences.UpdatesPreferencesController"
@@ -18,19 +24,34 @@
 	<padding>
 		<Insets topRightBottomLeft="24"/>
 	</padding>
-	<children>
-		<FormattedLabel format="%preferences.updates.currentVersion" arg1="${controller.currentVersion}" textAlignment="CENTER" wrapText="true"/>
+	<FormattedLabel format="%preferences.updates.currentVersion" arg1="${controller.currentVersion}" textAlignment="CENTER" wrapText="true"/>
 
-		<CheckBox fx:id="checkForUpdatesCheckbox" text="%preferences.updates.autoUpdateCheck"/>
+	<CheckBox fx:id="checkForUpdatesCheckbox" text="%preferences.updates.autoUpdateCheck"/>
 
-		<VBox alignment="CENTER" spacing="12">
-			<Button text="%preferences.updates.checkNowBtn" defaultButton="true" onAction="#checkNow" contentDisplay="${controller.checkForUpdatesButtonState}">
-				<graphic>
-					<FontAwesome5Spinner fx:id="spinner" glyphSize="12"/>
-				</graphic>
-			</Button>
+	<VBox alignment="CENTER" spacing="12">
+		<Button text="%preferences.updates.checkNowBtn" defaultButton="true" onAction="#checkNow" contentDisplay="${controller.checkForUpdatesButtonState}">
+			<graphic>
+				<FontAwesome5Spinner glyphSize="12"/>
+			</graphic>
+		</Button>
 
-			<Hyperlink text="${linkLabel.value}" onAction="#visitDownloadsPage" textAlignment="CENTER" wrapText="true" styleClass="hyperlink-underline" visible="${controller.updateAvailable}"/>
-		</VBox>
-	</children>
+		<TextFlow styleClass="text-flow" textAlignment="CENTER" visible="${controller.checkFailed}" managed="${controller.checkFailed}">
+			<FontAwesome5IconView glyphSize="12" styleClass="glyph-icon-orange" glyph="EXCLAMATION_TRIANGLE"/>
+			<Text text=" "/>
+			<Text text="%preferences.updates.checkFailed"/>
+			<Text text=" "/>
+			<Hyperlink styleClass="hyperlink-underline" text="%preferences.general.debugDirectory" onAction="#showLogfileDirectory"/>
+		</TextFlow>
+		<FormattedLabel format="%preferences.updates.lastUpdateCheck" arg1="${controller.timeDifferenceMessage}" textAlignment="CENTER" wrapText="true">
+			<tooltip>
+				<Tooltip text="${controller.lastUpdateCheckMessage}" showDelay="10ms"/>
+			</tooltip>
+		</FormattedLabel>
+		<Label text="%preferences.updates.upToDate" visible="${controller.upToDateLabelVisible}" managed="${controller.upToDateLabelVisible}">
+			<graphic>
+				<FontAwesome5IconView glyphSize="12" styleClass="glyph-icon-primary" glyph="CHECK"/>
+			</graphic>
+		</Label>
+		<Hyperlink text="${linkLabel.value}" onAction="#visitDownloadsPage" textAlignment="CENTER" wrapText="true" styleClass="hyperlink-underline" visible="${controller.updateAvailable}" managed="${controller.updateAvailable}"/>
+	</VBox>
 </VBox>

+ 8 - 0
src/main/resources/i18n/strings.properties

@@ -321,6 +321,14 @@ preferences.updates.currentVersion=Current Version: %s
 preferences.updates.autoUpdateCheck=Check for updates automatically
 preferences.updates.checkNowBtn=Check Now
 preferences.updates.updateAvailable=Update to version %s available.
+preferences.updates.lastUpdateCheck=Last check: %s
+preferences.updates.lastUpdateCheck.never=never
+preferences.updates.lastUpdateCheck.recently=recently
+preferences.updates.lastUpdateCheck.daysAgo=%s days ago
+preferences.updates.lastUpdateCheck.hoursAgo=%s hours ago
+preferences.updates.checkFailed=Looking for updates failed. Please check your internet connection or try again later.
+preferences.updates.upToDate=Cryptomator is up-to-date.
+
 ## Contribution
 preferences.contribute=Support Us
 preferences.contribute.registeredFor=Supporter certificate registered for %s

+ 2 - 1
src/test/java/org/cryptomator/common/settings/SettingsJsonTest.java

@@ -3,6 +3,7 @@ package org.cryptomator.common.settings;
 import com.fasterxml.jackson.core.JacksonException;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
 import org.hamcrest.MatcherAssert;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -68,7 +69,7 @@ public class SettingsJsonTest {
 		jsonObj.theme = UiTheme.DARK;
 		jsonObj.showTrayIcon = false;
 
-		var jsonStr = new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(jsonObj);
+		var jsonStr = new ObjectMapper().registerModule(new JavaTimeModule()).writerWithDefaultPrettyPrinter().writeValueAsString(jsonObj);
 
 		MatcherAssert.assertThat(jsonStr, containsString("\"theme\" : \"DARK\""));
 		MatcherAssert.assertThat(jsonStr, containsString("\"showTrayIcon\" : false"));