package com.battlelancer.seriesguide.backend; import android.annotation.SuppressLint; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.os.SystemClock; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.format.DateUtils; import com.battlelancer.seriesguide.SgApp; import com.battlelancer.seriesguide.backend.settings.HexagonSettings; import com.battlelancer.seriesguide.items.SearchResult; import com.battlelancer.seriesguide.provider.SeriesGuideContract; import com.battlelancer.seriesguide.sync.SgSyncAdapter; import com.battlelancer.seriesguide.ui.ListsActivity; import com.battlelancer.seriesguide.util.EpisodeTools; import com.battlelancer.seriesguide.util.ListsTools; import com.battlelancer.seriesguide.util.MovieTools; import com.battlelancer.seriesguide.util.ShowTools; import com.battlelancer.seriesguide.util.TaskManager; import com.battlelancer.seriesguide.util.Utils; import com.google.android.gms.auth.api.Auth; import com.google.android.gms.auth.api.signin.GoogleSignInAccount; import com.google.android.gms.auth.api.signin.GoogleSignInOptions; import com.google.android.gms.auth.api.signin.GoogleSignInResult; import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.api.GoogleApiClient; import com.google.android.gms.common.api.OptionalPendingResult; import com.google.android.gms.common.api.Status; import com.google.api.client.extensions.android.http.AndroidHttp; import com.google.api.client.extensions.android.json.AndroidJsonFactory; import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.JsonFactory; import com.uwetrottmann.androidutils.AndroidUtils; import com.uwetrottmann.seriesguide.backend.account.Account; import com.uwetrottmann.seriesguide.backend.episodes.Episodes; import com.uwetrottmann.seriesguide.backend.lists.Lists; import com.uwetrottmann.seriesguide.backend.movies.Movies; import com.uwetrottmann.seriesguide.backend.shows.Shows; import java.io.IOException; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import org.greenrobot.eventbus.EventBus; import timber.log.Timber; /** * Handles credentials and services for interacting with Hexagon. */ public class HexagonTools { private static final String HEXAGON_ERROR_CATEGORY = "Hexagon Error"; private static final String SIGN_IN_ERROR_CATEGORY = "Sign-in Error"; private static final String ACTION_SILENT_SIGN_IN = "silent sign-in"; private static final JsonFactory JSON_FACTORY = new AndroidJsonFactory(); private static final HttpTransport HTTP_TRANSPORT = AndroidHttp.newCompatibleTransport(); private static final long SIGN_IN_CHECK_INTERVAL_MS = 5 * DateUtils.MINUTE_IN_MILLIS; private static GoogleSignInOptions googleSignInOptions; private final SgApp app; private GoogleApiClient googleApiClient; private GoogleAccountCredential credential; private long lastSignInCheck; private Shows showsService; private Episodes episodesService; private Movies moviesService; private Lists listsService; public HexagonTools(SgApp app) { this.app = app; } /** * Enables Hexagon, resets sync state and saves account data. * * @return <code>false</code> if sync state could not be reset. */ public boolean setEnabled(@NonNull GoogleSignInAccount account) { if (!HexagonSettings.resetSyncState(app)) { return false; } if (!PreferenceManager.getDefaultSharedPreferences(app).edit() .putBoolean(HexagonSettings.KEY_ENABLED, true) .putBoolean(HexagonSettings.KEY_SHOULD_VALIDATE_ACCOUNT, false) .commit()) { return false; } storeAccount(account); return true; } /** * Disables Hexagon and removes any account data. */ public void setDisabled() { PreferenceManager.getDefaultSharedPreferences(app).edit() .putBoolean(HexagonSettings.KEY_ENABLED, false) .putBoolean(HexagonSettings.KEY_SHOULD_VALIDATE_ACCOUNT, false) .apply(); storeAccount(null); } /** * Creates and returns a new instance for this hexagon service or null if not signed in. * * Warning: checks sign-in state, make sure to guard with {@link HexagonSettings#isEnabled}. */ @Nullable public synchronized Account buildAccountService() { GoogleAccountCredential credential = getAccountCredential(true); if (credential.getSelectedAccount() == null) { return null; } Account.Builder builder = new Account.Builder( HTTP_TRANSPORT, JSON_FACTORY, credential ); return CloudEndpointUtils.updateBuilder(app, builder).build(); } /** * Returns the instance for this hexagon service or null if not signed in. * * Warning: checks sign-in state, make sure to guard with {@link HexagonSettings#isEnabled}. */ @Nullable public synchronized Shows getShowsService() { GoogleAccountCredential credential = getAccountCredential(true); if (credential.getSelectedAccount() == null) { return null; } if (showsService == null) { Shows.Builder builder = new Shows.Builder( HTTP_TRANSPORT, JSON_FACTORY, credential ); showsService = CloudEndpointUtils.updateBuilder(app, builder).build(); } return showsService; } /** * Returns the instance for this hexagon service or null if not signed in. * * Warning: checks sign-in state, make sure to guard with {@link HexagonSettings#isEnabled}. */ @Nullable public synchronized Episodes getEpisodesService() { GoogleAccountCredential credential = getAccountCredential(true); if (credential.getSelectedAccount() == null) { return null; } if (episodesService == null) { Episodes.Builder builder = new Episodes.Builder( HTTP_TRANSPORT, JSON_FACTORY, credential ); episodesService = CloudEndpointUtils.updateBuilder(app, builder).build(); } return episodesService; } /** * Returns the instance for this hexagon service or null if not signed in. * * Warning: checks sign-in state, make sure to guard with {@link HexagonSettings#isEnabled}. */ @Nullable public synchronized Movies getMoviesService() { GoogleAccountCredential credential = getAccountCredential(true); if (credential.getSelectedAccount() == null) { return null; } if (moviesService == null) { Movies.Builder builder = new Movies.Builder( HTTP_TRANSPORT, JSON_FACTORY, credential ); moviesService = CloudEndpointUtils.updateBuilder(app, builder).build(); } return moviesService; } /** * Returns the instance for this hexagon service or null if not signed in. */ @Nullable public synchronized Lists getListsService() { GoogleAccountCredential credential = getAccountCredential(true); if (credential.getSelectedAccount() == null) { return null; } if (listsService == null) { Lists.Builder builder = new Lists.Builder( HTTP_TRANSPORT, JSON_FACTORY, credential ); listsService = CloudEndpointUtils.updateBuilder(app, builder).build(); } return listsService; } /** * Get the Google account credentials to talk with Hexagon. * * <p>Make sure to check {@link GoogleAccountCredential#getSelectedAccount()} is not null (the * account might have gotten signed out). * * @param checkSignInState If enabled, tries to silently sign in with Google. If it fails, sets * the {@link HexagonSettings#KEY_SHOULD_VALIDATE_ACCOUNT} flag. If successful, clears the * flag. */ private synchronized GoogleAccountCredential getAccountCredential(boolean checkSignInState) { if (credential == null) { credential = GoogleAccountCredential.usingAudience(app.getApplicationContext(), HexagonSettings.AUDIENCE); } if (checkSignInState) { checkSignInState(); } return credential; } private void checkSignInState() { if (credential.getSelectedAccount() != null && !isTimeForSignInStateCheck()) { Timber.d("%s: just checked state, skip", ACTION_SILENT_SIGN_IN); } lastSignInCheck = SystemClock.elapsedRealtime(); if (googleApiClient == null) { googleApiClient = new GoogleApiClient.Builder(app) .addApi(Auth.GOOGLE_SIGN_IN_API, getGoogleSignInOptions()) .build(); } android.accounts.Account account = null; ConnectionResult connectionResult = googleApiClient.blockingConnect(); if (connectionResult.isSuccess()) { OptionalPendingResult<GoogleSignInResult> pendingResult = Auth.GoogleSignInApi.silentSignIn(googleApiClient); GoogleSignInResult result = pendingResult.await(); if (result.isSuccess()) { GoogleSignInAccount signInAccount = result.getSignInAccount(); if (signInAccount != null) { Timber.i("%s: successful", ACTION_SILENT_SIGN_IN); account = signInAccount.getAccount(); credential.setSelectedAccount(account); } else { trackSignInFailure(ACTION_SILENT_SIGN_IN, "GoogleSignInAccount is null"); } } else { trackSignInFailure(ACTION_SILENT_SIGN_IN, result.getStatus()); } googleApiClient.disconnect(); } else { trackSignInFailure(ACTION_SILENT_SIGN_IN, connectionResult); } boolean shouldFixAccount = account == null; PreferenceManager.getDefaultSharedPreferences(app).edit() .putBoolean(HexagonSettings.KEY_SHOULD_VALIDATE_ACCOUNT, shouldFixAccount) .apply(); } private boolean isTimeForSignInStateCheck() { return lastSignInCheck + SIGN_IN_CHECK_INTERVAL_MS < SystemClock.elapsedRealtime(); } /** * Sets the account used for calls to Hexagon and saves the email address to display it in UI. */ private void storeAccount(@Nullable GoogleSignInAccount account) { // store or remove account name in settings PreferenceManager.getDefaultSharedPreferences(app).edit() .putString(HexagonSettings.KEY_ACCOUNT_NAME, account != null ? account.getEmail() : null) .apply(); // try to set or remove account on credential getAccountCredential(false).setSelectedAccount(account != null ? account.getAccount() : null); } @NonNull public static GoogleSignInOptions getGoogleSignInOptions() { if (googleSignInOptions == null) { googleSignInOptions = new GoogleSignInOptions .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestEmail() .build(); } return googleSignInOptions; } public static void trackFailedRequest(Context context, String action, IOException e) { if (e instanceof HttpResponseException) { HttpResponseException responseException = (HttpResponseException) e; Utils.trackCustomEvent(context, HEXAGON_ERROR_CATEGORY, action, responseException.getStatusCode() + " " + responseException.getStatusMessage()); // log like "action: 404 not found" Timber.e("%s: %s %s", action, responseException.getStatusCode(), responseException.getStatusMessage()); } else { Utils.trackCustomEvent(context, HEXAGON_ERROR_CATEGORY, action, e.getMessage()); // log like "action: Unable to resolve host" Timber.e("%s: %s", action, e.getMessage()); } } public void trackSignInFailure(String action, ConnectionResult connectionResult) { String failureMessage = connectionResult.getErrorCode() + " " + connectionResult.getErrorMessage(); trackSignInFailure(action, failureMessage); } public void trackSignInFailure(String action, Status status) { String failureMessage = GoogleSignInStatusCodes.getStatusCodeString(status.getStatusCode()); trackSignInFailure(action, failureMessage); } private void trackSignInFailure(String action, String failureMessage) { Utils.trackCustomEvent(app, SIGN_IN_ERROR_CATEGORY, action, failureMessage); Timber.e("%s: %s", action, failureMessage); } /** * Syncs episodes, shows and movies with Hexagon. * * <p> Merges shows, episodes and movies after a sign-in. Consecutive syncs will only download * changes to shows, episodes and movies. */ public static boolean syncWithHexagon(SgApp app, HashSet<Integer> existingShows, HashMap<Integer, SearchResult> newShows) { Timber.d("syncWithHexagon: syncing..."); //// EPISODES boolean syncEpisodesSuccessful = syncEpisodes(app); Timber.d("syncWithHexagon: episode sync %s", syncEpisodesSuccessful ? "SUCCESSFUL" : "FAILED"); //// SHOWS boolean syncShowsSuccessful = syncShows(app, existingShows, newShows); Timber.d("syncWithHexagon: show sync %s", syncShowsSuccessful ? "SUCCESSFUL" : "FAILED"); //// MOVIES boolean syncMoviesSuccessful = syncMovies(app); Timber.d("syncWithHexagon: movie sync %s", syncMoviesSuccessful ? "SUCCESSFUL" : "FAILED"); //// LISTS boolean syncListsSuccessful = syncLists(app); Timber.d("syncWithHexagon: lists sync %s", syncListsSuccessful ? "SUCCESSFUL" : "FAILED"); Timber.d("syncWithHexagon: syncing...DONE"); return syncEpisodesSuccessful && syncShowsSuccessful && syncMoviesSuccessful && syncListsSuccessful; } private static boolean syncEpisodes(SgApp app) { // get shows that need episode merging Cursor query = app.getContentResolver().query(SeriesGuideContract.Shows.CONTENT_URI, new String[] { SeriesGuideContract.Shows._ID }, SeriesGuideContract.Shows.HEXAGON_MERGE_COMPLETE + "=0", null, null); if (query == null) { return false; } // try merging episodes for them boolean mergeSuccessful = true; while (query.moveToNext()) { // abort if connection is lost if (!AndroidUtils.isNetworkConnected(app)) { return false; } int showTvdbId = query.getInt(0); boolean success = EpisodeTools.Download.flagsFromHexagon(app, showTvdbId); if (!success) { // try again next time mergeSuccessful = false; continue; } success = EpisodeTools.Upload.flagsToHexagon(app, showTvdbId); if (success) { // set merge as completed ContentValues values = new ContentValues(); values.put(SeriesGuideContract.Shows.HEXAGON_MERGE_COMPLETE, true); app.getContentResolver() .update(SeriesGuideContract.Shows.buildShowUri(showTvdbId), values, null, null); } else { mergeSuccessful = false; } } query.close(); // download changed episodes and update properties on existing episodes boolean changedDownloadSuccessful = EpisodeTools.Download.flagsFromHexagon(app); return mergeSuccessful && changedDownloadSuccessful; } private static boolean syncShows(SgApp app, HashSet<Integer> existingShows, HashMap<Integer, SearchResult> newShows) { boolean hasMergedShows = HexagonSettings.hasMergedShows(app); // download shows and apply property changes (if merging only overwrite some properties) boolean downloadSuccessful = ShowTools.Download.fromHexagon(app, existingShows, newShows, hasMergedShows); if (!downloadSuccessful) { return false; } // if merge required, upload all shows to Hexagon if (!hasMergedShows) { boolean uploadSuccessful = ShowTools.Upload.toHexagon(app); if (!uploadSuccessful) { return false; } } // add new shows if (newShows.size() > 0) { List<SearchResult> newShowsList = new LinkedList<>(newShows.values()); TaskManager.getInstance(app).performAddTask(app, newShowsList, true, !hasMergedShows); } else if (!hasMergedShows) { // set shows as merged HexagonSettings.setHasMergedShows(app, true); } return true; } @SuppressLint("ApplySharedPref") private static boolean syncMovies(SgApp app) { boolean hasMergedMovies = HexagonSettings.hasMergedMovies(app); // download movies and apply property changes, build list of new movies Set<Integer> newCollectionMovies = new HashSet<>(); Set<Integer> newWatchlistMovies = new HashSet<>(); boolean downloadSuccessful = MovieTools.Download.fromHexagon(app, newCollectionMovies, newWatchlistMovies, hasMergedMovies); if (!downloadSuccessful) { return false; } if (!hasMergedMovies) { boolean uploadSuccessful = MovieTools.Upload.toHexagon(app); if (!uploadSuccessful) { return false; } } // add new movies with the just downloaded properties SgSyncAdapter.UpdateResult result = app.getMovieTools() .addMovies(newCollectionMovies, newWatchlistMovies); boolean addingSuccessful = result == SgSyncAdapter.UpdateResult.SUCCESS; if (!hasMergedMovies) { // ensure all missing movies from Hexagon are added before merge is complete if (!addingSuccessful) { return false; } // set movies as merged PreferenceManager.getDefaultSharedPreferences(app) .edit() .putBoolean(HexagonSettings.KEY_MERGED_MOVIES, true) .commit(); } return addingSuccessful; } @SuppressLint("ApplySharedPref") private static boolean syncLists(SgApp app) { boolean hasMergedLists = HexagonSettings.hasMergedLists(app); if (!ListsTools.downloadFromHexagon(app, hasMergedLists)) { return false; } if (hasMergedLists) { // on regular syncs, remove lists gone from hexagon if (!ListsTools.removeListsRemovedOnHexagon(app)) { return false; } } else { // upload all lists on initial data merge if (!ListsTools.uploadAllToHexagon(app)) { return false; } } // notify lists activity EventBus.getDefault().post(new ListsActivity.ListsChangedEvent()); if (!hasMergedLists) { // set lists as merged PreferenceManager.getDefaultSharedPreferences(app) .edit() .putBoolean(HexagonSettings.KEY_MERGED_LISTS, true) .commit(); } return true; } }