CultureQuestORG/SDP2023

View on GitHub
app/src/main/java/ch/epfl/culturequest/backend/tournament/apis/TournamentManagerApi.java

Summary

Maintainability
A
2 hrs
Test Coverage
B
88%
package ch.epfl.culturequest.backend.tournament.apis;

import static androidx.test.InstrumentationRegistry.getTargetContext;
import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static ch.epfl.culturequest.backend.tournament.apis.SeedApi.generateOrFetchSeedThenStore;
import static ch.epfl.culturequest.backend.tournament.apis.SeedApi.getCurrentSeed;
import static ch.epfl.culturequest.backend.tournament.apis.SeedApi.handleNewSeed;
import static ch.epfl.culturequest.backend.tournament.apis.SeedApi.seedAlreadyStoredInSharedPref;
import static ch.epfl.culturequest.backend.tournament.utils.RandomApi.generateWeeklyTournamentDate;
import static ch.epfl.culturequest.database.Database.fetchSeedIfAlreadyGenerated;
import static ch.epfl.culturequest.database.Database.handleFutureTimeout;
import static ch.epfl.culturequest.database.Database.indicateTournamentNotGenerated;
import static ch.epfl.culturequest.database.Database.isTournamentGenerationLocked;
import static ch.epfl.culturequest.database.Database.lockTournamentGeneration;
import static ch.epfl.culturequest.database.Database.unlockTournamentGeneration;
import static ch.epfl.culturequest.database.Database.uploadSeedToDatabase;
import static ch.epfl.culturequest.database.Database.uploadTournamentToDatabase;
import static ch.epfl.culturequest.database.Database.waitForTournamentGenerationAndFetchIt;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import com.google.gson.Gson;
import com.theokanning.openai.service.OpenAiService;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.InputStream;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Random;
import java.util.Scanner;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Supplier;
import ch.epfl.culturequest.BuildConfig;
import ch.epfl.culturequest.R;
import ch.epfl.culturequest.backend.tournament.tournamentobjects.ArtQuiz;
import ch.epfl.culturequest.backend.tournament.tournamentobjects.Tournament;
import ch.epfl.culturequest.backend.tournament.utils.RandomApi;

/**
    * Class in charge of the whole tournament backend management
    * The two accessible functions are: handleTournaments() and getTournamentFromSharedPref()
    * handleTournaments() should be called each time the main activity is created or resumed, it will handle the tournament scheduling and generation
    * getTournamentFromSharedPref() should be called each time the user wants to access the tournament after successfully stored or fetch, it will return the tournament stored in shared preferences
 */

@SuppressLint("NewApi")
public class TournamentManagerApi {

    public static OpenAiService service = new OpenAiService(BuildConfig.OPEN_AI_API_KEY, Duration.ofMinutes(2));
    private static Context currentContext;

    // Main function #1: To be called when most of activities are being resumed
    public static CompletableFuture<Void> handleTournaments(Context context) {

        currentContext = context;

        if (tournamentRemainingTime() == 0) { // A tournament has never been scheduled yet - first time the app is launched

            if(!seedAlreadyStoredInSharedPref()){
                return generateOrFetchSeedThenStore().thenAccept(x -> generateAndStoreTournamentDate());
            }
            generateAndStoreTournamentDate();

        } else if (tournamentRemainingTime() < 0) { // Tournament is over

            // Clear shared preferences, unlock all concurrency related variables, schedule the next tournament
            return setWeeklyTournamentOver(); // asynchronous call

        } else if (isTimeToGenerateTournament() && !tournamentAlreadyStoredInSharedPref()) {

            // generate or fetch tournament once and store it in Shared Preferences to access it easily later
            CompletableFuture<Void> future = generateOrFetchTournamentThenStore(); // asynchronous call
            handleFutureTimeout(future, 180); // timeout after 3 minutes

            return future;
        }
        return CompletableFuture.completedFuture(null);
    }
    // Main function #2: To be called to retrieve the tournament after it has been generated or fetched
    public static Tournament getTournamentFromSharedPref() {

        // Get shared preferences
        SharedPreferences sharedPreferences = getTournamentSharedPrefLocation();

        // Get the JSON string of the stored tournament
        String jsonTournament = sharedPreferences.getString("weeklyTournament", null);

        if (jsonTournament == null) {
            return null;
        }

        // Create a Gson instance
        Gson gson = new Gson();

        // Convert the JSON string back to a Tournament object
        Tournament tournament = gson.fromJson(jsonTournament, Tournament.class);

        return tournament;
    }

    private static CompletableFuture<Void> generateOrFetchTournamentThenStore() {

        CompletableFuture<Void> future = new CompletableFuture<>();

        // Generate or fetch tournament once and store it in Shared Preferences to access it easily later
        generateOrFetchTournament().thenAccept(tournament -> {
            if (tournament != null) {
                storeTournamentInSharedPref(tournament);
            }
            future.complete(null);
        });

        return future;
    }

