Bug 1177855: Fetch and show avatar image as preference icon. r=nalexander

The profile JSON is stored in the Account bundle.  There's no need to
bump the bundle version, since missing (i.e., null) profile JSON is
legal.

This introduces and uses a general-purpose PicassoPreferenceIcon
Picasso Target that, on API 11+ devices, dynamically loads a
preference icon.
This commit is contained in:
vivek 2015-06-30 21:09:44 -07:00
parent 5785e7b90a
commit 5b362f8911
8 changed files with 209 additions and 168 deletions

View File

@ -863,6 +863,7 @@ sync_java_files = [
'fxa/activities/FxAccountStatusFragment.java',
'fxa/activities/FxAccountUpdateCredentialsActivity.java',
'fxa/activities/FxAccountVerifiedAccountActivity.java',
'fxa/activities/PicassoPreferenceIconTarget.java',
'fxa/authenticator/AccountPickler.java',
'fxa/authenticator/AndroidFxAccount.java',
'fxa/authenticator/FxAccountAuthenticator.java',

View File

@ -24,7 +24,7 @@ public class FxAccountConstants {
public static final String STAGE_PROFILE_SERVER_ENDPOINT = "https://latest.dev.lcip.org/profile/v1";
// Action to update on cached profile information.
public static final String ACCOUNT_PROFILE_AVATAR_UPDATED_ACTION = "org.mozilla.gecko.fxa.profile.cached";
public static final String ACCOUNT_PROFILE_JSON_UPDATED_ACTION = "org.mozilla.gecko.fxa.profile.JSON.updated";
// You must be at least 13 years old, on the day of creation, to create a Firefox Account.
public static final int MINIMUM_AGE_TO_CREATE_AN_ACCOUNT = 13;

View File

@ -50,6 +50,8 @@ import android.text.TextUtils;
import android.text.format.DateUtils;
import android.widget.Toast;
import com.squareup.picasso.Picasso;
import com.squareup.picasso.Target;
/**
* A fragment that displays the status of an AndroidFxAccount.
@ -140,13 +142,11 @@ public class FxAccountStatusFragment
// Runnable to update last synced time.
protected Runnable lastSyncedTimeUpdateRunnable;
// Runnable to retry fetching profile information.
protected Runnable profileFetchRunnable;
// Broadcast Receiver to update profile Information.
protected FxAccountProfileInformationReceiver accountProfileInformationReceiver;
protected final InnerSyncStatusDelegate syncStatusDelegate = new InnerSyncStatusDelegate();
private Target profileAvatarTarget;
protected Preference ensureFindPreference(String key) {
Preference preference = findPreference(key);
@ -485,6 +485,18 @@ public class FxAccountStatusFragment
// register/unregister calls.
FxAccountSyncStatusHelper.getInstance().startObserving(syncStatusDelegate);
if (AppConstants.MOZ_ANDROID_FIREFOX_ACCOUNT_PROFILES) {
// Register a local broadcast receiver to get profile cached notification.
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION);
accountProfileInformationReceiver = new FxAccountProfileInformationReceiver();
LocalBroadcastManager.getInstance(getActivity()).registerReceiver(accountProfileInformationReceiver, intentFilter);
// profilePreference is set during onCreate, so it's definitely not null here.
final float cornerRadius = getResources().getDimension(R.dimen.fxaccount_profile_image_width) / 2;
profileAvatarTarget = new PicassoPreferenceIconTarget(getResources(), profilePreference, cornerRadius);
}
refresh();
}
@ -498,14 +510,15 @@ public class FxAccountStatusFragment
handler.removeCallbacks(lastSyncedTimeUpdateRunnable);
}
if (profileFetchRunnable != null) {
handler.removeCallbacks(profileFetchRunnable);
}
// Focus lost, unregister broadcast receiver.
if (accountProfileInformationReceiver != null) {
LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(accountProfileInformationReceiver);
}
if (profileAvatarTarget != null) {
Picasso.with(getActivity()).cancelRequest(profileAvatarTarget);
profileAvatarTarget = null;
}
}
protected void hardRefresh() {
@ -606,53 +619,60 @@ public class FxAccountStatusFragment
return;
}
final ExtendedJSONObject cachedProfileJSON = fxAccount.getCachedProfileJSON();
if (cachedProfileJSON != null) {
// Update profile information from the cached Json.
updateProfileInformation(cachedProfileJSON);
final ExtendedJSONObject profileJSON = fxAccount.getProfileJSON();
if (profileJSON == null) {
// Update the profile title with email as the fallback.
// Profile icon by default use the default avatar as the fallback.
profilePreference.setTitle(fxAccount.getEmail());
return;
}
// Update the profile title with email as the fallback.
// Profile icon by default use the default avatar as the fallback.
profilePreference.setTitle(fxAccount.getEmail());
// Register a local broadcast receiver to get profile cached notification.
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(FxAccountConstants.ACCOUNT_PROFILE_AVATAR_UPDATED_ACTION);
accountProfileInformationReceiver = new FxAccountProfileInformationReceiver();
LocalBroadcastManager.getInstance(getActivity()).registerReceiver(accountProfileInformationReceiver, intentFilter);
// Fetch the profile from the server.
fxAccount.maybeUpdateProfileJSON(false);
// Schedule an runnable to retry fetching profile.
profileFetchRunnable = new ProfileFetchUpdateRunnable();
handler.postDelayed(profileFetchRunnable, PROFILE_FETCH_RETRY_INTERVAL_IN_MILLISECONDS);
updateProfileInformation(profileJSON);
}
/**
* Update profile information from json on UI thread.
*
* @param profileJson json fetched from server.
* @param profileJSON json fetched from server.
*/
protected void updateProfileInformation(final ExtendedJSONObject profileJson) {
// Remove the scheduled runnable for fetching the profile information.
if (profileFetchRunnable != null) {
handler.removeCallbacks(profileFetchRunnable);
protected void updateProfileInformation(final ExtendedJSONObject profileJSON) {
// View changes must always be done on UI thread.
ThreadUtils.assertOnUiThread();
FxAccountUtils.pii(LOG_TAG, "Profile JSON is: " + profileJSON.toJSONString());
final String userName = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_USERNAME);
// Update the profile username and email if available.
if (!TextUtils.isEmpty(userName)) {
profilePreference.setTitle(userName);
profilePreference.setSummary(fxAccount.getEmail());
} else {
profilePreference.setTitle(fxAccount.getEmail());
}
// Read the profile information from json and Update the UI elements.
ThreadUtils.postToUiThread(new Runnable() {
@Override
public void run() {
// Icon update from java is not supported prior to API 11, skip the avatar update for older device.
if (AppConstants.Versions.feature11Plus) {
profilePreference.setIcon(getResources().getDrawable(R.drawable.sync_avatar_default));
}
profilePreference.setTitle(fxAccount.getAndroidAccount().name);
}
});
// Icon update from java is not supported prior to API 11, skip the avatar image fetch and update for older device.
if (!AppConstants.Versions.feature11Plus) {
Logger.info(LOG_TAG, "Skipping profile image fetch for older pre-API 11 devices.");
return;
}
// Avatar URI empty, skip profile image fetch.
final String avatarURI = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_AVATAR);
if (TextUtils.isEmpty(avatarURI)) {
Logger.info(LOG_TAG, "AvatarURI is empty, skipping profile image fetch.");
return;
}
// Using noPlaceholder would avoid a pop of the default image, but it's not available in the version of Picasso
// we ship in the tree.
Picasso
.with(getActivity())
.load(avatarURI)
.centerInside()
.resizeDimen(R.dimen.fxaccount_profile_image_width, R.dimen.fxaccount_profile_image_height)
.placeholder(R.drawable.sync_avatar_default)
.error(R.drawable.sync_avatar_default)
.into(profileAvatarTarget);
}
private void scheduleAndUpdateLastSyncedTime() {
@ -830,26 +850,24 @@ public class FxAccountStatusFragment
}
}
/**
* The Runnable that schedules a future to fetch profile information.
*/
protected class ProfileFetchUpdateRunnable implements Runnable {
@Override
public void run() {
updateProfileInformation();
}
}
/**
* Broadcast receiver to receive updates for the cached profile action.
*/
public class FxAccountProfileInformationReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(FxAccountConstants.ACCOUNT_PROFILE_AVATAR_UPDATED_ACTION)) {
// We should have a cached profile json here.
updateProfileInformation(fxAccount.getCachedProfileJSON());
if (!intent.getAction().equals(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION)) {
return;
}
Logger.info(LOG_TAG, "Profile avatar cache update action broadcast received.");
// Update the UI from cached profile json on the main thread.
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
updateProfileInformation();
}
});
}
}

