package io.mrarm.irc; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; import android.os.StatFs; import androidx.annotation.NonNull; import java.io.File; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.Executor; import io.mrarm.irc.config.AppSettings; import io.mrarm.irc.config.ServerConfigData; import io.mrarm.irc.config.ServerConfigManager; import io.mrarm.irc.config.SettingChangeCallback; import io.mrarm.irc.config.SettingsHelper; import io.mrarm.irc.util.PoolSerialExecutor; public class ChatLogStorageManager implements ServerConfigManager.ConnectionsListener { private static final int MIN_GLOBAL_MESSAGES_UPDATE = 1024; private static final int MIN_SERVER_MESSAGES_UPDATE = 128; private static final int GLOBAL_DELETION_CANDIDATES = 1024; private static final int CONNECTION_DELETION_CANDIDATES = 32; private static final SimpleDateFormat sFileNameFormat = new SimpleDateFormat("'messages-'yyyy-MM-dd'.db'", Locale.getDefault()); private static ChatLogStorageManager sInstance; public static ChatLogStorageManager getInstance(Context context) { if (sInstance == null) sInstance = new ChatLogStorageManager(context); return sInstance; } private ServerConnectionManager mConnectionManager; private ServerConfigManager mServerConfigManager; private long mBlockSize = 0L; private Map<UUID, ServerManager> mServerManagers = new HashMap<>(); private int mGlobalMessageCounter = 0; private TreeSet<DeletionCandidate> mGlobalDeletionCandidates = new TreeSet<>(); private long mGlobalTotalSize = 0L; private long mGlobalLimit; private long mDefaultServerLimit; private Executor mExecutor; public ChatLogStorageManager(Context context) { mConnectionManager = ServerConnectionManager.getInstance(context); mServerConfigManager = ServerConfigManager.getInstance(context); SettingsHelper.registerCallbacks(this); onSettingChanged(); mExecutor = new PoolSerialExecutor(); mServerConfigManager.addListener(this); List<ServerConfigData> servers = mServerConfigManager.getServers(); mExecutor.execute(() -> { for (ServerConfigData data : servers) mServerManagers.put(data.uuid, new ServerManager(data)); performUpdate(null); }); } @SettingChangeCallback(keys = { AppSettings.PREF_STORAGE_LIMIT_GLOBAL, AppSettings.PREF_STORAGE_LIMIT_SERVER }) private void onSettingChanged() { mGlobalLimit = AppSettings.getStorageLimitGlobal(); mDefaultServerLimit = AppSettings.getStorageLimitServer(); } public void requestUpdate(UUID serverUUID) { mExecutor.execute(() -> performUpdate(serverUUID)); } public void requestUpdate(UUID serverUUID, Runnable callback) { mExecutor.execute(() -> { performUpdate(serverUUID); callback.run(); }); } private void performUpdate(UUID serverUUID) { Calendar calendar = Calendar.getInstance(); int currentYear = calendar.get(Calendar.YEAR); int currentMonth = calendar.get(Calendar.MONTH); int currentDay = calendar.get(Calendar.DAY_OF_MONTH); if (serverUUID == null) { for (ServerManager manager : mServerManagers.values()) manager.update(currentYear, currentMonth, currentDay); } else { ServerManager manager = mServerManagers.get(serverUUID); if (manager != null) manager.update(currentYear, currentMonth, currentDay); } if (mGlobalLimit != -1L && mGlobalTotalSize > mGlobalLimit) performGlobalDeletion(mGlobalTotalSize - mGlobalLimit); } private void performGlobalDeletion(long size) { while (true) { boolean deletedAnyFile = false; for (Iterator<DeletionCandidate> iterator = mGlobalDeletionCandidates.iterator(); iterator.hasNext(); ) { DeletionCandidate candidate = iterator.next(); mGlobalTotalSize -= candidate.size; File file = new File(candidate.server.mLogsDir, sFileNameFormat.format(candidate.dateMs)); size -= getFileSize(file); SettingsHelper.deleteSQLiteDatabase(file); deletedAnyFile = true; iterator.remove(); if (size <= 0L) { if (mGlobalDeletionCandidates.size() == 0) { // If there are no deletion candidates left, refresh the list so they are // available during the next performGlobalDeletion call. for (ServerManager manager : mServerManagers.values()) manager.reload(); } return; } } if (!deletedAnyFile) return; // Refresh deletion candidates for (ServerManager manager : mServerManagers.values()) manager.reload(); } } public void onMessage(ServerConnectionInfo connection) { if (++mGlobalMessageCounter >= MIN_GLOBAL_MESSAGES_UPDATE) { requestUpdate(null); for (ServerConnectionInfo info : mConnectionManager.getConnections()) info.mChatLogStorageUpdateCounter = 0; mGlobalMessageCounter = 0; return; } if (++connection.mChatLogStorageUpdateCounter >= MIN_SERVER_MESSAGES_UPDATE) { requestUpdate(connection.getUUID()); connection.mChatLogStorageUpdateCounter = 0; } } private void addGlobalDeletionCandidate(DeletionCandidate candidate) { if (mGlobalDeletionCandidates.size() < GLOBAL_DELETION_CANDIDATES) { mGlobalDeletionCandidates.add(candidate); } else if (mGlobalDeletionCandidates.lower(candidate) != null) { mGlobalDeletionCandidates.add(candidate); mGlobalDeletionCandidates.remove(mGlobalDeletionCandidates.last()); } } private long getBlockSize() { if (mBlockSize == 0L) { File chatLogDir = mServerConfigManager.getChatLogDir(); if (!chatLogDir.exists()) return 0L; StatFs statFs = new StatFs(chatLogDir.getAbsolutePath()); if (Build.VERSION.SDK_INT >= 18) mBlockSize = statFs.getBlockSizeLong(); else mBlockSize = statFs.getBlockSize(); } return mBlockSize; } private long getFileSize(File file) { long blockSize = getBlockSize(); return (file.length() + blockSize - 1) / blockSize * blockSize; } @Override public void onConnectionAdded(ServerConfigData data) { mExecutor.execute(() -> { mServerManagers.put(data.uuid, new ServerManager(data)); }); } @Override public void onConnectionRemoved(ServerConfigData data) { mExecutor.execute(() -> { mServerManagers.get(data.uuid).remove(); mServerManagers.remove(data.uuid); }); } @Override public void onConnectionUpdated(ServerConfigData data) { requestUpdate(data.uuid); } public class ServerManager { private ServerConfigData mServerConfig; private File mLogsDir; private long mTotalSize = 0L; private Calendar mCurrentLogTime; private File mCurrentLogFile; private long mCurrentLogSize = 0L; private TreeSet<DeletionCandidate> mDeletionCandidates = new TreeSet<>(); public ServerManager(ServerConfigData config) { mServerConfig = config; mCurrentLogTime = Calendar.getInstance(); mLogsDir = mServerConfigManager.getServerChatLogDir(config.uuid); reload(); } private void reload() { remove(); File[] files = mLogsDir.listFiles(); if (files == null) return; mTotalSize = getBlockSize(); int currentYear = mCurrentLogTime.get(Calendar.YEAR); int currentMonth = mCurrentLogTime.get(Calendar.MONTH); int currentDay = mCurrentLogTime.get(Calendar.DAY_OF_MONTH); Calendar calendar = Calendar.getInstance(); for (File file : files) { long fileSize = getFileSize(file); mTotalSize += fileSize; Date date; try { date = sFileNameFormat.parse(file.getName()); } catch (ParseException ignored) { continue; } calendar.setTime(date); if (calendar.get(Calendar.YEAR) == currentYear && calendar.get(Calendar.MONTH) == currentMonth && calendar.get(Calendar.DAY_OF_MONTH) == currentDay) { mCurrentLogFile = file; mCurrentLogSize = fileSize; continue; } DeletionCandidate candidate = new DeletionCandidate(this, fileSize, date.getTime()); addDeletionCandidate(candidate); addGlobalDeletionCandidate(candidate); } mGlobalTotalSize += mTotalSize; } public void remove() { mGlobalTotalSize -= mTotalSize; mDeletionCandidates.clear(); for (Iterator<DeletionCandidate> iterator = mGlobalDeletionCandidates.iterator(); iterator.hasNext(); ) { DeletionCandidate candidate = iterator.next(); if (candidate.server == this) iterator.remove(); } mTotalSize = 0L; mCurrentLogFile = null; mCurrentLogSize = 0L; } public void update(int currentYear, int currentMonth, int currentDay) { mTotalSize -= mCurrentLogSize; mGlobalTotalSize -= mCurrentLogSize; mCurrentLogSize = mCurrentLogFile == null ? 0L : getFileSize(mCurrentLogFile); mTotalSize += mCurrentLogSize; mGlobalTotalSize += mCurrentLogSize; long blockSize = getBlockSize(); while (currentYear > mCurrentLogTime.get(Calendar.YEAR) || (currentYear == mCurrentLogTime.get(Calendar.YEAR) && (currentMonth > mCurrentLogTime.get(Calendar.MONTH) || (currentMonth == mCurrentLogTime.get(Calendar.MONTH) && currentDay > mCurrentLogTime.get(Calendar.DAY_OF_MONTH))))) { if (mCurrentLogFile != null && mCurrentLogFile.exists()) { DeletionCandidate candidate = new DeletionCandidate(this, mCurrentLogSize, mCurrentLogTime.getTimeInMillis()); addDeletionCandidate(candidate); addGlobalDeletionCandidate(candidate); } mCurrentLogTime.add(Calendar.DAY_OF_MONTH, 1); mCurrentLogFile = new File(mLogsDir, sFileNameFormat.format(mCurrentLogTime.getTime())); mCurrentLogSize = (mCurrentLogFile.length() + blockSize - 1) / blockSize * blockSize; mTotalSize += mCurrentLogSize; mGlobalTotalSize += mCurrentLogSize; } long limit = mServerConfig.storageLimit != 0L ? mServerConfig.storageLimit : mDefaultServerLimit; if (limit != -1L && mTotalSize >= limit) performDeletion(mTotalSize - limit); } private void performDeletion(long size) { while (true) { boolean deletedAnyFile = false; for (Iterator<DeletionCandidate> iterator = mDeletionCandidates.iterator(); iterator.hasNext(); ) { DeletionCandidate candidate = iterator.next(); mGlobalTotalSize -= candidate.size; File file = new File(candidate.server.mLogsDir, sFileNameFormat.format(candidate.dateMs)); size -= getFileSize(file); SettingsHelper.deleteSQLiteDatabase(file); deletedAnyFile = true; iterator.remove(); if (size <= 0L) { if (mGlobalDeletionCandidates.size() == 0) reload(); return; } } if (!deletedAnyFile) return; reload(); } } private void addDeletionCandidate(DeletionCandidate candidate) { if (mDeletionCandidates.size() < CONNECTION_DELETION_CANDIDATES) { mDeletionCandidates.add(candidate); } else if (mDeletionCandidates.lower(candidate) != null) { mDeletionCandidates.add(candidate); mDeletionCandidates.remove(mDeletionCandidates.last()); } } } private static class DeletionCandidate implements Comparable<DeletionCandidate> { private ServerManager server; private long size; private long dateMs; public DeletionCandidate(ServerManager server, long size, long dateMs) { this.server = server; this.size = size; this.dateMs = dateMs; } @Override public int compareTo(@NonNull DeletionCandidate o) { if (dateMs != o.dateMs) return Long.compare(o.dateMs, dateMs); if (size != o.size) return Long.compare(size, o.size); return 0; } } }