Added error codes to error screen (#1741)

Added error codes based on a translation of GeneratedErrorCode.kt (Cryptomator Android) into Java:

Source of GeneratedErrorCode.kt: https://github.com/cryptomator/android/blob/3ae90ab521a4aa69f394c0490f27e9db6106ce0e/presentation/src/main/java/org/cryptomator/presentation/logging/GeneratedErrorCode.kt

Co-authored-by: Sebastian Stenzel <sebastian.stenzel@gmail.com>
This commit is contained in:
JaniruTEC
2021-09-21 17:41:54 +02:00
committed by GitHub
co-authored by Sebastian Stenzel
parent efc77e4997
commit 3e216ed0ac
7 changed files with 362 additions and 6 deletions
@@ -0,0 +1,121 @@
package org.cryptomator.common;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.IntStream;
import java.util.stream.Stream;
/**
* Holds a throwable and provides a human-readable {@link #toString() three-component string representation}
* aiming to allow documentation and lookup of same or similar errors.
*/
public class ErrorCode {
private final static int A_PRIME = 31;
private final static int SEED = 0xdeadbeef;
public final static String DELIM = ":";
private final static int LATEST_FRAME = 1;
private final static int ALL_FRAMES = Integer.MAX_VALUE;
private final Throwable throwable;
private final Throwable rootCause;
private final int rootCauseSpecificFrames;
private ErrorCode(Throwable throwable, Throwable rootCause, int rootCauseSpecificFrames) {
this.throwable = Objects.requireNonNull(throwable);
this.rootCause = Objects.requireNonNull(rootCause);
this.rootCauseSpecificFrames = rootCauseSpecificFrames;
}
// visible for testing
String methodCode() {
return format(traceCode(rootCause, LATEST_FRAME));
}
// visible for testing
String rootCauseCode() {
return format(traceCode(rootCause, rootCauseSpecificFrames));
}
// visible for testing
String throwableCode() {
return format(traceCode(throwable, ALL_FRAMES));
}
/**
* Produces an error code consisting of three {@value DELIM}-separated components.
* <p>
* A full match of the error code indicates the exact same throwable (to the extent possible
* without hash collisions). A partial match of the first or second component indicates related problems
* with the same root cause.
*
* @return A three-part error code
*/
@Override
public String toString() {
return methodCode() + DELIM + rootCauseCode() + DELIM + throwableCode();
}
/**
* Deterministically creates an error code from the stack trace of the given <code>cause</code>.
* <p>
* The code consists of three parts separated by {@value DELIM}:
* <ul>
* <li>The first part depends on the root cause and the method that threw it</li>
* <li>The second part depends on the root cause and its stack trace</li>
* <li>The third part depends on all the cause hierarchy</li>
* </ul>
* <p>
* Parts may be identical if the cause is the root cause or the root cause has just one single item in its stack trace.
*
* @param throwable The exception
* @return A three-part error code
*/
public static ErrorCode of(Throwable throwable) {
var causalChain = Throwables.getCausalChain(throwable);
if (causalChain.size() > 1) {
var rootCause = causalChain.get(causalChain.size() - 1);
var parentOfRootCause = causalChain.get(causalChain.size() - 2);
var rootSpecificFrames = nonOverlappingFrames(parentOfRootCause.getStackTrace(), rootCause.getStackTrace());
return new ErrorCode(throwable, rootCause, rootSpecificFrames);
} else {
return new ErrorCode(throwable, throwable, ALL_FRAMES);
}
}
private String format(int value) {
// Cut off highest 12 bits (only leave 20 least significant bits) and XOR rest with cutoff
value = (value & 0xfffff) ^ (value >>> 20);
return Strings.padStart(Integer.toString(value, 32).toUpperCase(Locale.ROOT), 4, '0');
}
private int traceCode(Throwable e, int frameCount) {
int result = SEED;
if (e.getCause() != null) {
result = traceCode(e.getCause(), frameCount);
}
result = result * A_PRIME + e.getClass().getName().hashCode();
var stack = e.getStackTrace();
for (int i = 0; i < Math.min(stack.length, frameCount); i++) {
result = result * A_PRIME + stack[i].getClassName().hashCode();
result = result * A_PRIME + stack[i].getMethodName().hashCode();
}
return result;
}
private static int nonOverlappingFrames(StackTraceElement[] frames, StackTraceElement[] enclosingFrames) {
// Compute the number of elements in `frames` not contained in `enclosingFrames` by iterating backwards
// Result should usually be equal to the difference in size of both traces
var i = reverseStream(enclosingFrames).iterator();
return (int) reverseStream(frames).dropWhile(f -> i.hasNext() && i.next().equals(f)).count();
}
private static <T> Stream<T> reverseStream(T[] array) {
return IntStream.rangeClosed(1, array.length).mapToObj(i -> array[array.length - i]);
}
}
@@ -1,22 +1,47 @@
package org.cryptomator.ui.common;
import org.cryptomator.common.ErrorCode;
import org.cryptomator.common.Nullable;
import javax.inject.Inject;
import javax.inject.Named;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.stage.Stage;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class ErrorController implements FxController {
private static final String SEARCH_URL_FORMAT = "https://github.com/cryptomator/cryptomator/discussions/categories/errors?discussions_q=category:Errors+%s";
private static final String REPORT_URL_FORMAT = "https://github.com/cryptomator/cryptomator/discussions/new?category=Errors&title=Error+%s&body=%s";
private static final String SEARCH_ERRORCODE_DELIM = " OR ";
private static final String REPORT_BODY_TEMPLATE = """
<!-- ✏️ Please describe what happened as accurately as possible. -->
<!-- 📋 Please also copy and paste the detail text from the error window. -->
""";
private final Application application;
private final String stackTrace;
private final ErrorCode errorCode;
private final Scene previousScene;
private final Stage window;
private BooleanProperty copiedDetails = new SimpleBooleanProperty();
@Inject
ErrorController(@Named("stackTrace") String stackTrace, @Nullable Scene previousScene, Stage window) {
ErrorController(Application application, @Named("stackTrace") String stackTrace, ErrorCode errorCode, @Nullable Scene previousScene, Stage window) {
this.application = application;
this.stackTrace = stackTrace;
this.errorCode = errorCode;
this.previousScene = previousScene;
this.window = window;
}
@@ -33,6 +58,31 @@ public class ErrorController implements FxController {
window.close();
}
@FXML
public void searchError() {
var searchTerm = URLEncoder.encode(getErrorCode().replace(ErrorCode.DELIM, SEARCH_ERRORCODE_DELIM), StandardCharsets.UTF_8);
application.getHostServices().showDocument(SEARCH_URL_FORMAT.formatted(searchTerm));
}
@FXML
public void reportError() {
var title = URLEncoder.encode(getErrorCode(), StandardCharsets.UTF_8);
var body = URLEncoder.encode(REPORT_BODY_TEMPLATE, StandardCharsets.UTF_8);
application.getHostServices().showDocument(REPORT_URL_FORMAT.formatted(title, body));
}
@FXML
public void copyDetails() {
ClipboardContent clipboardContent = new ClipboardContent();
clipboardContent.putString(getDetailText());
Clipboard.getSystemClipboard().setContent(clipboardContent);
copiedDetails.set(true);
CompletableFuture.delayedExecutor(2, TimeUnit.SECONDS, Platform::runLater).execute(() -> {
copiedDetails.set(false);
});
}
/* Getter/Setter */
public boolean isPreviousScenePresent() {
@@ -42,4 +92,20 @@ public class ErrorController implements FxController {
public String getStackTrace() {
return stackTrace;
}
public String getErrorCode() {
return errorCode.toString();
}
public String getDetailText() {
return "```\nError Code " + getErrorCode() + "\n" + getStackTrace() + "\n```";
}
public BooleanProperty copiedDetailsProperty() {
return copiedDetails;
}
public boolean getCopiedDetails() {
return copiedDetails.get();
}
}
@@ -4,6 +4,7 @@ import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
import org.cryptomator.common.ErrorCode;
import javax.inject.Named;
import javax.inject.Provider;
@@ -31,6 +32,11 @@ abstract class ErrorModule {
return baos.toString(StandardCharsets.UTF_8);
}
@Provides
static ErrorCode provideErrorCode(Throwable cause) {
return ErrorCode.of(cause);
}
@Binds
@IntoMap
@FxControllerKey(ErrorController.class)
@@ -12,6 +12,7 @@ public enum FontAwesome5Icon {
CARET_RIGHT("\uF0Da"), //
CHECK("\uF00C"), //
CLOCK("\uF017"), //
CLIPBOARD("\uF328"), //
COG("\uF013"), //
COGS("\uF085"), //
COPY("\uF0C5"), //
+32 -3
View File
@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
<?import org.cryptomator.ui.controls.FormattedLabel?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ButtonBar?>
<?import javafx.scene.control.Hyperlink?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.shape.Circle?>
@@ -15,7 +18,7 @@
fx:controller="org.cryptomator.ui.common.ErrorController"
prefWidth="450"
prefHeight="450"
spacing="12"
spacing="18"
alignment="TOP_CENTER">
<padding>
<Insets topRightBottomLeft="24"/>
@@ -27,12 +30,38 @@
<FontAwesome5IconView styleClass="glyph-icon-white" glyph="EXCLAMATION" glyphSize="24"/>
</StackPane>
<VBox spacing="6" HBox.hgrow="ALWAYS">
<Label text="%generic.error.title" wrapText="true"/>
<FormattedLabel styleClass="label-large" format="%generic.error.title" arg1="${controller.errorCode}"/>
<Label text="%generic.error.instruction" wrapText="true"/>
<Hyperlink styleClass="hyperlink-underline" text="%generic.error.hyperlink.lookup" onAction="#searchError" contentDisplay="LEFT">
<graphic>
<FontAwesome5IconView glyph="LINK" glyphSize="12"/>
</graphic>
</Hyperlink>
<Hyperlink styleClass="hyperlink-underline" text="%generic.error.hyperlink.report" onAction="#reportError" contentDisplay="LEFT">
<graphic>
<FontAwesome5IconView glyph="LINK" glyphSize="12"/>
</graphic>
</Hyperlink>
</VBox>
</HBox>
<TextArea VBox.vgrow="ALWAYS" text="${controller.stackTrace}" prefRowCount="5" editable="false"/>
<VBox spacing="6" VBox.vgrow="ALWAYS">
<HBox>
<Label text="%generic.error.technicalDetails"/>
<Region HBox.hgrow="ALWAYS"/>
<Hyperlink styleClass="hyperlink-underline" text="%generic.button.copy" onAction="#copyDetails" contentDisplay="LEFT" visible="${!controller.copiedDetails}" managed="${!controller.copiedDetails}">
<graphic>
<FontAwesome5IconView glyph="CLIPBOARD" glyphSize="12"/>
</graphic>
</Hyperlink>
<Hyperlink styleClass="hyperlink-underline" text="%generic.button.copied" onAction="#copyDetails" contentDisplay="LEFT" visible="${controller.copiedDetails}" managed="${controller.copiedDetails}">
<graphic>
<FontAwesome5IconView glyph="CHECK" glyphSize="12"/>
</graphic>
</Hyperlink>
</HBox>
<TextArea VBox.vgrow="ALWAYS" text="${controller.detailText}" prefRowCount="5" editable="false"/>
</VBox>
<ButtonBar buttonMinWidth="120" buttonOrder="B+C">
<buttons>
+5 -2
View File
@@ -14,8 +14,11 @@ generic.button.done=Done
generic.button.next=Next
generic.button.print=Print
## Error
generic.error.title=An unexpected error occurred
generic.error.instruction=This should not have happened. Please report the error text below and include a description of what steps did lead to this error.
generic.error.title=Error %s
generic.error.instruction=Oops! Cryptomator didn't expect this to happen. You can look up existing solutions for this error. Or if it has not been reported yet, feel free to do so.
generic.error.hyperlink.lookup=Look up this error
generic.error.hyperlink.report=Report this error
generic.error.technicalDetails=Details:
# Defaults
defaults.vault.vaultName=Vault
@@ -0,0 +1,130 @@
package org.cryptomator.common;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
public class ErrorCodeTest {
private static ErrorCode codeCaughtFrom(RunnableThrowingException<RuntimeException> runnable) {
try {
runnable.run();
throw new IllegalStateException("should not reach this point");
} catch (RuntimeException e) {
return ErrorCode.of(e);
}
}
@Test
@DisplayName("same exception leads to same error code")
public void testDifferentErrorCodes() {
var code1 = codeCaughtFrom(this::throwNpe);
var code2 = codeCaughtFrom(this::throwNpe);
Assertions.assertEquals(code1.toString(), code2.toString());
}
private void throwNpe() {
throwException(new NullPointerException());
}
private void throwException(RuntimeException e) throws RuntimeException {
throw e;
}
@DisplayName("when different cause but same root cause")
@Nested
public class SameRootCauseDifferentCause {
private final ErrorCode code1 = codeCaughtFrom(this::foo);
private final ErrorCode code2 = codeCaughtFrom(this::bar);
private void foo() throws IllegalArgumentException {
try {
throwNpe();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e);
}
}
private void bar() throws IllegalStateException {
try {
throwNpe();
} catch (NullPointerException e) {
throw new IllegalStateException(e);
}
}
@Test
@DisplayName("error codes are different")
public void testDifferentCodes() {
Assertions.assertNotEquals(code1.toString(), code2.toString());
}
@Test
@DisplayName("throwableCodes are different")
public void testDifferentThrowableCodes() {
Assertions.assertNotEquals(code1.throwableCode(), code2.throwableCode());
}
@Test
@DisplayName("rootCauseCodes are equal")
public void testSameRootCauseCodes() {
Assertions.assertEquals(code1.rootCauseCode(), code2.rootCauseCode());
}
@Test
@DisplayName("methodCode are equal")
public void testSameMethodCodes() {
Assertions.assertEquals(code1.methodCode(), code2.methodCode());
}
}
@DisplayName("when same cause but different call stack")
@Nested
public class SameCauseDifferentCallStack {
private final ErrorCode code1 = codeCaughtFrom(this::foo);
private final ErrorCode code2 = codeCaughtFrom(this::bar);
private void foo() throws NullPointerException {
try {
throwNpe();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e);
}
}
private void bar() throws NullPointerException {
foo();
}
@Test
@DisplayName("error codes are different")
public void testDifferentCodes() {
Assertions.assertNotEquals(code1.toString(), code2.toString());
}
@Test
@DisplayName("throwableCodes are different")
public void testDifferentThrowableCodes() {
Assertions.assertNotEquals(code1.throwableCode(), code2.throwableCode());
}
@Test
@DisplayName("rootCauseCodes are equal")
public void testSameRootCauseCodes() {
Assertions.assertEquals(code1.rootCauseCode(), code2.rootCauseCode());
}
@Test
@DisplayName("methodCode are equal")
public void testSameMethodCodes() {
Assertions.assertEquals(code1.methodCode(), code2.methodCode());
}
}
}