summaryrefslogtreecommitdiff
path: root/java/com/android/dialer/app/contactinfo
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/dialer/app/contactinfo')
-rw-r--r--java/com/android/dialer/app/contactinfo/ContactInfoCache.java357
-rw-r--r--java/com/android/dialer/app/contactinfo/ContactInfoRequest.java122
-rw-r--r--java/com/android/dialer/app/contactinfo/ContactPhotoLoader.java129
-rw-r--r--java/com/android/dialer/app/contactinfo/ExpirableCacheHeadlessFragment.java67
-rw-r--r--java/com/android/dialer/app/contactinfo/NumberWithCountryIso.java57
5 files changed, 732 insertions, 0 deletions
diff --git a/java/com/android/dialer/app/contactinfo/ContactInfoCache.java b/java/com/android/dialer/app/contactinfo/ContactInfoCache.java
new file mode 100644
index 000000000..4135cb7b8
--- /dev/null
+++ b/java/com/android/dialer/app/contactinfo/ContactInfoCache.java
@@ -0,0 +1,357 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * 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.android.dialer.app.contactinfo;
+
+import android.os.Handler;
+import android.os.Message;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import com.android.dialer.phonenumbercache.ContactInfo;
+import com.android.dialer.phonenumbercache.ContactInfoHelper;
+import com.android.dialer.util.ExpirableCache;
+import java.lang.ref.WeakReference;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.PriorityBlockingQueue;
+
+/**
+ * This is a cache of contact details for the phone numbers in the c all log. The key is the phone
+ * number with the country in which teh call was placed or received. The content of the cache is
+ * expired (but not purged) whenever the application comes to the foreground.
+ *
+ * <p>This cache queues request for information and queries for information on a background thread,
+ * so {@code start()} and {@code stop()} must be called to initiate or halt that thread's exeuction
+ * as needed.
+ *
+ * <p>TODO: Explore whether there is a pattern to remove external dependencies for starting and
+ * stopping the query thread.
+ */
+public class ContactInfoCache {
+
+ private static final int REDRAW = 1;
+ private static final int START_THREAD = 2;
+ private static final int START_PROCESSING_REQUESTS_DELAY_MS = 1000;
+
+ private final ExpirableCache<NumberWithCountryIso, ContactInfo> mCache;
+ private final ContactInfoHelper mContactInfoHelper;
+ private final OnContactInfoChangedListener mOnContactInfoChangedListener;
+ private final BlockingQueue<ContactInfoRequest> mUpdateRequests;
+ private final Handler mHandler;
+ private QueryThread mContactInfoQueryThread;
+ private volatile boolean mRequestProcessingDisabled = false;
+
+ private static class InnerHandler extends Handler {
+
+ private final WeakReference<ContactInfoCache> contactInfoCacheWeakReference;
+
+ public InnerHandler(WeakReference<ContactInfoCache> contactInfoCacheWeakReference) {
+ this.contactInfoCacheWeakReference = contactInfoCacheWeakReference;
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ ContactInfoCache reference = contactInfoCacheWeakReference.get();
+ if (reference == null) {
+ return;
+ }
+ switch (msg.what) {
+ case REDRAW:
+ reference.mOnContactInfoChangedListener.onContactInfoChanged();
+ break;
+ case START_THREAD:
+ reference.startRequestProcessing();
+ }
+ }
+ }
+
+ public ContactInfoCache(
+ @NonNull ExpirableCache<NumberWithCountryIso, ContactInfo> internalCache,
+ @NonNull ContactInfoHelper contactInfoHelper,
+ @NonNull OnContactInfoChangedListener listener) {
+ mCache = internalCache;
+ mContactInfoHelper = contactInfoHelper;
+ mOnContactInfoChangedListener = listener;
+ mUpdateRequests = new PriorityBlockingQueue<>();
+ mHandler = new InnerHandler(new WeakReference<>(this));
+ }
+
+ public ContactInfo getValue(
+ String number,
+ String countryIso,
+ ContactInfo callLogContactInfo,
+ boolean remoteLookupIfNotFoundLocally) {
+ NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
+ ExpirableCache.CachedValue<ContactInfo> cachedInfo = mCache.getCachedValue(numberCountryIso);
+ ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue();
+ if (cachedInfo == null) {
+ mCache.put(numberCountryIso, ContactInfo.EMPTY);
+ // Use the cached contact info from the call log.
+ info = callLogContactInfo;
+ // The db request should happen on a non-UI thread.
+ // Request the contact details immediately since they are currently missing.
+ int requestType =
+ remoteLookupIfNotFoundLocally
+ ? ContactInfoRequest.TYPE_LOCAL_AND_REMOTE
+ : ContactInfoRequest.TYPE_LOCAL;
+ enqueueRequest(number, countryIso, callLogContactInfo, /* immediate */ true, requestType);
+ // We will format the phone number when we make the background request.
+ } else {
+ if (cachedInfo.isExpired()) {
+ // The contact info is no longer up to date, we should request it. However, we
+ // do not need to request them immediately.
+ enqueueRequest(
+ number,
+ countryIso,
+ callLogContactInfo, /* immediate */
+ false,
+ ContactInfoRequest.TYPE_LOCAL);
+ } else if (!callLogInfoMatches(callLogContactInfo, info)) {
+ // The call log information does not match the one we have, look it up again.
+ // We could simply update the call log directly, but that needs to be done in a
+ // background thread, so it is easier to simply request a new lookup, which will, as
+ // a side-effect, update the call log.
+ enqueueRequest(
+ number,
+ countryIso,
+ callLogContactInfo, /* immediate */
+ false,
+ ContactInfoRequest.TYPE_LOCAL);
+ }
+
+ if (info == ContactInfo.EMPTY) {
+ // Use the cached contact info from the call log.
+ info = callLogContactInfo;
+ }
+ }
+ return info;
+ }
+
+ /**
+ * Queries the appropriate content provider for the contact associated with the number.
+ *
+ * <p>Upon completion it also updates the cache in the call log, if it is different from {@code
+ * callLogInfo}.
+ *
+ * <p>The number might be either a SIP address or a phone number.
+ *
+ * <p>It returns true if it updated the content of the cache and we should therefore tell the view
+ * to update its content.
+ */
+ private boolean queryContactInfo(ContactInfoRequest request) {
+ ContactInfo info;
+ if (request.isLocalRequest()) {
+ info = mContactInfoHelper.lookupNumber(request.number, request.countryIso);
+ if (request.type == ContactInfoRequest.TYPE_LOCAL_AND_REMOTE) {
+ if (!mContactInfoHelper.hasName(info)) {
+ enqueueRequest(
+ request.number,
+ request.countryIso,
+ request.callLogInfo,
+ true,
+ ContactInfoRequest.TYPE_REMOTE);
+ return false;
+ }
+ }
+ } else {
+ info = mContactInfoHelper.lookupNumberInRemoteDirectory(request.number, request.countryIso);
+ }
+
+ if (info == null) {
+ // The lookup failed, just return without requesting to update the view.
+ return false;
+ }
+
+ // Check the existing entry in the cache: only if it has changed we should update the
+ // view.
+ NumberWithCountryIso numberCountryIso =
+ new NumberWithCountryIso(request.number, request.countryIso);
+ ContactInfo existingInfo = mCache.getPossiblyExpired(numberCountryIso);
+
+ final boolean isRemoteSource = info.sourceType != 0;
+
+ // Don't force redraw if existing info in the cache is equal to {@link ContactInfo#EMPTY}
+ // to avoid updating the data set for every new row that is scrolled into view.
+
+ // Exception: Photo uris for contacts from remote sources are not cached in the call log
+ // cache, so we have to force a redraw for these contacts regardless.
+ boolean updated =
+ (existingInfo != ContactInfo.EMPTY || isRemoteSource) && !info.equals(existingInfo);
+
+ // Store the data in the cache so that the UI thread can use to display it. Store it
+ // even if it has not changed so that it is marked as not expired.
+ mCache.put(numberCountryIso, info);
+
+ // Update the call log even if the cache it is up-to-date: it is possible that the cache
+ // contains the value from a different call log entry.
+ mContactInfoHelper.updateCallLogContactInfo(
+ request.number, request.countryIso, info, request.callLogInfo);
+ if (!request.isLocalRequest()) {
+ mContactInfoHelper.updateCachedNumberLookupService(info);
+ }
+ return updated;
+ }
+
+ /**
+ * After a delay, start the thread to begin processing requests. We perform lookups on a
+ * background thread, but this must be called to indicate the thread should be running.
+ */
+ public void start() {
+ // Schedule a thread-creation message if the thread hasn't been created yet, as an
+ // optimization to queue fewer messages.
+ if (mContactInfoQueryThread == null) {
+ // TODO: Check whether this delay before starting to process is necessary.
+ mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MS);
+ }
+ }
+
+ /**
+ * Stops the thread and clears the queue of messages to process. This cleans up the thread for
+ * lookups so that it is not perpetually running.
+ */
+ public void stop() {
+ stopRequestProcessing();
+ }
+
+ /**
+ * Starts a background thread to process contact-lookup requests, unless one has already been
+ * started.
+ */
+ private synchronized void startRequestProcessing() {
+ // For unit-testing.
+ if (mRequestProcessingDisabled) {
+ return;
+ }
+
+ // If a thread is already started, don't start another.
+ if (mContactInfoQueryThread != null) {
+ return;
+ }
+
+ mContactInfoQueryThread = new QueryThread();
+ mContactInfoQueryThread.setPriority(Thread.MIN_PRIORITY);
+ mContactInfoQueryThread.start();
+ }
+
+ public void invalidate() {
+ mCache.expireAll();
+ stopRequestProcessing();
+ }
+
+ /**
+ * Stops the background thread that processes updates and cancels any pending requests to start
+ * it.
+ */
+ private synchronized void stopRequestProcessing() {
+ // Remove any pending requests to start the processing thread.
+ mHandler.removeMessages(START_THREAD);
+ if (mContactInfoQueryThread != null) {
+ // Stop the thread; we are finished with it.
+ mContactInfoQueryThread.stopProcessing();
+ mContactInfoQueryThread.interrupt();
+ mContactInfoQueryThread = null;
+ }
+ }
+
+ /**
+ * Enqueues a request to look up the contact details for the given phone number.
+ *
+ * <p>It also provides the current contact info stored in the call log for this number.
+ *
+ * <p>If the {@code immediate} parameter is true, it will start immediately the thread that looks
+ * up the contact information (if it has not been already started). Otherwise, it will be started
+ * with a delay. See {@link #START_PROCESSING_REQUESTS_DELAY_MS}.
+ */
+ private void enqueueRequest(
+ String number,
+ String countryIso,
+ ContactInfo callLogInfo,
+ boolean immediate,
+ @ContactInfoRequest.TYPE int type) {
+ ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo, type);
+ if (!mUpdateRequests.contains(request)) {
+ mUpdateRequests.offer(request);
+ }
+
+ if (immediate) {
+ startRequestProcessing();
+ }
+ }
+
+ /** Checks whether the contact info from the call log matches the one from the contacts db. */
+ private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) {
+ // The call log only contains a subset of the fields in the contacts db. Only check those.
+ return TextUtils.equals(callLogInfo.name, info.name)
+ && callLogInfo.type == info.type
+ && TextUtils.equals(callLogInfo.label, info.label);
+ }
+
+ /** Sets whether processing of requests for contact details should be enabled. */
+ public void disableRequestProcessing() {
+ mRequestProcessingDisabled = true;
+ }
+
+ @VisibleForTesting
+ public void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) {
+ NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
+ mCache.put(numberCountryIso, contactInfo);
+ }
+
+ public interface OnContactInfoChangedListener {
+
+ void onContactInfoChanged();
+ }
+
+ /*
+ * Handles requests for contact name and number type.
+ */
+ private class QueryThread extends Thread {
+
+ private volatile boolean mDone = false;
+
+ public QueryThread() {
+ super("ContactInfoCache.QueryThread");
+ }
+
+ public void stopProcessing() {
+ mDone = true;
+ }
+
+ @Override
+ public void run() {
+ boolean shouldRedraw = false;
+ while (true) {
+ // Check if thread is finished, and if so return immediately.
+ if (mDone) {
+ return;
+ }
+
+ try {
+ ContactInfoRequest request = mUpdateRequests.take();
+ shouldRedraw |= queryContactInfo(request);
+ if (shouldRedraw
+ && (mUpdateRequests.isEmpty()
+ || request.isLocalRequest() && !mUpdateRequests.peek().isLocalRequest())) {
+ shouldRedraw = false;
+ mHandler.sendEmptyMessage(REDRAW);
+ }
+ } catch (InterruptedException e) {
+ // Ignore and attempt to continue processing requests
+ }
+ }
+ }
+ }
+}
diff --git a/java/com/android/dialer/app/contactinfo/ContactInfoRequest.java b/java/com/android/dialer/app/contactinfo/ContactInfoRequest.java
new file mode 100644
index 000000000..5c2eb1dbb
--- /dev/null
+++ b/java/com/android/dialer/app/contactinfo/ContactInfoRequest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * 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.android.dialer.app.contactinfo;
+
+import android.support.annotation.IntDef;
+import android.text.TextUtils;
+import com.android.dialer.phonenumbercache.ContactInfo;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** A request for contact details for the given number, used by the ContactInfoCache. */
+public final class ContactInfoRequest implements Comparable<ContactInfoRequest> {
+
+ private static final AtomicLong NEXT_SEQUENCE_NUMBER = new AtomicLong(0);
+
+ private final long sequenceNumber;
+
+ /** The number to look-up. */
+ public final String number;
+ /** The country in which a call to or from this number was placed or received. */
+ public final String countryIso;
+ /** The cached contact information stored in the call log. */
+ public final ContactInfo callLogInfo;
+
+ /** Is the request a remote lookup. Remote requests are treated as lower priority. */
+ @TYPE public final int type;
+
+ /** Specifies the type of the request is. */
+ @IntDef(
+ value = {
+ TYPE_LOCAL,
+ TYPE_LOCAL_AND_REMOTE,
+ TYPE_REMOTE,
+ }
+ )
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface TYPE {}
+
+ public static final int TYPE_LOCAL = 0;
+ /** If cannot find the contact locally, do remote lookup later. */
+ public static final int TYPE_LOCAL_AND_REMOTE = 1;
+
+ public static final int TYPE_REMOTE = 2;
+
+ public ContactInfoRequest(
+ String number, String countryIso, ContactInfo callLogInfo, @TYPE int type) {
+ this.sequenceNumber = NEXT_SEQUENCE_NUMBER.getAndIncrement();
+ this.number = number;
+ this.countryIso = countryIso;
+ this.callLogInfo = callLogInfo;
+ this.type = type;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (!(obj instanceof ContactInfoRequest)) {
+ return false;
+ }
+
+ ContactInfoRequest other = (ContactInfoRequest) obj;
+
+ if (!TextUtils.equals(number, other.number)) {
+ return false;
+ }
+ if (!TextUtils.equals(countryIso, other.countryIso)) {
+ return false;
+ }
+ if (!Objects.equals(callLogInfo, other.callLogInfo)) {
+ return false;
+ }
+
+ if (type != other.type) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public boolean isLocalRequest() {
+ return type == TYPE_LOCAL || type == TYPE_LOCAL_AND_REMOTE;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(sequenceNumber, number, countryIso, callLogInfo, type);
+ }
+
+ @Override
+ public int compareTo(ContactInfoRequest other) {
+ // Local query always comes first.
+ if (isLocalRequest() && !other.isLocalRequest()) {
+ return -1;
+ }
+ if (!isLocalRequest() && other.isLocalRequest()) {
+ return 1;
+ }
+ // First come first served.
+ return sequenceNumber < other.sequenceNumber ? -1 : 1;
+ }
+}
diff --git a/java/com/android/dialer/app/contactinfo/ContactPhotoLoader.java b/java/com/android/dialer/app/contactinfo/ContactPhotoLoader.java
new file mode 100644
index 000000000..a8c718502
--- /dev/null
+++ b/java/com/android/dialer/app/contactinfo/ContactPhotoLoader.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * 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.android.dialer.app.contactinfo;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawable;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory;
+import com.android.contacts.common.GeoUtil;
+import com.android.contacts.common.lettertiles.LetterTileDrawable;
+import com.android.dialer.app.R;
+import com.android.dialer.common.Assert;
+import com.android.dialer.common.LogUtil;
+import com.android.dialer.phonenumbercache.ContactInfo;
+import com.android.dialer.phonenumbercache.ContactInfoHelper;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+/**
+ * Class to create the appropriate contact icon from a ContactInfo. This class is for synchronous,
+ * blocking calls to generate bitmaps, while ContactCommons.ContactPhotoManager is to cache, manage
+ * and update a ImageView asynchronously.
+ */
+public class ContactPhotoLoader {
+
+ private final Context mContext;
+ private final ContactInfo mContactInfo;
+
+ public ContactPhotoLoader(Context context, ContactInfo contactInfo) {
+ mContext = Objects.requireNonNull(context);
+ mContactInfo = Objects.requireNonNull(contactInfo);
+ }
+
+ private static Bitmap drawableToBitmap(Drawable drawable, int width, int height) {
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+ return bitmap;
+ }
+
+ /** Create a contact photo icon bitmap appropriate for the ContactInfo. */
+ public Bitmap loadPhotoIcon() {
+ Assert.isWorkerThread();
+ int photoSize = mContext.getResources().getDimensionPixelSize(R.dimen.contact_photo_size);
+ return drawableToBitmap(getIcon(), photoSize, photoSize);
+ }
+
+ @VisibleForTesting
+ Drawable getIcon() {
+ Drawable drawable = createPhotoIconDrawable();
+ if (drawable == null) {
+ drawable = createLetterTileDrawable();
+ }
+ return drawable;
+ }
+
+ /**
+ * @return a {@link Drawable} of circular photo icon if the photo can be loaded, {@code null}
+ * otherwise.
+ */
+ @Nullable
+ private Drawable createPhotoIconDrawable() {
+ if (mContactInfo.photoUri == null) {
+ return null;
+ }
+ try {
+ InputStream input = mContext.getContentResolver().openInputStream(mContactInfo.photoUri);
+ if (input == null) {
+ LogUtil.w(
+ "ContactPhotoLoader.createPhotoIconDrawable",
+ "createPhotoIconDrawable: InputStream is null");
+ return null;
+ }
+ Bitmap bitmap = BitmapFactory.decodeStream(input);
+ input.close();
+
+ if (bitmap == null) {
+ LogUtil.w(
+ "ContactPhotoLoader.createPhotoIconDrawable",
+ "createPhotoIconDrawable: Bitmap is null");
+ return null;
+ }
+ final RoundedBitmapDrawable drawable =
+ RoundedBitmapDrawableFactory.create(mContext.getResources(), bitmap);
+ drawable.setAntiAlias(true);
+ drawable.setCornerRadius(bitmap.getHeight() / 2);
+ return drawable;
+ } catch (IOException e) {
+ LogUtil.e("ContactPhotoLoader.createPhotoIconDrawable", e.toString());
+ return null;
+ }
+ }
+
+ /** @return a {@link LetterTileDrawable} based on the ContactInfo. */
+ private Drawable createLetterTileDrawable() {
+ ContactInfoHelper helper =
+ new ContactInfoHelper(mContext, GeoUtil.getCurrentCountryIso(mContext));
+ LetterTileDrawable drawable = new LetterTileDrawable(mContext.getResources());
+ drawable.setCanonicalDialerLetterTileDetails(
+ mContactInfo.name,
+ mContactInfo.lookupKey,
+ LetterTileDrawable.SHAPE_CIRCLE,
+ helper.isBusiness(mContactInfo.sourceType)
+ ? LetterTileDrawable.TYPE_BUSINESS
+ : LetterTileDrawable.TYPE_DEFAULT);
+ return drawable;
+ }
+}
diff --git a/java/com/android/dialer/app/contactinfo/ExpirableCacheHeadlessFragment.java b/java/com/android/dialer/app/contactinfo/ExpirableCacheHeadlessFragment.java
new file mode 100644
index 000000000..aed51b507
--- /dev/null
+++ b/java/com/android/dialer/app/contactinfo/ExpirableCacheHeadlessFragment.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * 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.android.dialer.app.contactinfo;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v7.app.AppCompatActivity;
+import com.android.dialer.phonenumbercache.ContactInfo;
+import com.android.dialer.util.ExpirableCache;
+
+/**
+ * Fragment without any UI whose purpose is to retain an instance of {@link ExpirableCache} across
+ * configuration change through the use of {@link #setRetainInstance(boolean)}. This is done as
+ * opposed to implementing {@link android.os.Parcelable} as it is a less widespread change.
+ */
+public class ExpirableCacheHeadlessFragment extends Fragment {
+
+ private static final String FRAGMENT_TAG = "ExpirableCacheHeadlessFragment";
+ private static final int CONTACT_INFO_CACHE_SIZE = 100;
+
+ private ExpirableCache<NumberWithCountryIso, ContactInfo> retainedCache;
+
+ @NonNull
+ public static ExpirableCacheHeadlessFragment attach(@NonNull AppCompatActivity parentActivity) {
+ return attach(parentActivity.getSupportFragmentManager());
+ }
+
+ @NonNull
+ private static ExpirableCacheHeadlessFragment attach(FragmentManager fragmentManager) {
+ ExpirableCacheHeadlessFragment fragment =
+ (ExpirableCacheHeadlessFragment) fragmentManager.findFragmentByTag(FRAGMENT_TAG);
+ if (fragment == null) {
+ fragment = new ExpirableCacheHeadlessFragment();
+ // Allowing state loss since in rare cases this is called after activity's state is saved and
+ // it's fine if the cache is lost.
+ fragmentManager.beginTransaction().add(fragment, FRAGMENT_TAG).commitNowAllowingStateLoss();
+ }
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ retainedCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE);
+ setRetainInstance(true);
+ }
+
+ public ExpirableCache<NumberWithCountryIso, ContactInfo> getRetainedCache() {
+ return retainedCache;
+ }
+}
diff --git a/java/com/android/dialer/app/contactinfo/NumberWithCountryIso.java b/java/com/android/dialer/app/contactinfo/NumberWithCountryIso.java
new file mode 100644
index 000000000..a005c447d
--- /dev/null
+++ b/java/com/android/dialer/app/contactinfo/NumberWithCountryIso.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * 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.android.dialer.app.contactinfo;
+
+import android.text.TextUtils;
+
+/**
+ * Stores a phone number of a call with the country code where it originally occurred. This object
+ * is used as a key in the {@code ContactInfoCache}.
+ *
+ * <p>The country does not necessarily specify the country of the phone number itself, but rather it
+ * is the country in which the user was in when the call was placed or received.
+ */
+public final class NumberWithCountryIso {
+
+ public final String number;
+ public final String countryIso;
+
+ public NumberWithCountryIso(String number, String countryIso) {
+ this.number = number;
+ this.countryIso = countryIso;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null) {
+ return false;
+ }
+ if (!(o instanceof NumberWithCountryIso)) {
+ return false;
+ }
+ NumberWithCountryIso other = (NumberWithCountryIso) o;
+ return TextUtils.equals(number, other.number) && TextUtils.equals(countryIso, other.countryIso);
+ }
+
+ @Override
+ public int hashCode() {
+ int numberHashCode = number == null ? 0 : number.hashCode();
+ int countryHashCode = countryIso == null ? 0 : countryIso.hashCode();
+
+ return numberHashCode ^ countryHashCode;
+ }
+}