rkuenzi-epfl/Wnder

View on GitHub
app/src/main/java/com/github/wnder/picture/FirebasePicturesDatabase.java

Summary

Maintainability
A
2 hrs
Test Coverage
B
86%
package com.github.wnder.picture;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.location.Location;

import com.github.wnder.Score;
import com.google.firebase.firestore.CollectionReference;
import com.google.firebase.firestore.FirebaseFirestore;
import com.google.firebase.firestore.GeoPoint;
import com.google.firebase.storage.FirebaseStorage;
import com.google.firebase.storage.StorageMetadata;
import com.google.firebase.storage.StorageReference;
import com.google.firebase.storage.UploadTask;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;

import javax.inject.Inject;
import javax.inject.Singleton;

@Singleton
public class FirebasePicturesDatabase implements PicturesDatabase {

    private final StorageReference storage;
    private final CollectionReference picturesCollection;
    private final CollectionReference usersCollection;
    private final CollectionReference reportedPicturesCollection;

    private enum PictureType {guess, upload};

    private final static String ACTIVE_UPLOAD_URI_DIR_NAME = "active_uploads";
    private final File activeUploadDirectory;

    @Inject
    public FirebasePicturesDatabase(Context context){
        storage = FirebaseStorage.getInstance().getReference();
        picturesCollection = FirebaseFirestore.getInstance().collection("pictures");
        usersCollection = FirebaseFirestore.getInstance().collection("users");
        reportedPicturesCollection = FirebaseFirestore.getInstance().collection("reportedPictures");

        activeUploadDirectory = context.getDir(ACTIVE_UPLOAD_URI_DIR_NAME, Context.MODE_PRIVATE);

        // List all files waiting that we resume upload
        String[] activeUploadFiles = activeUploadDirectory.list();
        for (String uniqueId: activeUploadFiles) {

            File file = new File(activeUploadDirectory, uniqueId);
            UploadInfo uploadInfo = UploadInfo.loadUploadInfo(file);

            if(uploadInfo != null){
                uploadPicture(uniqueId, uploadInfo);
            } else {
                // Not all information are available so we can't upload
                // Abort and delete the malformed file
                UploadInfo.deleteUploadInfo(file);
            }
        }
    }

    @Override
    public CompletableFuture<Location> getLocation(String uniqueId) {
        CompletableFuture<Location> cf = new CompletableFuture<>();
        picturesCollection.document(uniqueId).get().
                addOnSuccessListener((documentSnapshot) -> {
                    //once we get it, convert it to a Location (because it's two double in Firestore)
                    Location convertedResult = new Location("");
                    double latitude = documentSnapshot.getDouble("latitude");
                    double longitude = documentSnapshot.getDouble("longitude");
                    convertedResult.setLatitude(latitude);
                    convertedResult.setLongitude(longitude);

                    cf.complete(convertedResult);

                })
                .addOnFailureListener(cf::completeExceptionally);
        return cf;
    }

    @Override
    public CompletableFuture<Map<String, Location>> getUserGuesses(String uniqueId) {
        CompletableFuture<Map<String, Location>> cf = new CompletableFuture<>();
        picturesCollection.document(uniqueId).collection("userData").document("userGuesses").get()
                .addOnSuccessListener((documentSnapshot) -> {
                    //Once done, organize and convert them into Locations
                    Map<String, Location> convertedResult = new TreeMap<>();
                    for(Map.Entry<String, Object> e : documentSnapshot.getData().entrySet()){
                        GeoPoint geoPoint = documentSnapshot.getGeoPoint(e.getKey());
                        if(geoPoint != null){

                            Location locationEntry = new Location("");
                            locationEntry.setLatitude(geoPoint.getLatitude());
                            locationEntry.setLongitude(geoPoint.getLongitude());
                            convertedResult.put(e.getKey(), locationEntry);
                        }
                    }

                    cf.complete(convertedResult);

                })
                .addOnFailureListener(cf::completeExceptionally);
        return cf;
    }

    @Override
    public CompletableFuture<Map<String, Double>> getScoreboard(String uniqueId) {
        CompletableFuture<Map<String, Double>> cf = new CompletableFuture<>();
        picturesCollection.document(uniqueId).collection("userData").document("userScores").get()
                .addOnSuccessListener((documentSnapshot) -> {
                    //Once done, organize results and accept them
                    Map<String, Double> convertedResult = new TreeMap<>();
                    for(Map.Entry<String, Object> e : documentSnapshot.getData().entrySet()){
                        Double value = documentSnapshot.getDouble(e.getKey());
                        convertedResult.put(e.getKey(), documentSnapshot.getDouble(e.getKey()));
                    }
                    cf.complete(convertedResult);
                })
                .addOnFailureListener(cf::completeExceptionally);
        return cf;
    }

