asfoury/projmag

View on GitHub
app/src/main/java/com/sdp13epfl2021/projmag/database/impl/cache/OfflineProjectDatabase.kt

Summary

Maintainability
A
55 mins
Test Coverage
A
100%
package com.sdp13epfl2021.projmag.database.impl.cache

import com.sdp13epfl2021.projmag.database.ProjectChange
import com.sdp13epfl2021.projmag.database.interfaces.CandidatureDatabase
import com.sdp13epfl2021.projmag.database.interfaces.ProjectDatabase
import com.sdp13epfl2021.projmag.database.interfaces.ProjectId
import com.sdp13epfl2021.projmag.database.saveToFile
import com.sdp13epfl2021.projmag.model.ImmutableProject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
import java.io.ObjectInputStream
import java.util.*


/**
 * An implementation of ProjectDatabase that store/load projects from local storage and rely on another ProjectsDatabase.
 *
 * Any call to a getXXX methods will use a combined list of local storage and db (priority to db -> up to date).
 *
 * Any call to  push/delete/update methods will be forward to the given db.
 *
 * @param db a ProjectDatabase that will be used as a base database.
 * @param projectsDir the directory of projects, any directory inside represent a project.
 */
class OfflineProjectDatabase(
    private val db: ProjectDatabase,
    private val projectsDir: File,
    candidatureDB: CandidatureDatabase
) : ProjectDatabase {

    private val projectDataFilename: String = "project.data"
    private val idRegex: Regex = Regex("^[a-zA-Z0-9]*\$")

    /**
     * Projects are not stored in memory, we only keep track of projectId for valid projects.
     */
    private var projectsFiles: Map<ProjectId, File> = emptyMap()
    private var listeners: List<((ProjectChange) -> Unit)> = emptyList()

    /**
     * Load projects from the local storage then from the given db and register for any change.
     *
     * Only one local ChangeListener is add to the given db.
     * All other listeners are called from this one, when a change occurs.
     */
    init {
        projectsDir.mkdirs()
        loadProjects()

        db.addProjectsChangeListener { change ->
            when (change.type) {
                ProjectChange.Type.ADDED -> saveProject(change.project)
                ProjectChange.Type.MODIFIED -> saveProject(change.project)
                ProjectChange.Type.REMOVED -> deleteProject(change.project.id)
            }
            listeners.forEach { it -> it(change) }
        }

        db.getAllProjects({ remoteProjects ->
            GlobalScope.launch(Dispatchers.IO) {
                remoteProjects.forEach { p ->
                    saveProject(p)
                    candidatureDB.addListener(p.id) { _, _ -> }
                }
            }
        }, {})
    }

    /**
     * This method is called once when initialized.
     *
     * Loads all valid projects found in the local storage from the files /projects/{projectId}/project.data.
     */
    private fun loadProjects() {
        projectsFiles = projectsDir
            .listFiles()
            ?.let {
                it.map { child -> child.name to File(child, projectDataFilename) }
                    .filter { (id, data) ->
                        id.matches(idRegex) && data.exists() && data.isFile && readProject(
                            data
                        ) != null
                    }
                    .toMap()
            } ?: emptyMap()
    }

    @Synchronized
    private fun deleteProject(projectId: ProjectId) {
        val projectFile = projectsFiles[projectId]
        projectsFiles = projectsFiles - projectId
        if (projectFile != null && projectFile.exists()) {
            synchronized(projectFile) {
                projectFile.parentFile?.deleteRecursively()
            }
        }
    }

    @Synchronized
    private fun saveProject(project: ImmutableProject) {
        val projectDir = File(projectsDir, project.id)
        if (projectDir.isDirectory || projectDir.mkdir()) {
            var projectFile = projectsFiles[project.id]
            if (projectFile == null) {
                projectFile = File(projectDir, projectDataFilename)
                projectsFiles = projectsFiles + (project.id to projectFile)
            }
            saveToFile(projectFile, project.toMapString())
        }
    }


    @Suppress("UNCHECKED_CAST")
    private fun readProject(file: File): ImmutableProject? {
        synchronized(file) {
            if (file.exists() && file.isFile) {
                try {
                    val id: ProjectId = file.parentFile!!.name
                    ObjectInputStream(FileInputStream(file)).use {
                        val map: HashMap<String, Any> = it.readObject() as HashMap<String, Any>
                        val project = ImmutableProject.buildFromMap(map, id)
                        if (project == null) {
                            deleteProject(id)
                        }
                        return project
                    }
                } catch (e: Exception) {
                    file.delete()
                    return null
                }
            } else {
                return null
            }
        }
    }


    override fun getAllIds(onSuccess: (List<ProjectId>) -> Unit, onFailure: (Exception) -> Unit) {
        db.getAllIds({ remoteProjects ->
            GlobalScope.launch(Dispatchers.IO) {
                val localProjects = projectsFiles
                    .filterKeys { id -> !remoteProjects.contains(id) }
                    .map { entry -> entry.key }
                onSuccess(remoteProjects + localProjects)
            }
        }, onFailure)
    }

    override fun getProjectFromId(
        id: ProjectId,
        onSuccess: (ImmutableProject?) -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        val projectFile: File? = projectsFiles[id]
        if (projectFile == null) {
            db.getProjectFromId(id, onSuccess, onFailure)
        } else {
            GlobalScope.launch(Dispatchers.IO) {
                onSuccess(readProject(projectFile))
            }
        }
    }

    override fun getAllProjects(
        onSuccess: (List<ImmutableProject>) -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        db.getAllProjects({ remoteProjects ->
            GlobalScope.launch(Dispatchers.IO) {
                val localProjects = projectsFiles
                    .filterKeys { id -> !remoteProjects.any { p -> p.id == id } }
                    .mapNotNull { entry -> readProject(entry.value) }
                onSuccess(remoteProjects + localProjects)
            }
        }, onFailure)
    }

    override fun getProjectsFromName(
        name: String,
        onSuccess: (List<ImmutableProject>) -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        db.getProjectsFromName(name, { remoteProjects ->
            GlobalScope.launch(Dispatchers.IO) {
                val localProjects = projectsFiles
                    .filterKeys { id -> !remoteProjects.any { p -> p.id == id } }
                    .mapNotNull { entry -> readProject(entry.value) }
                    .filter { p -> p.name == name }
                onSuccess(remoteProjects + localProjects)
            }
        }, onFailure)
    }

    override fun getProjectsFromTags(
        tags: List<String>,
        onSuccess: (List<ImmutableProject>) -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        db.getProjectsFromTags(tags, { remoteProjects ->
            GlobalScope.launch(Dispatchers.IO) {
                val listOfTags = tags.map { tag -> tag.toLowerCase(Locale.ROOT) }
                val localProjects = projectsFiles
                    .filterKeys { id -> !remoteProjects.any { p -> p.id == id } }
                    .mapNotNull { entry -> readProject(entry.value) }
                    .filter { p -> p.tags.any { tag -> listOfTags.contains(tag.toLowerCase(Locale.ROOT)) } }
                onSuccess(remoteProjects + localProjects)
            }
        }, onFailure)
    }

    override fun pushProject(
        project: ImmutableProject,
        onSuccess: (ProjectId) -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        db.pushProject(project, onSuccess, onFailure)
    }

    override fun deleteProjectWithId(
        id: ProjectId,
        onSuccess: () -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        db.deleteProjectWithId(id, onSuccess, onFailure)
    }

    override fun updateVideoWithProject(
        id: ProjectId,
        uri: String,
        onSuccess: () -> Unit,
        onFailure: (Exception) -> Unit
    ) {
        db.updateVideoWithProject(id, uri, onSuccess, onFailure)
    }

    @Synchronized
    override fun addProjectsChangeListener(changeListener: (ProjectChange) -> Unit) {
        listeners = listeners + changeListener
    }

    @Synchronized
    override fun removeProjectsChangeListener(changeListener: (ProjectChange) -> Unit) {
        listeners = listeners - changeListener
    }

}