MarshallAsch/veil-droid

View on GitHub
app/src/main/java/ca/marshallasch/veil/HashTableStore.java

Summary

Maintainability
D
2 days
Test Coverage
package ca.marshallasch.veil;

import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.util.Pair;
import android.util.Log;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicInteger;

import javax.annotation.concurrent.ThreadSafe;

import ca.marshallasch.veil.comparators.CommentComparator;
import ca.marshallasch.veil.comparators.EntryComparator;
import ca.marshallasch.veil.exceptions.TooManyResultsException;
import ca.marshallasch.veil.proto.DhtProto;
import ca.marshallasch.veil.serializer.MapSerializer;
import ca.marshallasch.veil.utilities.Util;

import static ca.marshallasch.veil.proto.DhtProto.KeywordType.COMMENT_FOR;
import static ca.marshallasch.veil.proto.DhtProto.KeywordType.EMAIL;
import static ca.marshallasch.veil.proto.DhtProto.KeywordType.NAME;
import static ca.marshallasch.veil.proto.DhtProto.KeywordType.TAG;
import static ca.marshallasch.veil.proto.DhtProto.KeywordType.TITLE_FULL;
import static ca.marshallasch.veil.proto.DhtProto.KeywordType.TITLE_PARTIAL;

/**
 * This class is a data store for forum. It is implemented as a local hash table. This implementation
 * should be fairly similar to the targeted implementation in a distributed hash table.
 * Inorder for the different devices to get a copy of the posts and the messages it will rely on
 * the peer discovery data exchange protocol for now.
 *
 * Do not access this class directly for the data. you should go through {@link DataStore}.
 *
 * Note that the {@link HashMap} and the {@link ArrayList} classes are not thread safe.
 * Since this class is called by multiple threads any insert or read call needs to lock on the
 * hash map. and anything that modifies or looks at the value of en entry needs to lock on that list.
 *
 * @author Marshall Asch
 * @version 1.0
 * @since 2018-06-08
 */
@ThreadSafe
public class HashTableStore implements ForumStorage
{
    // Made package-private so that it can be accessed for testing.
    final HashMap<String, List<DhtProto.DhtWrapper>> hashMap;

    private static HashTableStore instance;
    private static final AtomicInteger openCounter = new AtomicInteger();

    private final File mapFile;

    private boolean modified;

    private HashTableStore(@Nullable  Context c) {

        // if there is no context then do not load a persistent file
        if (c == null) {
            hashMap = new HashMap<>();
            mapFile = null;
            return;
        }

        // try loading from mem or create new
        mapFile = new File(c.getFilesDir(), "HASH_MAP");
        HashMap<String, List<DhtProto.DhtWrapper>> tempMap = null;

        if (mapFile.exists()) {
            try {
                FileInputStream fileInputStream = new FileInputStream(mapFile);

                tempMap = MapSerializer.readMap(fileInputStream);

                fileInputStream.close();

            }
            catch (Exception e) {
                e.printStackTrace();
            }
        }

        // create a new map if one does not exist
        if (tempMap == null) {
            modified = true;
            hashMap = new HashMap<>();
        } else {
            hashMap = tempMap;
            modified = false;
        }
    }

    public static synchronized HashTableStore getInstance(@Nullable final Context c)
    {
        if (instance == null) {
            instance = new HashTableStore(c);
        }
        openCounter.incrementAndGet();
        return instance;
    }

