package envoy.client.data; import static java.util.function.Predicate.not; import java.io.*; import java.nio.channels.*; import java.nio.file.StandardOpenOption; import java.time.Instant; import java.util.*; import java.util.logging.*; import java.util.stream.Stream; import javafx.application.Platform; import javafx.collections.*; import envoy.client.event.*; import envoy.data.*; import envoy.data.Message.MessageStatus; import envoy.event.*; import envoy.event.contact.*; import envoy.exception.EnvoyException; import envoy.util.*; import dev.kske.eventbus.Event; import dev.kske.eventbus.EventBus; import dev.kske.eventbus.EventListener; /** * Stores information about the current {@link User} and their {@link Chat}s. * For message ID generation a {@link IDGenerator} is stored as well. *

* The managed objects are stored inside a folder in the local file system. * * @author Kai S. K. Engelbart * @since Envoy Client v0.3-alpha */ public final class LocalDB implements EventListener { // Data private User user; private Map users = Collections.synchronizedMap(new HashMap<>()); private ObservableList chats = FXCollections.observableArrayList(); private IDGenerator idGenerator; private CacheMap cacheMap = new CacheMap(); private String authToken; private boolean contactsChanged; // Auto save timer private Timer autoSaver; private boolean autoSaveRestart = true; // State management private Instant lastSync = Instant.EPOCH; // Persistence private File userFile; private FileLock instanceLock; private final File dbDir, idGeneratorFile, lastLoginFile, usersFile; private static final Logger logger = EnvoyLog.getLogger(LocalDB.class); /** * Constructs an empty local database. * * @param dbDir the directory in which to persist data * @throws IOException if {@code dbDir} is a file (and not a directory) * @throws EnvoyException if {@code dbDir} is in use by another Envoy instance * @since Envoy Client v0.2-beta */ public LocalDB(File dbDir) throws IOException, EnvoyException { this.dbDir = dbDir; EventBus.getInstance().registerListener(this); // Ensure that the database directory exists if (!dbDir.exists()) dbDir.mkdirs(); else if (!dbDir.isDirectory()) throw new IOException(String.format("LocalDBDir '%s' is not a directory!", dbDir.getAbsolutePath())); // Lock the directory lock(); // Initialize global files idGeneratorFile = new File(dbDir, "id_gen.db"); lastLoginFile = new File(dbDir, "last_login.db"); usersFile = new File(dbDir, "users.db"); // Load global files loadGlobalData(); // Initialize offline caches cacheMap.put(Message.class, new Cache<>()); cacheMap.put(MessageStatusChange.class, new Cache<>()); } /** * Ensured that only one Envoy instance is using this local database by creating * a lock file. * The lock file is deleted on application exit. * * @throws EnvoyException if the lock cannot by acquired * @since Envoy Client v0.2-beta */ private synchronized void lock() throws EnvoyException { final var file = new File(dbDir, "instance.lock"); try { final var fc = FileChannel.open(file.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE); instanceLock = fc.tryLock(); if (instanceLock == null) throw new EnvoyException("Another Envoy instance is using this local database!"); } catch (final IOException e) { throw new EnvoyException("Could not create lock file!", e); } } /** * Loads the local user registry {@code users.db}, the id generator * {@code id_gen.db} and last login file {@code last_login.db}. * * @since Envoy Client v0.2-beta */ private synchronized void loadGlobalData() { try { try (var in = new ObjectInputStream(new FileInputStream(usersFile))) { users = (Map) in.readObject(); } idGenerator = SerializationUtils.read(idGeneratorFile, IDGenerator.class); try (var in = new ObjectInputStream(new FileInputStream(lastLoginFile))) { user = (User) in.readObject(); authToken = (String) in.readObject(); } } catch (IOException | ClassNotFoundException e) {} } /** * Loads all data of the client user. * * @throws ClassNotFoundException if the loading process failed * @throws IOException if the loading process failed * @since Envoy Client v0.3-alpha */ public synchronized void loadUserData() throws ClassNotFoundException, IOException { if (user == null) throw new IllegalStateException("Client user is null, cannot initialize user storage"); userFile = new File(dbDir, user.getID() + ".db"); try (var in = new ObjectInputStream(new FileInputStream(userFile))) { chats = FXCollections.observableList((List) in.readObject()); // Some chats have changed and should not be overwritten by the saved values if (contactsChanged) { final var contacts = user.getContacts(); // Mark chats as disabled if a contact is no longer in this users contact list final var changedUserChats = chats.stream() .filter(not(chat -> contacts.contains(chat.getRecipient()))) .peek(chat -> { chat.setDisabled(true); logger.log(Level.INFO, String.format("Deleted chat with %s.", chat.getRecipient())); }); // Also update groups with a different member count final var changedGroupChats = contacts.stream().filter(Group.class::isInstance).flatMap(group -> { final var potentialChat = getChat(group.getID()); if (potentialChat.isEmpty()) return Stream.empty(); final var chat = potentialChat.get(); if (group.getContacts().size() != chat.getRecipient().getContacts().size()) { logger.log(Level.INFO, "Removed one (or more) members from " + group); return Stream.of(chat); } else return Stream.empty(); }); Stream.concat(changedUserChats, changedGroupChats).forEach(chat -> chats.set(chats.indexOf(chat), chat)); // loadUserData can get called two (or more?) times during application lifecycle contactsChanged = false; } cacheMap = (CacheMap) in.readObject(); lastSync = (Instant) in.readObject(); } finally { synchronize(); } } /** * Synchronizes the contact list of the client user with the chat and user * storage. * * @since Envoy Client v0.1-beta */ private void synchronize() { user.getContacts().stream().filter(u -> u instanceof User && !users.containsKey(u.getName())).forEach(u -> users.put(u.getName(), (User) u)); users.put(user.getName(), user); // Synchronize user status data for (final var contact : user.getContacts()) if (contact instanceof User) getChat(contact.getID()).ifPresent(chat -> { ((User) chat.getRecipient()).setStatus(((User) contact).getStatus()); }); // Create missing chats user.getContacts() .stream() .filter(c -> !c.equals(user) && getChat(c.getID()).isEmpty()) .map(c -> c instanceof User ? new Chat(c) : new GroupChat(user, (Group) c)) .forEach(chats::add); } /** * Initializes a timer that automatically saves this local database after a * period of time specified in the settings. * * @since Envoy Client v0.2-beta */ public void initAutoSave() { // A logout happened so the timer should be restarted if (autoSaveRestart) { autoSaver = new Timer("LocalDB Autosave", true); autoSaveRestart = false; } autoSaver.schedule(new TimerTask() { @Override public void run() { save(); } }, 2000, ClientConfig.getInstance().getLocalDBSaveInterval() * 60000); } /** * Stores all users. If the client user is specified, their chats will be stored * as well. The message id generator will also be saved if present. * * @throws IOException if the saving process failed * @since Envoy Client v0.3-alpha */ @Event(eventType = EnvoyCloseEvent.class, priority = 500) private synchronized void save() { EnvoyLog.getLogger(LocalDB.class).log(Level.FINER, "Saving local database..."); // Save users try { SerializationUtils.write(usersFile, users); // Save user data and last sync time stamp if (user != null) SerializationUtils .write(userFile, new ArrayList<>(chats), cacheMap, Context.getInstance().getClient().isOnline() ? Instant.now() : lastSync); // Save last login information if (authToken != null) SerializationUtils.write(lastLoginFile, user, authToken); // Save ID generator if (hasIDGenerator()) SerializationUtils.write(idGeneratorFile, idGenerator); } catch (final IOException e) { EnvoyLog.getLogger(LocalDB.class).log(Level.SEVERE, "Unable to save local database: ", e); } } @Event(priority = 500) private void onMessage(Message msg) { if (msg.getStatus() == MessageStatus.SENT) msg.nextStatus(); } @Event(priority = 500) private void onGroupMessage(GroupMessage msg) { // TODO: Cancel event once EventBus is updated if (msg.getStatus() == MessageStatus.WAITING || msg.getStatus() == MessageStatus.READ) logger.warning("The groupMessage has the unexpected status " + msg.getStatus()); } @Event(priority = 500) private void onMessageStatusChange(MessageStatusChange evt) { getMessage(evt.getID()).ifPresent(msg -> msg.setStatus(evt.get())); } @Event(priority = 500) private void onGroupMessageStatusChange(GroupMessageStatusChange evt) { this.getMessage(evt.getID()).ifPresent(msg -> msg.getMemberStatuses().replace(evt.getMemberID(), evt.get())); } @Event(priority = 500) private void onUserStatusChange(UserStatusChange evt) { getChat(evt.getID()).map(Chat::getRecipient).map(User.class::cast).ifPresent(u -> u.setStatus(evt.get())); } @Event(priority = 500) private void onUserOperation(UserOperation operation) { final var eventUser = operation.get(); switch (operation.getOperationType()) { case ADD: Platform.runLater(() -> chats.add(0, new Chat(eventUser))); break; case REMOVE: getChat(eventUser.getID()).ifPresent(chat -> chat.setDisabled(true)); break; } } @Event private void onGroupCreationResult(GroupCreationResult evt) { final var newGroup = evt.get(); // The group creation was not successful if (newGroup == null) return; // The group was successfully created else Platform.runLater(() -> chats.add(new GroupChat(user, newGroup))); } @Event(priority = 500) private void onGroupResize(GroupResize evt) { getChat(evt.getGroupID()).map(Chat::getRecipient).map(Group.class::cast).ifPresent(evt::apply); } @Event(priority = 500) private void onNameChange(NameChange evt) { chats.stream().map(Chat::getRecipient).filter(c -> c.getID() == evt.getID()).findAny().ifPresent(c -> c.setName(evt.get())); } /** * Stores a new authentication token. * * @param evt the event containing the authentication token * @since Envoy Client v0.2-beta */ @Event private void onNewAuthToken(NewAuthToken evt) { authToken = evt.get(); } /** * Deletes all associations to the current user. * * @since Envoy Client v0.2-beta */ @Event(eventType = Logout.class, priority = 50) private void onLogout() { autoSaver.cancel(); autoSaveRestart = true; lastLoginFile.delete(); userFile = null; user = null; authToken = null; chats.clear(); lastSync = Instant.EPOCH; cacheMap.clear(); } /** * Deletes the message with the given ID, if present. * * @param message the event that was * @since Envoy Client v0.3-beta */ @Event private void onMessageDeletion(MessageDeletion message) { Platform.runLater(() -> { // We suppose that messages have unique IDs, hence the search can be stopped // once a message was removed final var messageID = message.get(); for (final var chat : chats) if (chat.remove(messageID)) break; }); } @Event(priority = 500) private void onOwnStatusChange(OwnStatusChange statusChange) { user.setStatus(statusChange.get()); } @Event(eventType = ContactsChangedSinceLastLogin.class, priority = 500) private void onContactsChangedSinceLastLogin() { contactsChanged = true; } @Event(priority = 500) private void onContactDisabled(ContactDisabled event) { getChat(event.get().getID()).ifPresent(chat -> chat.setDisabled(true)); } /** * @return a {@code Map} of all users stored locally with their * user names as keys * @since Envoy Client v0.2-alpha */ public Map getUsers() { return users; } /** * Searches for a message by ID. * * @param id the ID of the message to search for * @return an optional containing the message * @since Envoy Client v0.1-beta */ public Optional getMessage(long id) { return (Optional) chats.stream().map(Chat::getMessages).flatMap(List::stream).filter(m -> m.getID() == id).findAny(); } /** * Searches for a chat by recipient ID. * * @param recipientID the ID of the chat's recipient * @return an optional containing the chat * @since Envoy Client v0.1-beta */ public Optional getChat(long recipientID) { return chats.stream().filter(c -> c.getRecipient().getID() == recipientID).findAny(); } /** * @return all saved {@link Chat} objects that list the client user as the * sender * @since Envoy Client v0.1-alpha **/ public ObservableList getChats() { return chats; } /** * @return the {@link User} who initialized the local database * @since Envoy Client v0.2-alpha */ public User getUser() { return user; } /** * @param user the user to set * @since Envoy Client v0.2-alpha */ public void setUser(User user) { this.user = user; } /** * @return the message ID generator * @since Envoy Client v0.3-alpha */ public IDGenerator getIDGenerator() { return idGenerator; } /** * @param idGenerator the message ID generator to set * @since Envoy Client v0.3-alpha */ @Event(priority = 150) public void setIDGenerator(IDGenerator idGenerator) { this.idGenerator = idGenerator; } /** * @return {@code true} if an {@link IDGenerator} is present * @since Envoy Client v0.3-alpha */ public boolean hasIDGenerator() { return idGenerator != null; } /** * @return the cache map for messages and message status changes * @since Envoy Client v0.1-beta */ public CacheMap getCacheMap() { return cacheMap; } /** * @return the time stamp when the database was last saved * @since Envoy Client v0.2-beta */ public Instant getLastSync() { return lastSync; } /** * @return the authentication token of the user * @since Envoy Client v0.2-beta */ public String getAuthToken() { return authToken; } }