    // If the tournament generation is locked, another device is currently generating the tournament so we should wait for it to be generated and fetch it from the database
    public static CompletableFuture<Tournament> fetchTournamentWhenGenerated(String tournamentId) {

        AtomicReference<Tournament> fetchedTournament = new AtomicReference<>();
        CompletableFuture<Tournament> future = new CompletableFuture<>();

        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
        executor.schedule(() -> {
            if (fetchedTournament.get() == null) {
                // If the tournament hasn't been generated 2 minutes after the generation lock, it's likely that the device that was generating it crashed so we should unlock the generation and try to generate it again
                // we make the future complete exceptionally so that the caller (generateOrFetchTournament) can handle it and try to generate the tournament again if needed
                future.completeExceptionally(new TimeoutException("Failed to fetch the tournament from the database after 2 minutes"));
            }
        }, 2, TimeUnit.MINUTES);

        return waitForTournamentGenerationAndFetchIt(fetchedTournament, future, tournamentId);
    }

    private static void generateAndStoreTournamentDate() {

        // To be called only if tournamentRemainingTime <= 0 (i.e. tournament is over or has not been generated yet)
        if (tournamentRemainingTime() > 0) {
            return;
        }

        Calendar tournamentDate = generateWeeklyTournamentDate();

        // Store calendar.getTime() in shared preferences
        SharedPreferences sharedPref = getTournamentSharedPrefLocation();
        SharedPreferences.Editor editor = sharedPref.edit();
        editor.putLong("tournamentDate", tournamentDate.getTime().getTime());
        editor.apply();
    }

    private static Boolean isTimeToGenerateTournament() {

        long tournamentDate = getTournamentSharedPrefLocation().getLong("tournamentDate", 0);

        if (tournamentDate == 0) {
            return false;
        }

        Calendar calendar = Calendar.getInstance();
        long currentTime = calendar.getTime().getTime();

        return currentTime > tournamentDate;
    }

    // Remaining time from now until the tournament ends (tournament date + 1 week)
    // If tournament end date is in the past, return value is negative
    private static int tournamentRemainingTime() {

        // a tournament should last 1 week
        int tournamentDuration = 7 * 24 * 60 * 60 * 1000;

        long tournamentDate = getTournamentSharedPrefLocation().getLong("tournamentDate", 0);

        if (tournamentDate == 0) {
            return 0;
        }

        Calendar calendar = Calendar.getInstance();
        long currentTime = calendar.getTime().getTime();

        long remainingTime = tournamentDate + tournamentDuration - currentTime;

        return (int) remainingTime;

    }

    // If the tournament has not been generated yet, generate it and upload it to the database
    // If the tournament has already been generated, fetch it from the database and return it
    private static CompletableFuture<Tournament> generateOrFetchTournament() {

        return isTournamentGenerationLocked().thenCompose(generationLocked -> {
            if (generationLocked) {
                return fetchTournamentWhenGeneratedAndHandleGenerationTimeout();
            }
            else {
                // lock the tournament generation to prevent other users from generating it at the same time
                lockTournamentGeneration();

                //// Generate the tournament ////
                ArrayList<String> artNames = randomlyChooseArtNames();

                Map<String, CompletableFuture<ArtQuiz>> artQuizFutures = generateTournamentQuizzesGivenArtNames(artNames);

                CompletableFuture<ArtQuiz>[] futuresArray = artQuizFutures.values().toArray(new CompletableFuture[0]);

                // wait for all the art quizzes to be generated then create the tournament and upload it to the database
                return CompletableFuture.allOf(futuresArray)
                        .thenApply(x -> createAndUploadTournamentFromQuizzes(artQuizFutures));
            }
        });
    }

    // This method is responsible for fetching the tournament from database (call to fetchTournamentWhenGenerated) and handling the case where the tournament takes too long to be generated, in which case we unlock the generation and try to generate it again
    private static CompletableFuture<Tournament> fetchTournamentWhenGeneratedAndHandleGenerationTimeout(){

        String weeklyTournamentId = RandomApi.getWeeklyTournamentPseudoRandomUUID();

        return fetchTournamentWhenGenerated(weeklyTournamentId)
                .handle((result, ex) -> {
                    if (ex != null && ex.getCause() instanceof TimeoutException) {
                        if (ex.getCause() instanceof TimeoutException) {

                            // If the device that firstly took the generation lead at least 2 minutes after the lock didn't generate the tournament yet, we consider that this device is finito, so we unlock the generation, take the lead and generate the tournament
                            return unlockTournamentGeneration().thenCompose(unlocked -> {
                                if (unlocked) {
                                    return generateOrFetchTournament();
                                } else {
                                    throw new RuntimeException("Failed to unlock tournament generation", ex);
                                }
                            });
                        } else {
                            throw new CompletionException(ex);
                        }
                    } else {
                        return CompletableFuture.completedFuture(result);
                    }
                }).thenCompose(Function.identity());
    }