View File

@ -0,0 +1,76 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.fxa.activities;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.preference.Preference;
import android.support.v4.graphics.drawable.RoundedBitmapDrawable;
import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory;
import com.squareup.picasso.Picasso;
import com.squareup.picasso.Target;
import org.mozilla.gecko.AppConstants;
/**
* A Picasso Target that updates a preference icon.
*
* Nota bene: Android grew support for updating preference icons programatically
* only in API 11. This class silently ignores requests before API 11.
*/
public class PicassoPreferenceIconTarget implements Target {
private final Preference preference;
private final Resources resources;
private final float cornerRadius;
public PicassoPreferenceIconTarget(Resources resources, Preference preference) {
this(resources, preference, 0);
}
public PicassoPreferenceIconTarget(Resources resources, Preference preference, float cornerRadius) {
this.resources = resources;
this.preference = preference;
this.cornerRadius = cornerRadius;
}
@Override
public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) {
// Updating icons from Java is not supported prior to API 11.
if (!AppConstants.Versions.feature11Plus) {
return;
}
final Drawable drawable;
if (cornerRadius > 0) {
final RoundedBitmapDrawable roundedBitmapDrawable;
roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(resources, bitmap);
roundedBitmapDrawable.setCornerRadius(cornerRadius);
roundedBitmapDrawable.setAntiAlias(true);
drawable = roundedBitmapDrawable;
} else {
drawable = new BitmapDrawable(resources, bitmap);
}
preference.setIcon(drawable);
}
@Override
public void onBitmapFailed(Drawable errorDrawable) {
// Updating icons from Java is not supported prior to API 11.
if (!AppConstants.Versions.feature11Plus) {
return;
}
preference.setIcon(errorDrawable);
}
@Override
public void onPrepareLoad(Drawable placeHolderDrawable) {
// Updating icons from Java is not supported prior to API 11.
if (!AppConstants.Versions.feature11Plus) {
return;
}
preference.setIcon(placeHolderDrawable);
}
}