    @Override
    public CompletableFuture<Void> sendUserGuess(String uniqueId, String user, Location guessedLocation, Bitmap mapSnapshot) {
        CompletableFuture<Void> guessSent = new CompletableFuture<>();
        CompletableFuture<Void> scoreSent = new CompletableFuture<>();
        getLocation(uniqueId).thenAccept(location -> {
            getUserGuesses(uniqueId).thenAccept((userGuesses) -> {
                userGuesses.put(user, guessedLocation);

                //Convert it to GeoPoint to fit Firestore (because Location doesn't work)
                Map<String, GeoPoint> convertedGuesses = new TreeMap<>();
                for (Map.Entry<String, Location> e : userGuesses.entrySet()) {
                    GeoPoint guess = new GeoPoint(e.getValue().getLatitude(), e.getValue().getLongitude());
                    convertedGuesses.put(e.getKey(), guess);
                }
                picturesCollection.document(uniqueId).collection("userData").document("userGuesses")
                        .set(convertedGuesses)
                        .addOnSuccessListener(result -> guessSent.complete(null)).addOnFailureListener(guessSent::completeExceptionally);

            });
            getScoreboard(uniqueId).thenAccept((scoreboard) -> {
                //Compute score
                Double score = Score.computeScore(location, guessedLocation);
                Map<String, Double> newScoreboard = new HashMap<>(scoreboard);
                newScoreboard.put(user, score);

                picturesCollection.document(uniqueId).collection("userData").document("userScores")
                        .set(newScoreboard)
                        .addOnSuccessListener(result -> scoreSent.complete(null)).addOnFailureListener(scoreSent::completeExceptionally);

            });
        });
        return CompletableFuture.allOf(guessSent, scoreSent, addToUserPictures(uniqueId, user, PictureType.guess));
    }

    @Override
    public CompletableFuture<Bitmap> getBitmap(String uniqueId) {
        CompletableFuture<Bitmap> cf = new CompletableFuture<>();
        storage.child("pictures/"+uniqueId+".jpg").getBytes(Long.MAX_VALUE)
                .addOnSuccessListener(bytes -> cf.complete(BitmapFactory.decodeByteArray(bytes, 0, bytes.length)))
                .addOnFailureListener(cf::completeExceptionally);
        return cf;
    }

    @Override
    public CompletableFuture<Bitmap> getMapSnapshot(Context context, String uniqueId) {
        CompletableFuture<Bitmap> cf = new CompletableFuture<>();
        cf.completeExceptionally(new IllegalStateException("This method is only available on the local database"));
        return cf;
    }

    @Override
    public CompletableFuture<Location> getUserGuess(String uniqueId) {
        CompletableFuture<Location> cf = new CompletableFuture<>();
        cf.completeExceptionally(new IllegalStateException("This method is only available on the local database"));
        return cf;
    }

    @Override
    public CompletableFuture<Void> uploadPicture(String uniqueId, UploadInfo uploadInfo) {

        // Start upload
        StorageMetadata metadata = new StorageMetadata.Builder().setContentType("image/jpeg").build();
        UploadTask pictureTask = storage.child("pictures/"+uniqueId+".jpg").putFile(uploadInfo.pictureUri, metadata);

        File file = new File(activeUploadDirectory, uniqueId);

        // Write json to file
        try{
            UploadInfo.storeUploadInfo(file, uploadInfo);
        } catch (Exception e) {
            // If it fails return an exceptionnaly completed future
            CompletableFuture<Void> cf = new CompletableFuture<>();
            cf.completeExceptionally(e);
            return cf;
        }

        // Attach upload SuccessListener
        return attachUploadListener(uniqueId, uploadInfo.userName, uploadInfo.location, pictureTask);
    }