    /**
     * This function will update the saved copy of the hash map to the disk. The file needs to be
     * created in the constructor if a non null context was passed to it. Otherwise the table is not
     * saved.
     */
    public void save() {

        // if the file does not exist then do not save, or if it has not been modified
        if (mapFile == null || !modified) {
            Log.d("HashTableStore", "Not actually saving the file, changed: " + String.valueOf(modified));
            return;
        }

        try {
            FileOutputStream fileOutputStream = new FileOutputStream(mapFile, false);

            synchronized (hashMap) {
                MapSerializer.write(fileOutputStream, hashMap);
            }

            Log.d("SAVE", "saved map");
            fileOutputStream.close();
            modified = false;
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * This function will insert the post object into the local hash table. This will also add
     * records for the tags and the title to help search for the post later. The post uuid <b>MUST</b>
     * be set to the hash for the post, this can be done by calling
     * {@link Util#createPost(String, String, DhtProto.User, List)}.
     * @param post the post item to store
     * @return the hash  identifying the post
     */
    @Override
    public String insertPost(DhtProto.Post post)
    {
        if (post == null) {
            return null;
        }

        String hash = post.getUuid();

        DhtProto.DhtWrapper wrapper = DhtProto.DhtWrapper.newBuilder()
                .setPost(post)
                .setType(DhtProto.MessageType.POST)
                .build();

        insert(wrapper, hash);

        List<String> tags = post.getTagsList();
        String title = post.getTitle();
        DhtProto.DhtWrapper keyword;

        // add the tags to index the post
        for (String tag: tags) {
            tag = tag.toLowerCase(Locale.getDefault());
            keyword = generateKeyword(tag, hash, TAG);
            insert(keyword, Util.generateHash(tag.getBytes()));
        }

        // add the full text title to the index
        keyword = generateKeyword(title, hash, TITLE_FULL);
        insert(keyword, Util.generateHash(title.toLowerCase(Locale.getDefault()).getBytes()));

        // tokenize the title and add the tokens
        String[] titleKeywords = title.split(" ");
        for (String tag: titleKeywords) {
            tag = tag.toLowerCase(Locale.getDefault());
            keyword = generateKeyword(title, hash, TITLE_PARTIAL);
            insert(keyword, Util.generateHash(tag.getBytes()));
        }

        return hash;
    }

    /**
     * This will get a post object for a specific hash. This will throw an exception if
     * more then 1 resulting post is found with the same hash, this should not ever occur but
     * the exception is there to handle the possibility.
     *
     * @param hash the unique SHA256 hash of the post
     * @return null if no matching post is found or the post object
     * @throws TooManyResultsException gets thrown if more then 1 post is found with the same hash.
     */
    @Override
    @Nullable
    public DhtProto.Post findPostByHash(String hash) throws TooManyResultsException
    {
        ArrayList<DhtProto.DhtWrapper> entries;

        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>)hashMap.get(hash);
        }

        if (entries == null) {
            return null;
        }
        ArrayList<DhtProto.Post> posts = new ArrayList<>();

        for (DhtProto.DhtWrapper wrapper: entries) {

            // make sure that it is a post item
            if (wrapper.getType() == DhtProto.MessageType.POST) {
               posts.add(wrapper.getPost());
            }
        }

        // make sure that only 1 result was found
        if (posts.size() > 1) {
            throw new TooManyResultsException("Too many results for: " + hash);
        } else if (posts.size() == 1) {
            return  posts.get(0);
        }

        // otherwise there were no results found.
        return null;
    }

    /**
     * This keyword must be a single token to search for, and it must match exactly.
     * The keyword will be normalized to lowercase.
     *
     * @param keyword the keyword string to look up
     * @return the list of results
     */
    @Override
    @Nullable
    public List<DhtProto.Post> findPostsByKeyword(String keyword)
    {
        keyword = keyword.toLowerCase(Locale.getDefault());
        ArrayList<DhtProto.DhtWrapper> entries;

        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>)hashMap.get(Util.generateHash(keyword.getBytes()));
        }

        if (entries == null) {
            return new ArrayList<>();
        }
        ArrayList<String> postHashes = new ArrayList<>();

        for (DhtProto.DhtWrapper wrapper: entries) {

            // make sure that it is a post item
            if (wrapper.getType() == DhtProto.MessageType.KEYWORD) {

                // only include results that are for posts
                DhtProto.KeywordType type = wrapper.getKeyword().getType();
                if (type == TAG || type == TITLE_PARTIAL || type == TITLE_FULL) {
                    postHashes.add(wrapper.getKeyword().getHash());
                }
            }
        }

        ArrayList<DhtProto.Post> posts = new ArrayList<>();

        // find all the post objects
        for(String hash: postHashes) {

            try {
                posts.add(findPostByHash(hash));
            }
            catch (TooManyResultsException e) {
                e.printStackTrace();
            }
        }

