Lesson 16 - Java Chat - Client - View Local Servers
In the previous lesson, Java Chat - Client - Introducing the Application Structure, we learned about the client's app structure.
Today we're going to implement the server administration window. Therefore, we'll display the servers found in the local network.
Modifying the
LanServerFinder
Class
Before we start implementing client parts, we must first modify the
OnServerFoundListener
attribute interface in the
LanServerFinder
class a bit. We'll add an
InetAddress address
parameter to the onServerFound()
method, which will contain the address of the server where the datagram came
from:
void onServerFound(ServerStatusData data, InetAddress address);
Next, we need to fix the calling of this method:
serverFoundListener.onServerFound(statusData, datagramPacket.getAddress());
We get the source address by calling the getAddress()
method on
the received datagram.
Displaying Local Servers
The server display window is already created. We'll show all found servers in the listView. We'll display three server information:
- server name
- server address and port
- server occupancy
Server Entry Model
We'll create a ServerEntry
class and put it in the
model
package. This class will contain all the information
above:
package cz.stechy.chat.model; public class ServerEntry { private final UUID serverID; private final InetAddress serverAddress; private final StringProperty serverName = new SimpleStringProperty(this, "serverName", null); private final IntegerProperty connectedClients = new SimpleIntegerProperty(this, "connectedClients", 0); private final IntegerProperty maxClients = new SimpleIntegerProperty(this, "maxClients", Integer.MAX_VALUE); private final ObjectProperty<ServerStatus> serverStatus = new SimpleObjectProperty<>(this, "serverStatus", ServerStatus.EMPTY); private final BooleanProperty connected = new SimpleBooleanProperty(this, "connected", false); private final IntegerProperty port = new SimpleIntegerProperty(this, "port", 0); private final AtomicLong lastUpdate = new AtomicLong(); public ServerEntry(ServerStatusData serverStatusData, InetAddress serverAddress) { this.serverID = serverStatusData.serverID; this.serverAddress = serverAddress; this.serverName.set(serverStatusData.serverName); this.connectedClients.set(serverStatusData.clientCount); this.maxClients.set(serverStatusData.maxClients); this.serverStatus.set(serverStatusData.serverStatus); this.port.set(serverStatusData.port); this.lastUpdate.set(System.currentTimeMillis()); } public void update(ServerStatusData newServerStatusData) { this.serverName.set(newServerStatusData.serverName); this.connectedClients.set(newServerStatusData.clientCount); this.maxClients.set(newServerStatusData.maxClients); this.serverStatus.set(newServerStatusData.serverStatus); this.port.set(newServerStatusData.port); this.lastUpdate.set(System.currentTimeMillis()); } public boolean hasOldData() { final long time = System.currentTimeMillis(); return time - lastUpdate.get() > 3000; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } ServerEntry that = (ServerEntry) o; return Objects.equals(serverID, that.serverID); } @Override public int hashCode() { return Objects.hash(serverID); }
The class contains the same variables as the ServerStatusData
class + a lastUpdate
variable, which contains information about the
most recent entry update. The update()
method updates the
information. The hasOldData()
method tells us whether or not the
current instance contains old data. If the data is out of date, we'll remove the
entry from the list of found servers (we haven't done this yet). It was
necessary to override the equals()
and hashCode()
to
make the HashMap search work correctly.
Server Entry Cell
To be able to show a custom ListView
item, we have to create it
first. I've already designed a graphical representation of the item. It's
located in the connect/server_entry.fxml
file. We'll now create a
"controller" to control this item. We'll create a new widget
package and place it next to the model
packages. In the newly
created package, we'll create a ServerEntryCell
class to represent
a listView item. The class will inherit from the generic
ListCell<ServerEntry>
class and will be typed to the
ServerEntry
model class:
package cz.stechy.chat.widget; public class ServerEntryCell extends ListCell<ServerEntry> { private static final String FXML_PATH = "/fxml/connect/server_entry.fxml"; private static final String ADDRESS_PORT_FORMAT = "%s:%d"; @FXML private Label lblName; @FXML private Label lblClients; @FXML private Label lblAddress; private Parent container; public ServerEntryCell() { final FXMLLoader loader = new FXMLLoader(getClass().getResource(FXML_PATH)); loader.setController(this); try { container = loader.load(); } catch (IOException e) { e.printStackTrace(); } } @Override protected void updateItem(ServerEntry item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); setGraphic(null); lblName.textProperty().unbind(); lblClients.textProperty().unbind(); } else { lblName.textProperty().bind(item.serverNameProperty()); lblClients.textProperty().bind(item.clientsProperty()); lblAddress.textProperty().set(String.format(ADDRESS_PORT_FORMAT, item.getServerAddress().getHostAddress(), item.getPort())); setGraphic(container); } } }
The FXML_PATH
class constant contains the path to the view file.
We use the FXMLLoader
to load the file in the class constructor and
bind the view with our class using the setController()
method.
Calling this method is very important because variables annotated with the
@FXML
annotation wouldn't be initialized otherwise. Finally, we
override the updateItem()
method, which is called whenever a
listView item needs to be updated. In the method, we must first call
updateItem()
on the parent to set the parent variables correctly.
Our code follows. If the item contains no entry, we clear the text and graphics
and remove the bindings to the name and number of clients. If the item contains
an entry, we bind all the information to the corresponding components. Using the
setGraphic()
method, we display our components in a single listView
item.
Local Server Management Service
We'll create a class to manage servers found in the local network. Next to
the widget
and controller
packages, we'll create a new
package named service
, in which we'll define a new
LocalServerService
class:
public final class LocalServerService implements OnServerFoundListener { private static final String BROADCAST_ADDRESS = "224.0.2.60"; private static final int BROADCAST_PORT = 56489; // map of all found servers private final ObservableMap<UUID, ServerEntry> serverMap = FXCollections.observableMap(new HashMap<>()); private LanServerFinder serverFinder; public LocalServerService() { try { this.serverFinder = new LanServerFinder(InetAddress.getByName(BROADCAST_ADDRESS), BROADCAST_PORT); this.serverFinder.setServerFoundListener(this); ThreadPool.COMMON_EXECUTOR.submit(this.serverFinder); } catch (IOException e) { e.printStackTrace(); } } @Override public void onServerFound(ServerStatusData data, InetAddress address) { ThreadPool.JAVAFX_EXECUTOR.execute(() -> { final UUID serverID = data.serverID; if (serverMap.containsKey(serverID)) { serverMap.get(serverID).update(data); } else { serverMap.put(serverID, new ServerEntry(data, address)); } }); } public ObservableMap<UUID, ServerEntry> getServerMap() { return FXCollections.unmodifiableObservableMap(serverMap); } public void stop() { serverFinder.shutdown(); }
The class implements the OnServerFoundListener
interface, which
is located in the LanServerFinder
class. We'll use this interface
to add servers we found to serverMap
. The class also contains the
BROADCAST_ADDRESS
and BROADCAST_PORT
constants whose
values must match those on the server. serverMap
is of the
ObservableMap
type, which allows us to update the resulting map
list in the GUI automatically. In the constructor, we create a new
LanServerFinder
class instance and set the listener. The class also
has a getter for the observable map. Note the call to the
unmodifiableObservableMap()
method to ensure that the map obtained
by the getter cannot be changed from the outside. With the stop()
method, we stop LanServerFinder
. The
OnServerFoundListener
interface requires us to implement the
onServerFound()
method, which is called each time we find a new
server. In the method, we check if the map already contains the entry. If it's
there, we update the information, otherwise a new entry is added. This must
happen in the JavaFX thread, because if we changed the map in
another thread, it'd throw an exception with the description: Not on FX
application thread. Later, we'll wire the server map with a graphical
component and it can only be changed in the JavaFX thread.
Wiring the Service With The Controller
Finally, we have all the components ready. Now we'll wire them all together.
We'll edit the ConnectController
class. Let's begin by adding the
Initializable
and OnCloseListener
interfaces, whose
methods we'll implement in a moment.
We'll set the ServerEntry
data type to the
lvServers
variable:
@FXML private ListView<ServerEntry> lvServers;
Next, we'll create a new serverService
instance constant:
private final LocalServerService serverService = new LocalServerService();
In the initialize()
method, we'll set the factory for graphical
components to servers and set the listener to the server map from our
service:
public void initialize(URL url, ResourceBundle resourceBundle) { lvServers.setCellFactory(param -> new ServerEntryCell()); serverService.getServerMap().addListener(serverMapListener); }
The serverMapListener
variable will contain a converter from the
map to to a list:
private MapChangeListener<? super UUID, ? super ServerEntry> serverMapListener = change -> { if (change.wasAdded()) { lvServers.getItems().addAll(change.getValueAdded()); } if (change.wasRemoved()) { lvServers.getItems().removeAll(change.getValueRemoved()); } };
Finally, we'll implement the onClose()
method, which is called
when the window is closed:
public void onClose() { serverService.stop(); }
Display the Server Selection Window
In the MainController
we'll add a menu button handler in the
handleConnect()
method to display the server management window:
@FXML private void handleConnect(ActionEvent actionEvent) { try { showNewWindow("connect/connect", "Connect to server..."); } catch (IOException e) { e.printStackTrace(); } }
We use the showNewWindow()
utility method which accepts the path
to the fxml document and the window name as parameters.
If you did everything correctly, after starting the server, client, and displaying the server selection window, you should see the following window with one server found:
In the next lesson, Java Chat - Client - Server Connection Part 1, we'll create a class that will keep in touch with the server and finally log in to the server.