diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs index 5e625c6..65c71af 100644 --- a/.settings/org.eclipse.jdt.core.prefs +++ b/.settings/org.eclipse.jdt.core.prefs @@ -245,7 +245,7 @@ org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type=insert org.eclipse.jdt.core.formatter.insert_new_line_after_label=insert org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=do not insert +org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=insert org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert @@ -439,7 +439,7 @@ org.eclipse.jdt.core.formatter.keep_code_block_on_one_line=one_line_if_empty org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=true org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false org.eclipse.jdt.core.formatter.keep_enum_constant_declaration_on_one_line=one_line_never -org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line=one_line_never +org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line=one_line_if_empty org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line=one_line_if_single_item org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=false org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line=one_line_always @@ -450,7 +450,7 @@ org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line=false org.eclipse.jdt.core.formatter.keep_simple_getter_setter_on_one_line=true org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line=false org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=true -org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line=one_line_never +org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line=one_line_if_empty org.eclipse.jdt.core.formatter.lineSplit=150 org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=false org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=false diff --git a/src/main/java/envoy/client/data/audio/AudioPlayer.java b/src/main/java/envoy/client/data/audio/AudioPlayer.java new file mode 100644 index 0000000..61982db --- /dev/null +++ b/src/main/java/envoy/client/data/audio/AudioPlayer.java @@ -0,0 +1,64 @@ +package envoy.client.data.audio; + +import javax.sound.sampled.*; + +import envoy.exception.EnvoyException; + +/** + * Plays back audio from a byte array. + *

+ * Project: envoy-client
+ * File: AudioPlayer.java
+ * Created: 05.07.2020
+ * + * @author Kai S. K. Engelbart + * @since Envoy Client v0.1-beta + */ +public final class AudioPlayer { + + private final AudioFormat format; + private final DataLine.Info info; + + private Clip clip; + + /** + * Initializes the player with the default audio format. + * + * @since Envoy Client v0.1-beta + */ + public AudioPlayer() { this(AudioRecorder.DEFAULT_AUDIO_FORMAT); } + + /** + * Initializes the player with a given audio format. + * + * @param format the audio format to use + * @since Envoy Client v0.1-beta + */ + public AudioPlayer(AudioFormat format) { + this.format = format; + info = new DataLine.Info(Clip.class, format); + } + + /** + * @return {@code true} if audio play back is supported + * @since Envoy Client v0.1-beta + */ + public boolean isSupported() { return AudioSystem.isLineSupported(info); } + + /** + * Plays back an audio clip. + * + * @param data the data of the clip + * @throws EnvoyException if the play back failed + * @since Envoy Client v0.1-beta + */ + public void play(byte[] data) throws EnvoyException { + try { + clip = (Clip) AudioSystem.getLine(info); + clip.open(format, data, 0, data.length); + clip.start(); + } catch (final LineUnavailableException e) { + throw new EnvoyException("Cannot play back audio", e); + } + } +} diff --git a/src/main/java/envoy/client/data/audio/AudioRecorder.java b/src/main/java/envoy/client/data/audio/AudioRecorder.java new file mode 100644 index 0000000..85dafae --- /dev/null +++ b/src/main/java/envoy/client/data/audio/AudioRecorder.java @@ -0,0 +1,122 @@ +package envoy.client.data.audio; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import javax.sound.sampled.*; + +import envoy.exception.EnvoyException; + +/** + * Records audio and exports it as a byte array. + *

+ * Project: envoy-client
+ * File: AudioRecorder.java
+ * Created: 02.07.2020
+ * + * @author Kai S. K. Engelbart + * @since Envoy Client v0.1-beta + */ +public final class AudioRecorder { + + /** + * The default audio format used for recording and play back. + * + * @since Envoy Client v0.1-beta + */ + public static final AudioFormat DEFAULT_AUDIO_FORMAT = new AudioFormat(16000, 16, 1, true, false); + + private final AudioFormat format; + private final DataLine.Info info; + + private TargetDataLine line; + private Path tempFile; + + /** + * Initializes the recorder with the default audio format. + * + * @since Envoy Client v0.1-beta + */ + public AudioRecorder() { this(DEFAULT_AUDIO_FORMAT); } + + /** + * Initializes the recorder with a given audio format. + * + * @param format the audio format to use + * @since Envoy Client v0.1-beta + */ + public AudioRecorder(AudioFormat format) { + this.format = format; + info = new DataLine.Info(TargetDataLine.class, format); + } + + /** + * @return {@code true} if audio recording is supported + * @since Envoy Client v0.1-beta + */ + public boolean isSupported() { return AudioSystem.isLineSupported(info); } + + /** + * @return {@code true} if the recorder is active + * @since Envoy Client v0.1-beta + */ + public boolean isRecording() { return line != null && line.isActive(); } + + /** + * Starts the audio recording. + * + * @throws EnvoyException if starting the recording failed + * @since Envoy Client v0.1-beta + */ + public void start() throws EnvoyException { + try { + + // Open the line + line = (TargetDataLine) AudioSystem.getLine(info); + line.open(format); + line.start(); + + // Prepare temp file + tempFile = Files.createTempFile("recording", "wav"); + + // Start the recording + final var ais = new AudioInputStream(line); + AudioSystem.write(ais, AudioFileFormat.Type.WAVE, tempFile.toFile()); + } catch (IOException | LineUnavailableException e) { + throw new EnvoyException("Cannot record voice", e); + } + } + + /** + * Stops the recording. + * + * @return the finished recording + * @throws EnvoyException if finishing the recording failed + * @since Envoy Client v0.1-beta + */ + public byte[] finish() throws EnvoyException { + try { + line.stop(); + line.close(); + final byte[] data = Files.readAllBytes(tempFile); + Files.delete(tempFile); + return data; + } catch (final IOException e) { + throw new EnvoyException("Cannot save voice recording", e); + } + } + + /** + * Cancels the active recording. + * + * @since Envoy Client v0.1-beta + */ + public void cancel() { + line.stop(); + line.close(); + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) {} + } +} diff --git a/src/main/java/envoy/client/data/audio/package-info.java b/src/main/java/envoy/client/data/audio/package-info.java new file mode 100644 index 0000000..3a172be --- /dev/null +++ b/src/main/java/envoy/client/data/audio/package-info.java @@ -0,0 +1,11 @@ +/** + * Contains classes related to recording and playing back audio clips. + *

+ * Project: envoy-client
+ * File: package-info.java
+ * Created: 05.07.2020
+ * + * @author Kai S. K. Engelbart + * @since Envoy Client v0.1-beta + */ +package envoy.client.data.audio; diff --git a/src/main/java/envoy/client/net/Receiver.java b/src/main/java/envoy/client/net/Receiver.java index dbcca32..f90d10b 100644 --- a/src/main/java/envoy/client/net/Receiver.java +++ b/src/main/java/envoy/client/net/Receiver.java @@ -51,16 +51,25 @@ public class Receiver extends Thread { @Override public void run() { - try { - while (true) { + while (true) { + try { // Read object length final byte[] lenBytes = new byte[4]; in.read(lenBytes); final int len = SerializationUtils.bytesToInt(lenBytes, 0); + logger.log(Level.FINEST, "Expecting object of length " + len + "."); // Read object into byte array - final byte[] objBytes = new byte[len]; - in.read(objBytes); + final byte[] objBytes = new byte[len]; + final int bytesRead = in.read(objBytes); + logger.log(Level.FINEST, "Read " + bytesRead + " bytes."); + + // Catch LV encoding errors + if (len != bytesRead) { + logger.log(Level.WARNING, + String.format("LV encoding violated: expected %d bytes, received %d bytes. Discarding object...", len, bytesRead)); + continue; + } try (ObjectInputStream oin = new ObjectInputStream(new ByteArrayInputStream(objBytes))) { final Object obj = oin.readObject(); @@ -75,11 +84,12 @@ public class Receiver extends Thread { obj.getClass())); else processor.accept(obj); } + } catch (final SocketException e) { + // Connection probably closed by client. + return; + } catch (final Exception e) { + logger.log(Level.SEVERE, "Error on receiver thread", e); } - } catch (final SocketException e) { - // Connection probably closed by client. - } catch (final Exception e) { - logger.log(Level.SEVERE, "Error on receiver thread", e); } } diff --git a/src/main/java/envoy/client/ui/AudioControl.java b/src/main/java/envoy/client/ui/AudioControl.java new file mode 100644 index 0000000..d82fd24 --- /dev/null +++ b/src/main/java/envoy/client/ui/AudioControl.java @@ -0,0 +1,49 @@ +package envoy.client.ui; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.Button; +import javafx.scene.layout.HBox; + +import envoy.client.data.audio.AudioPlayer; +import envoy.exception.EnvoyException; +import envoy.util.EnvoyLog; + +/** + * Enables the play back of audio clips through a button. + *

+ * Project: envoy-client
+ * File: AudioControl.java
+ * Created: 05.07.2020
+ * + * @author Kai S. K. Engelbart + * @since Envoy Client v0.1-beta + */ +public final class AudioControl extends HBox { + + private AudioPlayer player = new AudioPlayer(); + + private static final Logger logger = EnvoyLog.getLogger(AudioControl.class); + + /** + * Initializes the audio control. + * + * @param audioData the audio data to play. + * @since Envoy Client v0.1-beta + */ + public AudioControl(byte[] audioData) { + var button = new Button("Play"); + button.setOnAction(e -> { + try { + player.play(audioData); + } catch (EnvoyException ex) { + logger.log(Level.SEVERE, "Could not play back audio: ", ex); + new Alert(AlertType.ERROR, "Could not play back audio").showAndWait(); + } + }); + getChildren().add(button); + } +} diff --git a/src/main/java/envoy/client/ui/controller/ChatScene.java b/src/main/java/envoy/client/ui/controller/ChatScene.java index 68449b4..d327039 100644 --- a/src/main/java/envoy/client/ui/controller/ChatScene.java +++ b/src/main/java/envoy/client/ui/controller/ChatScene.java @@ -18,6 +18,7 @@ import javafx.scene.input.KeyEvent; import javafx.scene.paint.Color; import envoy.client.data.*; +import envoy.client.data.audio.AudioRecorder; import envoy.client.event.MessageCreationEvent; import envoy.client.net.Client; import envoy.client.net.WriteProxy; @@ -29,7 +30,9 @@ import envoy.client.ui.listcell.MessageControl; import envoy.client.ui.listcell.MessageListCellFactory; import envoy.data.*; import envoy.event.*; +import envoy.data.Attachment.AttachmentType; import envoy.event.contact.ContactOperation; +import envoy.exception.EnvoyException; import envoy.util.EnvoyLog; /** @@ -54,6 +57,9 @@ public final class ChatScene implements Restorable { @FXML private Button postButton; + @FXML + private Button voiceButton; + @FXML private Button settingsButton; @@ -74,9 +80,11 @@ public final class ChatScene implements Restorable { private WriteProxy writeProxy; private SceneContext sceneContext; - private boolean postingPermanentlyDisabled = false; - - private Chat currentChat; + private Chat currentChat; + private AudioRecorder recorder; + private boolean recording; + private Attachment pendingAttachment; + private boolean postingPermanentlyDisabled = false; private static final Settings settings = Settings.getInstance(); private static final EventBus eventBus = EventBus.getInstance(); @@ -173,6 +181,8 @@ public final class ChatScene implements Restorable { contactLabel.setText(localDB.getUser().getName()); MessageControl.setUser(localDB.getUser()); if (!client.isOnline()) updateInfoLabel("You are offline", "infoLabel-info"); + + recorder = new AudioRecorder(); } @Override @@ -206,11 +216,20 @@ public final class ChatScene implements Restorable { logger.log(Level.WARNING, "Could not read current chat.", e); } + // Discard the pending attachment + if (recorder.isRecording()) { + recorder.cancel(); + recording = false; + voiceButton.setText("Record Voice Message"); + } + pendingAttachment = null; + remainingChars.setVisible(true); remainingChars .setText(String.format("remaining chars: %d/%d", MAX_MESSAGE_LENGTH - messageTextArea.getText().length(), MAX_MESSAGE_LENGTH)); } messageTextArea.setDisable(currentChat == null || postingPermanentlyDisabled); + voiceButton.setDisable(!recorder.isSupported()); } /** @@ -235,6 +254,26 @@ public final class ChatScene implements Restorable { sceneContext.getController().initializeData(sceneContext, localDB); } + @FXML + private void voiceButtonClicked() { + new Thread(() -> { + try { + if (!recording) { + recording = true; + Platform.runLater(() -> voiceButton.setText("Recording...")); + recorder.start(); + } else { + pendingAttachment = new Attachment(recorder.finish(), AttachmentType.VOICE); + recording = false; + Platform.runLater(() -> { voiceButton.setText("Record Voice Message"); checkPostConditions(false); }); + } + } catch (EnvoyException e) { + logger.log(Level.SEVERE, "Could not record audio: ", e); + Platform.runLater(new Alert(AlertType.ERROR, "Could not record audio")::showAndWait); + } + }).start(); + } + /** * Checks the text length of the {@code messageTextArea}, adjusts the * {@code remainingChars} label and checks whether to send the message @@ -257,11 +296,14 @@ public final class ChatScene implements Restorable { */ @FXML private void checkPostConditions(KeyEvent e) { + checkPostConditions(settings.isEnterToSend() && e.getCode() == KeyCode.ENTER + || !settings.isEnterToSend() && e.getCode() == KeyCode.ENTER && e.isControlDown()); + } + + private void checkPostConditions(boolean sendKeyPressed) { if (!postingPermanentlyDisabled) { - if (!postButton.isDisabled() && (settings.isEnterToSend() && e.getCode() == KeyCode.ENTER - || !settings.isEnterToSend() && e.getCode() == KeyCode.ENTER && e.isControlDown())) - postMessage(); - postButton.setDisable(messageTextArea.getText().isBlank() || currentChat == null); + if (!postButton.isDisabled() && sendKeyPressed) postMessage(); + postButton.setDisable((messageTextArea.getText().isBlank() && pendingAttachment == null) || currentChat == null); } else { final var noMoreMessaging = "Go online to send messages"; if (!infoLabel.getText().equals(noMoreMessaging)) @@ -317,11 +359,16 @@ public final class ChatScene implements Restorable { return; } final var text = messageTextArea.getText().strip(); - if (text.isBlank()) throw new IllegalArgumentException("A message without visible text can not be sent."); try { - // Create and send message + // Creating the message and its metadata final var builder = new MessageBuilder(localDB.getUser().getID(), currentChat.getRecipient().getID(), localDB.getIDGenerator()) .setText(text); + // Setting an attachment, if present + if (pendingAttachment != null) { + builder.setAttachment(pendingAttachment); + pendingAttachment = null; + } + // Building the final message final var message = currentChat.getRecipient() instanceof Group ? builder.buildGroupMessage((Group) currentChat.getRecipient()) : builder.build(); diff --git a/src/main/java/envoy/client/ui/listcell/MessageControl.java b/src/main/java/envoy/client/ui/listcell/MessageControl.java index a2632e8..39d590e 100644 --- a/src/main/java/envoy/client/ui/listcell/MessageControl.java +++ b/src/main/java/envoy/client/ui/listcell/MessageControl.java @@ -9,6 +9,7 @@ import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.VBox; +import envoy.client.ui.AudioControl; import envoy.client.ui.IconUtil; import envoy.data.Message; import envoy.data.Message.MessageStatus; @@ -38,6 +39,20 @@ public class MessageControl extends VBox { public MessageControl(Message message) { // Creating the underlying VBox, the dateLabel and the textLabel super(new Label(dateFormat.format(message.getCreationDate()))); + + // Handling message attachment display + if (message.hasAttachment()) switch (message.getAttachment().getType()) { + case PICTURE: + break; + case VIDEO: + break; + case VOICE: + getChildren().add(new AudioControl(message.getAttachment().getData())); + break; + case DOCUMENT: + break; + } + final var textLabel = new Label(message.getText()); textLabel.setWrapText(true); getChildren().add(textLabel); diff --git a/src/main/resources/css/base.css b/src/main/resources/css/base.css index 9230b0f..2830bc6 100644 --- a/src/main/resources/css/base.css +++ b/src/main/resources/css/base.css @@ -1,4 +1,4 @@ -.button, .list-cell { +.button, .list-cell, .progress-bar * { -fx-background-radius: 5.0em; } @@ -26,6 +26,10 @@ -fx-text-fill: transparent; } +.progress-bar{ + -fx-progress-color: blue; +} + .online { -fx-text-fill: limegreen; } diff --git a/src/main/resources/fxml/ChatScene.fxml b/src/main/resources/fxml/ChatScene.fxml index 8ed1185..3239307 100644 --- a/src/main/resources/fxml/ChatScene.fxml +++ b/src/main/resources/fxml/ChatScene.fxml @@ -2,6 +2,7 @@ + @@ -18,29 +19,24 @@ xmlns:fx="http://javafx.com/fxml/1" fx:controller="envoy.client.ui.controller.ChatScene"> - - - + + + + - - - - + minHeight="50.0" prefHeight="155.14286150251115" vgrow="ALWAYS" /> + + + -