    private static Map<String, CompletableFuture<ArtQuiz>> generateTournamentQuizzesGivenArtNames(ArrayList<String> artNames) {

        Map<String, CompletableFuture<ArtQuiz>> artQuizFutures = new HashMap<>();

        Supplier<ArtQuiz> fallBack = () -> null;

        for (String artName : artNames) {
            Supplier<CompletableFuture<ArtQuiz>> quizGenerator = () -> new QuizGeneratorApi(service).generateArtQuiz(artName);
            CompletableFuture<ArtQuiz> artQuizFuture = RetryFuture.ExecWithRetryOrFallback(quizGenerator, fallBack, 2);
            artQuizFutures.put(artName, artQuizFuture);
        }

        return artQuizFutures;
    }

    private static Tournament createAndUploadTournamentFromQuizzes(Map<String, CompletableFuture<ArtQuiz>> completedQuizzesMappedByArtName){

        Map<String, ArtQuiz> artQuizzes = new HashMap<>();
        for (Map.Entry<String, CompletableFuture<ArtQuiz>> entry : completedQuizzesMappedByArtName.entrySet()) {
            try {

                ArtQuiz quiz = entry.getValue().get();

                // A null quiz means that the quiz generation failed after 2 retries, the tournament would fail to be generated so we should abort and unlock
                if (quiz == null) {
                    // unlock the tournament generation so that another user can try to generate it
                    unlockTournamentGeneration();
                    return null;
                }

                artQuizzes.put(entry.getKey(), quiz);
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
        }

        Tournament tournament = new Tournament(artQuizzes);
        uploadTournamentToDatabase(tournament);

        return tournament;
    }

    private static ArrayList<String> randomlyChooseArtNames() {
        Random random = RandomApi.getRandom();

        ArrayList<String> artNames = new ArrayList<>();
        try {
            Resources resources = currentContext.getResources();
            InputStream inputStream = resources.openRawResource(R.raw.famous_arts);
            // InputStream to JSONArray
            String json;
            try (Scanner s = new Scanner(inputStream)) {
                json = s.useDelimiter("\\A").hasNext() ? s.next() : "";
            }
            JSONObject jsonObj = new JSONObject(json);
            JSONArray jsonArray = jsonObj.getJSONArray("artworks");

            // Randomly choose three artworks from the JSON array
            int numArtworks = jsonArray.length();
            int numArtNamesToChoose = 3;
            Set<Integer> chosenIndices = new HashSet<>();

            while (chosenIndices.size() < numArtNamesToChoose) {
                int randomIndex = random.nextInt(numArtworks);
                if (!chosenIndices.contains(randomIndex)) {
                    JSONObject artworkObject = jsonArray.getJSONObject(randomIndex);
                    String artName = artworkObject.getString("artName");
                    artNames.add(artName);
                    chosenIndices.add(randomIndex);
                }}
        } catch (JSONException e) {
            e.printStackTrace();}
        return artNames;
    }
    public static void storeTournamentInSharedPref(Tournament tournament) {

        SharedPreferences sharedPreferences = getTournamentSharedPrefLocation();
        Gson gson = new Gson();
        String jsonTournament = gson.toJson(tournament);
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putString("weeklyTournament", jsonTournament);
        editor.apply();
    }

    private static CompletableFuture<Void> setWeeklyTournamentOver() {

        // We remember the current seed before cleaning the shared pref
        Long currentSeed = getCurrentSeed();

        clearTournamentSharedPref();

        CompletableFuture<Void> newSeedHandled = handleNewSeed(currentSeed).thenAccept(x -> {
            generateAndStoreTournamentDate();
        });

        CompletableFuture<Boolean> unlockTournamentGenerationFuture = unlockTournamentGeneration();
        CompletableFuture<Boolean> indicateTournamentNotGeneratedFuture = indicateTournamentNotGenerated();

        return CompletableFuture.allOf(unlockTournamentGenerationFuture, indicateTournamentNotGeneratedFuture, newSeedHandled);
    }

    public static SharedPreferences getTournamentSharedPrefLocation() {

        return currentContext.getSharedPreferences("tournament", Context.MODE_PRIVATE);
    }

    private static void clearTournamentSharedPref() {
        SharedPreferences sharedPreferences = getTournamentSharedPrefLocation();
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.clear();
        editor.apply();
    }
    private static boolean tournamentAlreadyStoredInSharedPref() {
        SharedPreferences sharedPreferences = getTournamentSharedPrefLocation();
        return sharedPreferences.getString("weeklyTournament", null) != null;
    }

}