        return posts;
    }

    /**
     * This function will insert the comment object into the local hash table. This will also add
     * record for looking up the comment by the post it is part of.
     *
     * @param comment the comment object that will be added
     * @param postHash the post identifier that the comment is being added to
     * @return the hash  identifying the comment
     */
    @Override
    public String insertComment(@NonNull DhtProto.Comment comment, @NonNull String postHash)
    {
        String hash = comment.getUuid();

        DhtProto.DhtWrapper wrapper = DhtProto.DhtWrapper.newBuilder()
                .setComment(comment)
                .setType(DhtProto.MessageType.COMMENT)
                .build();

        insert(wrapper, hash);

        // the keyword item is empty because it is unused in the search,
        // this is the search item so it can be associates with a post
        DhtProto.DhtWrapper keyword = generateKeyword(null, hash, COMMENT_FOR);
        insert(keyword, postHash);

        return hash;
    }

    /**
     * This function will get all the comments that are associated with the given post.
     * The comments are sorted by the time that they were created.
     *
     * @param postHash he unique SHA256 hash of the post
     * @return the list of results
     * @deprecated
     */
    @Deprecated
    @Override
    @NonNull
    public List<DhtProto.Comment> findCommentsByPost(String postHash)
    {
        ArrayList<DhtProto.DhtWrapper> entries;
        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>)hashMap.get(postHash);
        }

        if (entries == null) {
            return new ArrayList<>();
        }
        ArrayList<String> commentHashes = new ArrayList<>();

        for (DhtProto.DhtWrapper wrapper: entries) {

            // make sure that it is a user item
            if (wrapper.getType() == DhtProto.MessageType.KEYWORD) {

                // only include results that are for comments
                DhtProto.KeywordType type = wrapper.getKeyword().getType();
                if (type == COMMENT_FOR) {
                    commentHashes.add(wrapper.getKeyword().getHash());
                }
            }
        }

        ArrayList<DhtProto.Comment> comments = new ArrayList<>();

        DhtProto.Comment comment;
        // find all the user objects
        for(String hash: commentHashes) {

            try {
                comment = findCommentByHash(hash);

                if (comment != null) {
                    comments.add(comment);
                }
            }
            catch (TooManyResultsException e) {
                e.printStackTrace();
            }
        }

        Collections.sort(comments, new CommentComparator());

        return comments;
    }

    /**
     * This will get a comment object for a specific hash. This will throw an exception if
     * more then 1 resulting comment is found with the same hash, this should not ever occur but
     * the exception is there to handle the possibility.
     *
     * @param hash the unique SHA256 hash of the comment
     * @return null if no matching comment is found or the result that contains the comment object
     * @throws TooManyResultsException gets thrown if more then 1 comment is found with the same hash.
     */
    @Override
    @Nullable
    public DhtProto.Comment findCommentByHash(String hash) throws TooManyResultsException
    {
        ArrayList<DhtProto.DhtWrapper> entries;
        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>)hashMap.get(hash);
        }

        if (entries == null) {
            return null;
        }
        ArrayList<DhtProto.Comment> comments = new ArrayList<>();

        for (DhtProto.DhtWrapper wrapper: entries) {

            // make sure that it is a comment item
            if (wrapper.getType() == DhtProto.MessageType.COMMENT) {
                comments.add(wrapper.getComment());
            }
        }

        // make sure that only 1 result was found
        if (comments.size() > 1) {
            throw new TooManyResultsException("Too many results for: " + hash);
        } else if (comments.size() == 1) {
            return comments.get(0);
        }

        // otherwise there were no results found.
        return null;
    }

    /**
     * Insert a user entry into the hash table along with some indexing records to look up
     * the user. The users hash is currently the hash of the uuid
     *
     * @param user the public user object to store (does not contain the password)
     * @return the hash that is used to identify the user.
     */
    @Override
    public String insertUser(DhtProto.User user)
    {
        if (user == null) {
            return null;
        }

        String hash = Util.generateHash(user.getUuid().getBytes());
        String email = user.getEmail();
        String firstName = user.getFirstName();
        String lastName = user.getLastName();
        String fullName = firstName + " " + lastName;

        DhtProto.DhtWrapper wrapper = generateKeyword(email, hash, EMAIL);
        insert(wrapper, Util.generateHash(email.toLowerCase(Locale.getDefault()).getBytes()));

        // add indexes for the user in the table
        wrapper = generateKeyword(fullName, hash, NAME);
        insert(wrapper, Util.generateHash(firstName.toLowerCase(Locale.getDefault()).getBytes()));

        wrapper = generateKeyword(fullName, hash, NAME);
        insert(wrapper, Util.generateHash(lastName.toLowerCase(Locale.getDefault()).getBytes()));

        wrapper = generateKeyword(fullName, hash, NAME);
        insert(wrapper, Util.generateHash(fullName.toLowerCase(Locale.getDefault()).getBytes()));

        // insert the user item
        wrapper = DhtProto.DhtWrapper.newBuilder()
                .setType(DhtProto.MessageType.USER)
                .setUser(user)
                .build();
        insert(wrapper, hash);

        return hash;
    }

    /**
     * This will get a user object for a specific hash. This will throw an exception if
     * more then 1 resulting user is found with the same hash, this should not ever occur but
     * the exception is there to handle the possibility.
     *
     * @param userHash the unique SHA256 hash identifying the user
     * @return null if no matching user is found or the result that contains the user object
     * @throws TooManyResultsException gets thrown if more then 1 user is found with the same hash.
     */
    @Override
    public Pair<String, DhtProto.User> findUserByHash(String userHash) throws TooManyResultsException
    {
        ArrayList<DhtProto.DhtWrapper> entries;
        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>)hashMap.get(userHash);
        }

        if (entries == null) {
            return null;
        }
        ArrayList<DhtProto.User> users = new ArrayList<>();

        for (DhtProto.DhtWrapper wrapper: entries) {

            // make sure that it is a user item
            if (wrapper.getType() == DhtProto.MessageType.USER) {
                users.add(wrapper.getUser());
            }
        }

        // make sure that only 1 result was found
        if (users.size() > 1) {
            throw new TooManyResultsException("Too many results for: " + userHash);
        } else if (users.size() == 1) {
            return new Pair<>(userHash, users.get(0));
        }

        // otherwise there were no results found.
        return null;
    }

    /**
     * This keyword must be a single token to search for, and it must match exactly.
     * The keyword will be normalized to lowercase. It can be a name or email.
     *
     * @param name the keyword to search for.
     * @return the list of results
     */
    @Override
    @Nullable
    public List<Pair<String, DhtProto.User>> findUsersByName(String name)
    {
        if (name == null) {
            return new ArrayList<>();
        }

        name = name.toLowerCase(Locale.getDefault());
        ArrayList<DhtProto.DhtWrapper> entries;

        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>)hashMap.get(Util.generateHash(name.getBytes()));
        }

        if (entries == null) {
            return new ArrayList<>();
        }
        ArrayList<String> userHashes = new ArrayList<>();

        for (DhtProto.DhtWrapper wrapper: entries) {

            // make sure that it is a user item
            if (wrapper.getType() == DhtProto.MessageType.KEYWORD) {

                // only include results that are for posts
                DhtProto.KeywordType type = wrapper.getKeyword().getType();
                if (type == EMAIL || type == NAME) {
                    userHashes.add(wrapper.getKeyword().getHash());
                }
            }
        }

        ArrayList<Pair<String, DhtProto.User>> users = new ArrayList<>();

        // find all the user objects
        for(String hash: userHashes) {

            try {
                users.add(findUserByHash(hash));
            }
            catch (TooManyResultsException e) {
                e.printStackTrace();
            }
        }

        return users;
    }

    @Override
    public boolean updateUser(DhtProto.User user)
    {
        return false;
    }

    /**
     * This function will generate a keyword object used for indexing the objects in the table.
     * The keyword is normalized to lowercase to make future searches case insensitive.
     *
     * @param keyword the keyword it is mapping
     * @param dataHash the data it is mapping the key to
     * @param type the type of keyword it is. {@link DhtProto.KeywordType}
     * @return the wrapper object to insert into the hashmap
     */
    @NonNull
    private DhtProto.DhtWrapper generateKeyword(@Nullable String keyword, String dataHash, DhtProto.KeywordType type) {

        // according to the protobuf spec, the default value for a string is empty not null
        if (keyword == null) {
            keyword = "";
        } else {
            keyword = keyword.toLowerCase(Locale.getDefault());
        }

        DhtProto.Keyword keywordObj = DhtProto.Keyword.newBuilder()
                .setHash(dataHash)
                .setKeyword(keyword)
                .setType(type)
                .build();

        // create the wrapper object
        return DhtProto.DhtWrapper.newBuilder()
                .setKeyword(keywordObj)
                .setType(DhtProto.MessageType.KEYWORD)
                .build();
    }

    /**
     * This function will insert the record into the local hash table
     * @param data the data to put into the table
     * @param key the key that it will go under
     */
    public void insert(@NonNull  DhtProto.DhtWrapper data, String key) {

        ArrayList<DhtProto.DhtWrapper> entries;

        synchronized (hashMap) {
            entries = (ArrayList<DhtProto.DhtWrapper>) hashMap.get(key);
        }

        if (entries == null) {
            entries = new ArrayList<>();
        }

        boolean match = false;
        for (DhtProto.DhtWrapper entry: entries) {
            if (EntryComparator.entryEquals(entry, data)){
                match = true;
                break;
            }
        }

        if (!match) {
            entries.add(data);
        }

        synchronized (hashMap) {
            hashMap.put(key, entries);
        }
        modified = true;
    }

    /**
     * Get the wrapper item for the data sync function. Will only get the first comment or post item
     * with the given key.
     * @param hash the hash identifying the entry
     * @return the pair on success NULL if a match was not found.
     */
    @Nullable
    public DhtProto.DhtWrapper getPostOrComment(String hash) {

        List<DhtProto.DhtWrapper> list = hashMap.get(hash);

        if (list != null) {
            for (DhtProto.DhtWrapper element : list) {

                if (element.getType() == DhtProto.MessageType.COMMENT || element.getType() == DhtProto.MessageType.POST) {
                    return element;
                }
            }
        }
        return null;
    }

    /**
     * This function will delete the entries in the hash table with the matching hash.
     * @param hash the hash to remove
     */
    public void deleteByHash(String hash){
        hashMap.remove(hash);
    }

    /**
     * Gets the number of bytes in the file for the hash table.
     * @return num bytes in the hash table file
     */
    public long getFileSize() {
        return mapFile.length();
    }
}