This repository has been archived on 2021-12-05. You can view files and clone it, but cannot push or open issues or pull requests.
envoy/client/src/main/java/envoy/client/data/LocalDB.java

542 lines
15 KiB
Java

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 dev.kske.eventbus.core.*;
import dev.kske.eventbus.core.Event;
import envoy.data.*;
import envoy.data.Message.MessageStatus;
import envoy.event.*;
import envoy.event.contact.*;
import envoy.exception.EnvoyException;
import envoy.util.*;
import envoy.client.event.*;
/**
* Stores information about the current {@link User} and their {@link Chat}s. For message ID
* generation a {@link IDGenerator} is stored as well.
* <p>
* 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 {
// Data
private User user;
private Map<String, User> users = Collections.synchronizedMap(new HashMap<>());
private ObservableList<Chat> 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<String, User>) 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))) {
Chat.getTotalUnreadAmount().set(0);
chats = FXCollections.observableList((List<Chat>) 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(EnvoyCloseEvent.class)
@Priority(500)
private synchronized void save() {
// Stop saving if this account has been deleted
if (userFile == null)
return;
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);
}
}
/**
* Deletes any local remnant of this user.
*
* @since Envoy Client v0.3-beta
*/
public void delete() {
try {
// Save ID generator - can be used for other users in that db
if (hasIDGenerator())
SerializationUtils.write(idGeneratorFile, idGenerator);
} catch (final IOException e) {
EnvoyLog.getLogger(LocalDB.class).log(Level.SEVERE, "Unable to save local database: ",
e);
}
if (lastLoginFile != null)
lastLoginFile.delete();
userFile.delete();
users.remove(user.getName());
userFile = null;
onLogout();
}
@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.<GroupMessage>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(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(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));
}
@Event
@Priority(500)
private void onAccountDeletion(AccountDeletion deletion) {
if (user.getID() == deletion.get())
logger.log(Level.WARNING,
"I have been informed by the server that I have been deleted without even knowing it...");
getChat(deletion.get()).ifPresent(chat -> chat.setDisabled(true));
}
/**
* @return a {@code Map<String, User>} of all users stored locally with their user names as keys
* @since Envoy Client v0.2-alpha
*/
public Map<String, User> 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 <T extends Message> Optional<T> getMessage(long id) {
return (Optional<T>) 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<Chat> 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<Chat> 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; }
}