/* * Copyright (c) 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.google.samples.apps.iosched.archframework; import android.app.LoaderManager; import android.content.Loader; import android.database.Cursor; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.google.common.annotations.VisibleForTesting; import com.google.samples.apps.iosched.session.SessionDetailModel; import java.util.HashMap; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.samples.apps.iosched.util.LogUtils.LOGE; import static com.google.samples.apps.iosched.util.LogUtils.makeLogTag; /** * Implementation class for {@link Model}, using the {@link LoaderManager} callbacks to query the * data from the {@link com.google.samples.apps.iosched.provider.ScheduleProvider}. */ public abstract class ModelWithLoaderManager<Q extends QueryEnum, UA extends UserActionEnum> implements Model<Q, UA>, LoaderManager.LoaderCallbacks<Cursor> { /** * Key to be used in Bundle passed in {@link #onUserAction(UserActionEnum, Bundle)} for a user * action that requires running {@link QueryEnum}, specifying its id. The value stored must be * an Integer. */ public static final String KEY_RUN_QUERY_ID = "KEY_RUN_QUERY_ID"; private static final String TAG = makeLogTag(ModelWithLoaderManager.class); private LoaderManager mLoaderManager; private Q[] mQueries; private UA[] mUserActions; /** * Map of callbacks, using the query as key. This is required because we can't pass on the * {@link com.google.samples.apps.iosched.archframework.Model.DataQueryCallback} to the {@link * LoaderManager} callbacks. * <p/> * This is @VisibleForTesting because for integration testing, a fake model is used to allow * bypassing the {@link LoaderManager} and pass a mock {@link Cursor} directly to {@link * #onLoadFinished(QueryEnum, Cursor)} and adding the callback so events can be fired normally * on the callback after the data is read from the cursor. */ @VisibleForTesting protected HashMap<Q, DataQueryCallback> mDataQueryCallbacks = new HashMap<Q, DataQueryCallback>(); /** * Map of callbacks, using the id of the user action as key. This is required because some user * actions launch a data query and we can't pass on the {@link com.google.samples.apps * .iosched.archframework.Model.UserActionCallback} to the {@link LoaderManager} callbacks. * <p/> * When the user action leads to a new query being run, the {@link LoaderManager} callbacks * provide us with an Integer id. Therefore, we link an Integer id to a callback, and use a * separate map to link the Integer id to a user action {}see {@link * #mUserActionsLaunchingQueries}. * <p/> * This is @VisibleForTesting because for integration testing, a fake model is used to allow * bypassing the {@link LoaderManager} and pass a mock {@link Cursor} directly to {@link * #onLoadFinished(QueryEnum, Cursor)} and adding the callback so events can be fired normally * on the callback after the data is read from the cursor. */ @VisibleForTesting protected HashMap<Integer, UserActionCallback> mDataUpdateCallbacks = new HashMap<Integer, UserActionCallback>(); /** * Map of user actions that have launched queries, using their id as key. This is used in * conjunction with {@link #mDataUpdateCallbacks}, so once the {@link * android.app.LoaderManager.LoaderCallbacks#onLoadFinished(Loader, Object)} has fired, the * {@link UserActionCallback} that launched that query can be fired. * <p/> * This is @VisibleForTesting because for integration testing, a fake model is used to allow * bypassing the {@link LoaderManager} and pass a mock {@link Cursor} directly to {@link * #onLoadFinished(QueryEnum, Cursor)} and adding the callback so events can be fired normally * on the callback after the data is read from the cursor. */ @VisibleForTesting protected HashMap<Integer, UA> mUserActionsLaunchingQueries = new HashMap<Integer, UA>(); public ModelWithLoaderManager(Q[] queries, UA[] userActions, LoaderManager loaderManager) { mQueries = queries; mUserActions = userActions; mLoaderManager = loaderManager; } @Override public Q[] getQueries() { return mQueries; } @Override public UA[] getUserActions() { return mUserActions; } /** * Called when the user has performed an {@code action}, with data in {@code args}. * <p/> * Add the constants used to store values in the bundle to the Model implementation class as * final static protected strings. * <p/> * If the {@code action} should trigger a new data query, specify the query ID by storing the * associated Integer in the {@code args} using {@link #KEY_RUN_QUERY_ID}. The {@code args} will * be passed on to the cursor loader so you can pass in extra arguments for your query. */ @Override public void deliverUserAction(@NonNull UA action, @Nullable Bundle args, @NonNull UserActionCallback callback) { checkNotNull(callback); checkNotNull(action); if (args != null && args.containsKey(KEY_RUN_QUERY_ID)) { Object queryId = args.get(KEY_RUN_QUERY_ID); if (queryId instanceof Integer) { if (isQueryValid((Integer) queryId) && mLoaderManager != null) { mLoaderManager.restartLoader((Integer) queryId, args, this); mDataUpdateCallbacks.put((Integer) queryId, callback); mUserActionsLaunchingQueries.put((Integer) queryId, action); } else if (isQueryValid((Integer) queryId) && mLoaderManager == null) { // The loader manager hasn't been initialised because initial queries haven't // been run yet. This happens when a user action is triggered by a change in // shared preferences before the initial queries are loaded. Unlikely to happen // often, but it is a possible race condition and it was triggered in UI // tests. Nothing to do in that case because presenter will run all queries // when it will go through loadInitialQueries. } else { callback.onError(action); // Query id should be valid! LOGE(TAG, "onUserAction called with a bundle containing KEY_RUN_QUERY_ID but" + "the value is not a valid query id!"); } } else { callback.onError(action); // Query id should be an integer! LOGE(TAG, "onUserAction called with a bundle containing KEY_RUN_QUERY_ID but" + "the value is not an Integer so it's not a valid query id!"); } } else { processUserAction(action, args, callback); } } /** * This should be implemented by the feature. Typically, there will be a switch on the {@code * action}, a method will be called to update the data, then the callback will be fired. * * @see SessionDetailModel#processUserAction(UA, @Nullable Bundle, UserActionCallback ) */ public abstract void processUserAction(UA action, @Nullable Bundle args, UserActionCallback callback); @Override public void requestData(@NonNull Q query, @NonNull DataQueryCallback callback) { checkNotNull(query); checkNotNull(callback); if (isQueryValid(query)) { mLoaderManager.initLoader(query.getId(), null, this); mDataQueryCallbacks.put(query, callback); } else { LOGE(TAG, "Invalid query " + query); callback.onError(query); } } private boolean isQueryValid(@NonNull Q query) { checkNotNull(query); return isQueryValid(query.getId()); } private boolean isQueryValid(int queryId) { Q match = (Q) QueryEnumHelper.getQueryForId(queryId, getQueries()); return match != null; } @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { return createCursorLoader((Q) QueryEnumHelper.getQueryForId(id, mQueries), args); } /** * This should be implemented by the feature. In addition to the {@link * QueryEnum#getProjection()}, other information such as sorting order will be needed. */ public abstract Loader<Cursor> createCursorLoader(Q query, Bundle args); /** * This should be implemented by the feature. It reads the data from the {@code cursor} for the * given {@code query}. Typically, there will be a switch on the {@code query}, then a private * method will be called to read the data from the cursor. */ public abstract boolean readDataFromCursor(Cursor cursor, Q query); @Override public void onLoadFinished(Loader<Cursor> loader, Cursor data) { Q query = (Q) QueryEnumHelper.getQueryForId(loader.getId(), mQueries); onLoadFinished(query, data); } /** * This method is called directly from integration tests to allow us to pass in a mock cursor, * so we can stub out different data and thus test the UI fully. */ @VisibleForTesting public void onLoadFinished(Q query, Cursor data) { boolean success = readDataFromCursor(data, query); if (mDataUpdateCallbacks.containsKey(query.getId()) && mUserActionsLaunchingQueries.containsKey(query.getId())) { UserActionCallback callback = mDataUpdateCallbacks.get(query.getId()); UA userAction = mUserActionsLaunchingQueries.get(query.getId()); if (success) { callback.onModelUpdated(this, userAction); } else { callback.onError(userAction); } } else if (mDataQueryCallbacks.containsKey(query) && mDataQueryCallbacks.get(query) != null) { DataQueryCallback callback = mDataQueryCallbacks.get(query); if (success) { callback.onModelUpdated(this, query); } else { callback.onError(query); } } } @Override public void onLoaderReset(Loader<Cursor> loader) { // Not used } }