    /**
     * Attach new listener on picture upload success to upload the associated metadata
     * @param uniqueId uploaded picture unique id
     * @param userName uploading user name
     * @param location location the picture was taken from
     * @param task the task to attach the metadata task to
     * @return a completable future that completes when everything is uploaded
     */
    private CompletableFuture<Void> attachUploadListener(String uniqueId, String userName, Location location, UploadTask task){
        CompletableFuture<Void> attributesCf = new CompletableFuture<>();
        CompletableFuture<Void> userGuessesCf = new CompletableFuture<>();
        CompletableFuture<Void> userScoresCf = new CompletableFuture<>();
        CompletableFuture<Void> userUploadListCf = addToUserPictures(uniqueId, userName, PictureType.upload);
        CompletableFuture<Void> cf = CompletableFuture.allOf(userUploadListCf, attributesCf, userGuessesCf, userScoresCf);

        task.addOnSuccessListener(pictureUploadResult -> {
            //coordinates
            Map<String, Object> attributes = new HashMap<>();
            attributes.put("latitude", location.getLatitude());
            attributes.put("longitude", location.getLongitude());
            attributes.put("karma", 0);
            picturesCollection.document(uniqueId).set(attributes)
                    .addOnSuccessListener(result -> attributesCf.complete(null)).addOnFailureListener(attributesCf::completeExceptionally);

            //default instantiation for the guesses ()
            //necessary to have the correct documents created in firestore
            Map<String, GeoPoint> emptyGuesses = new HashMap<>();
            GeoPoint defaultGuess = new GeoPoint(location.getLatitude(),location.getLongitude());
            emptyGuesses.put(userName, defaultGuess);
            picturesCollection.document(uniqueId).collection("userData").document("userGuesses").set(emptyGuesses)
                    .addOnSuccessListener(result -> userGuessesCf.complete(null)).addOnFailureListener(userGuessesCf::completeExceptionally);


            //necessary to have the correct documents created in firestore
            Map<String, Double> emptyScoreboard= new HashMap<>();
            emptyScoreboard.put(userName, -1.);
            picturesCollection.document(uniqueId).collection("userData").document("userScores").set(emptyScoreboard)
                    .addOnSuccessListener(result -> userScoresCf.complete(null)).addOnFailureListener(userScoresCf::completeExceptionally);
        }).addOnFailureListener(cf::completeExceptionally);

        // Delete local metadata when everything is uploaded
        cf.thenAccept(nothing -> UploadInfo.deleteUploadInfo(new File(activeUploadDirectory, uniqueId)));
        return cf;

    }

    @Override
    public CompletableFuture<Void> addToReportedPictures(String uniqueId) {
        CompletableFuture<Void> pictureAdded = new CompletableFuture<>();
        reportedPicturesCollection.document(uniqueId).set(new HashMap<String, Object>()).addOnSuccessListener((result -> {
            pictureAdded.complete(null);
        })).addOnFailureListener(pictureAdded::completeExceptionally);

        return pictureAdded;
    }

    /**
     * Add this picture to the list of uploaded or guessed pictures of the user
     */
    private CompletableFuture<Void> addToUserPictures(String uniqueId, String user, PictureType type){
        CompletableFuture<Void> cf = new CompletableFuture<>();
        //get current user data
        usersCollection.document(user).get()
                .addOnSuccessListener((documentSnapshot) ->{

                    //Get current user data
                    List<String> guessedPictures = (List<String>) documentSnapshot.get("guessedPics");
                    List<String> uploadedPictures = (List<String>) documentSnapshot.get("uploadedPics");

                    //Create lists if they don't exist
                    if (guessedPictures == null) {
                        guessedPictures = new ArrayList<>();
                    }
                    if (uploadedPictures == null) {
                        uploadedPictures = new ArrayList<>();
                    }

                    if(type.equals(PictureType.upload)){
                        //If uploaded pictures doesn't contain the new picture, add it
                        if (!uploadedPictures.contains(uniqueId)){
                            uploadedPictures.add(uniqueId);
                        }
                    }
                    else{
                        //add picture to the list of guessed pictures if it isn't already in
                        if (!guessedPictures.contains(uniqueId)) {
                            guessedPictures.add(uniqueId);
                        }
                    }

                    uploadBack(user, guessedPictures, uploadedPictures, cf);
                })
                .addOnFailureListener(cf::completeExceptionally);

        return cf;
    }

    private void uploadBack(String user, List<String> guessedPics, List<String> uploadedPics, CompletableFuture<Void> cf){
        //Upload everything back to Firestore
        Map<String, Object> toUpload = new HashMap<>();
        toUpload.put("guessedPics", guessedPics);
        toUpload.put("uploadedPics", uploadedPics);
        usersCollection.document(user).set(toUpload)
                .addOnSuccessListener(result -> cf.complete(null))
                .addOnFailureListener(cf::completeExceptionally);
    }

    @Override
    public CompletableFuture<Long> getKarma(String uniqueId) {
        CompletableFuture<Long> cf = new CompletableFuture<>();

        picturesCollection.document(uniqueId).get()
                .addOnSuccessListener(documentSnapshot -> cf.complete(documentSnapshot.getLong("karma")))
                .addOnFailureListener(cf::completeExceptionally);

        return cf;
    }

    @Override
    public CompletableFuture<Void> updateKarma(String uniqueId, int delta) {
        CompletableFuture<Void> cf = new CompletableFuture<>();
        //Get current karma
        getKarma(uniqueId)
                .thenAccept(karma -> {
                    picturesCollection.document(uniqueId)
                            .update("karma", karma+delta)
                            .addOnSuccessListener(result -> cf.complete(null))
                            .addOnFailureListener(cf::completeExceptionally);
                });
        return cf;
    }
}