View File

@ -69,12 +69,12 @@ public class AndroidFxAccount {
public static final String ACCOUNT_KEY_TOKEN_SERVER = "tokenServerURI"; // Sync-specific.
public static final String ACCOUNT_KEY_DESCRIPTOR = "descriptor";
public static final String ACCOUNT_KEY_PROFILE_AVATAR = "avatar";
public static final int CURRENT_BUNDLE_VERSION = 2;
public static final String BUNDLE_KEY_BUNDLE_VERSION = "version";
public static final String BUNDLE_KEY_STATE_LABEL = "stateLabel";
public static final String BUNDLE_KEY_STATE = "state";
public static final String BUNDLE_KEY_PROFILE_JSON = "profile";
// Account authentication token type for fetching account profile.
public static final String PROFILE_OAUTH_TOKEN_TYPE = "oauth::profile";
@ -105,13 +105,6 @@ public class AndroidFxAccount {
}
private static final String PREF_KEY_LAST_SYNCED_TIMESTAMP = "lastSyncedTimestamp";
public static final String PREF_KEY_LAST_PROFILE_FETCH_TIME = "lastProfilefetchTime";
public static final String PREF_KEY_NUMBER_OF_PROFILE_FETCH = "numProfileFetch";
// Max wait time between successful profile avatar network fetch.
public static final long PROFILE_FETCH_RETRY_BACKOFF_DELTA_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
// Max attempts allowed for retrying profile avatar network fetch.
public static final int MAX_PROFILE_FETCH_RETRIES = 5;
protected final Context context;
protected final AccountManager accountManager;
@ -127,7 +120,6 @@ public class AndroidFxAccount {
*/
protected static final ConcurrentHashMap<String, ExtendedJSONObject> perAccountBundleCache =
new ConcurrentHashMap<>();
private ExtendedJSONObject profileJson;
public static void invalidateCaches() {
perAccountBundleCache.clear();
@ -667,39 +659,17 @@ public class AndroidFxAccount {
return intent;
}
private void setLastProfileFetchTimestampAndAttempts(long now, int attempts) {
try {
getSyncPrefs().edit().putLong(PREF_KEY_LAST_PROFILE_FETCH_TIME, now).commit();
getSyncPrefs().edit().putInt(PREF_KEY_NUMBER_OF_PROFILE_FETCH, attempts);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception setting last profile fetch time & attempts; ignoring.", e);
}
}
private long getLastProfileFetchTimestamp() {
final long neverFetched = -1L;
try {
return getSyncPrefs().getLong(PREF_KEY_LAST_PROFILE_FETCH_TIME, neverFetched);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception getting last profile fetch time; ignoring.", e);
return neverFetched;
}
}
private int getNumberOfProfileFetch() {
final int neverFetched = 0;
try {
return getSyncPrefs().getInt(PREF_KEY_NUMBER_OF_PROFILE_FETCH, neverFetched);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception getting number of profile fetch; ignoring.", e);
return neverFetched;
}
}
private boolean canScheduleProfileFetch() {
final int attempts = getNumberOfProfileFetch();
final long delta = System.currentTimeMillis() - getLastProfileFetchTimestamp();
return delta > PROFILE_FETCH_RETRY_BACKOFF_DELTA_IN_MILLISECONDS || attempts < MAX_PROFILE_FETCH_RETRIES;
/**
* Create an intent announcing that the profile JSON attached to this Firefox Account has been updated.
* <p>
* It is not guaranteed that the profile JSON has changed.
*
* @return <code>Intent</code> to broadcast.
*/
private Intent makeProfileJSONUpdatedIntent() {
final Intent intent = new Intent();
intent.setAction(FxAccountConstants.ACCOUNT_PROFILE_JSON_UPDATED_ACTION);
return intent;
}
public void setLastSyncedTimestamp(long now) {
@ -755,60 +725,31 @@ public class AndroidFxAccount {
ContentResolver.setIsSyncable(account, BrowserContract.READING_LIST_AUTHORITY, 1);
}
// Helper function to create intent for profile avatar updated event.
private Intent getProfileAvatarUpdatedIntent() {
final Intent profileCachedIntent = new Intent();
profileCachedIntent.setAction(FxAccountConstants.ACCOUNT_PROFILE_AVATAR_UPDATED_ACTION);
return profileCachedIntent;
}
/**
* Returns the cached profile JSON object if available or null.
* Returns the current profile JSON if available, or null.
*
* @return profile JSON Object.
* @return profile JSON object.
*/
public ExtendedJSONObject getCachedProfileJSON() {
if (profileJson == null) {
// Try to retrieve and parse the json string from account manager.
final String profileJsonString = accountManager.getUserData(account, ACCOUNT_KEY_PROFILE_AVATAR);
if (profileJsonString != null) {
Logger.info(LOG_TAG, "Cached Profile information retrieved from AccountManager.");
try {
profileJson = ExtendedJSONObject.parseJSONObject(profileJsonString);
} catch (Exception e) {
Logger.error(LOG_TAG, "Failed to parse profile json; ignoring.", e);
}
}
public ExtendedJSONObject getProfileJSON() {
final String profileString = getBundleData(BUNDLE_KEY_PROFILE_JSON);
if (profileString == null) {
return null;
}
return profileJson;
try {
return new ExtendedJSONObject(profileString);
} catch (Exception e) {
Logger.error(LOG_TAG, "Failed to parse profile JSON; ignoring and returning null.", e);
}
return null;
}
/**
* Fetches the profile json from the server and updates the local cache.
*
* Fetch the profile JSON associated to the underlying Firefox Account from the server and update the local store.
* <p>
* On successful fetch and cache, LocalBroadcastManager is used to notify the receivers asynchronously.
* </p>
*
* @param isForceFetch boolean to isForceFetch fetch from the server.
* The LocalBroadcastManager is used to notify the receivers asynchronously after a successful fetch.
*/
public void maybeUpdateProfileJSON(final boolean isForceFetch) {
final ExtendedJSONObject profileJson = getCachedProfileJSON();
final Intent profileAvatarUpdatedIntent = getProfileAvatarUpdatedIntent();
if (!isForceFetch && profileJson != null && !profileJson.keySet().isEmpty()) {
// Second line of defense, cache may have been updated in between.
Logger.info(LOG_TAG, "Profile already cached.");
LocalBroadcastManager.getInstance(context).sendBroadcast(profileAvatarUpdatedIntent);
return;
}
if (!isForceFetch && !canScheduleProfileFetch()) {
// Rate limiting repeated attempts to fetch the profile information.
Logger.info(LOG_TAG, "Too many attempts to fetch the profile information.");
return;
}
public void fetchProfileJSON() {
ThreadUtils.postToBackgroundThread(new Runnable() {
@Override
public void run() {
@ -828,24 +769,15 @@ public class AndroidFxAccount {
final Intent intent = new Intent(context, FxAccountProfileService.class);
intent.putExtra(FxAccountProfileService.KEY_AUTH_TOKEN, authToken);
intent.putExtra(FxAccountProfileService.KEY_PROFILE_SERVER_URI, getProfileServerURI());
intent.putExtra(FxAccountProfileService.KEY_RESULT_RECEIVER, new ProfileResultReceiver(profileAvatarUpdatedIntent));
intent.putExtra(FxAccountProfileService.KEY_RESULT_RECEIVER, new ProfileResultReceiver(new Handler()));
context.startService(intent);
// Update the profile fetch time and attempts, resetting the attempts if last fetch was over a day old.
final int attempts = getNumberOfProfileFetch();
final long now = System.currentTimeMillis();
final long delta = now - getLastProfileFetchTimestamp();
setLastProfileFetchTimestampAndAttempts(now, delta < PROFILE_FETCH_RETRY_BACKOFF_DELTA_IN_MILLISECONDS ? attempts + 1 : 1);
}
});
}
private class ProfileResultReceiver extends ResultReceiver {
private final Intent profileAvatarUpdatedIntent;
public ProfileResultReceiver(Intent broadcastIntent) {
super(new Handler());
this.profileAvatarUpdatedIntent = broadcastIntent;
public ProfileResultReceiver(Handler handler) {
super(handler);
}
@Override
@ -853,21 +785,17 @@ public class AndroidFxAccount {
super.onReceiveResult(resultCode, bundle);
switch (resultCode) {
case Activity.RESULT_OK:
try {
final String resultData = bundle.getString(FxAccountProfileService.KEY_RESULT_STRING);
profileJson = ExtendedJSONObject.parseJSONObject(resultData);
accountManager.setUserData(account, ACCOUNT_KEY_PROFILE_AVATAR, resultData);
Logger.pii(LOG_TAG, "Profile fetch successful." + resultData);
LocalBroadcastManager.getInstance(context).sendBroadcast(profileAvatarUpdatedIntent);
} catch (Exception e) {
Logger.error(LOG_TAG, "Failed to parse profile json; ignoring.", e);
}
final String resultData = bundle.getString(FxAccountProfileService.KEY_RESULT_STRING);
updateBundleValues(BUNDLE_KEY_PROFILE_JSON, resultData);
Logger.info(LOG_TAG, "Profile JSON fetch succeeeded!");
FxAccountUtils.pii(LOG_TAG, "Profile JSON fetch returned: " + resultData);
LocalBroadcastManager.getInstance(context).sendBroadcast(makeDeletedAccountIntent());
break;
case Activity.RESULT_CANCELED:
Logger.warn(LOG_TAG, "Failed to fetch profile; ignoring.");
Logger.warn(LOG_TAG, "Failed to fetch profile JSON; ignoring.");
break;
default:
Logger.warn(LOG_TAG, "Invalid Result code received; ignoring.");
Logger.warn(LOG_TAG, "Invalid result code received; ignoring.");
break;
}
}

View File

@ -11,6 +11,7 @@ import android.os.Bundle;
import android.os.ResultReceiver;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.background.fxa.FxAccountUtils;
import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient;
import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException;
import org.mozilla.gecko.background.fxa.profile.FxAccountProfileClient10;
@ -37,6 +38,11 @@ public class FxAccountProfileService extends IntentService {
final String profileServerURI = intent.getStringExtra(KEY_PROFILE_SERVER_URI);
final ResultReceiver resultReceiver = intent.getParcelableExtra(KEY_RESULT_RECEIVER);
if (resultReceiver == null) {
Logger.warn(LOG_TAG, "Result receiver must not be null; ignoring intent.");
return;
}
if (authToken == null || authToken.length() == 0) {
Logger.warn(LOG_TAG, "Invalid Auth Token");
sendResult("Invalid Auth Token", resultReceiver, Activity.RESULT_CANCELED);
@ -66,7 +72,7 @@ public class FxAccountProfileService extends IntentService {
@Override
public void handleSuccess(ExtendedJSONObject result) {
if (result != null){
Logger.pii(LOG_TAG, "Profile Server response : " + result.toJSONString());
FxAccountUtils.pii(LOG_TAG, "Profile server return profile: " + result.toJSONString());
sendResult(result.toJSONString(), resultReceiver, Activity.RESULT_OK);
}
}

View File

@ -14,6 +14,7 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.background.fxa.FxAccountUtils;
import org.mozilla.gecko.background.fxa.SkewHandler;
@ -532,6 +533,12 @@ public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter {
final KeyBundle syncKeyBundle = married.getSyncKeyBundle();
final String clientState = married.getClientState();
syncWithAssertion(audience, assertion, tokenServerEndpointURI, tokenBackoffHandler, sharedPrefs, syncKeyBundle, clientState, sessionCallback, extras, fxAccount);
if (AppConstants.MOZ_ANDROID_FIREFOX_ACCOUNT_PROFILES) {
// Force fetch the profile avatar information.
Logger.info(LOG_TAG, "Fetching profile avatar information.");
fxAccount.fetchProfileJSON();
}
} catch (Exception e) {
syncDelegate.handleError(e);
return;

View File

@ -25,4 +25,9 @@
<dimen name="preference_fragment_padding_side">16dp</dimen>
<integer name="preference_fragment_scrollbarStyle">0x02000000</integer> <!-- outsideOverlay -->
<!-- Profile avatar image height. -->
<dimen name="fxaccount_profile_image_height">48dp</dimen>
<!-- Profile avatar image width. -->
<dimen name="fxaccount_profile_image_width">48dp</dimen>
</resources>