summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChiao Cheng <chiaocheng@google.com>2012-08-17 16:59:12 -0700
committerChiao Cheng <chiaocheng@google.com>2012-08-21 13:31:19 -0700
commit94b10b530c0fc297e2974e57e094c500d3ee6003 (patch)
treeb74d663c2663b5db2f6da888081648ce054480f5
parentdab5cd8890c0d0ca9001a13c2197114a4002338a (diff)
Initial move of dialer features from contacts app.
Bug: 6993891 Change-Id: I758ce359ca7e87a1d184303822979318be171921
-rw-r--r--Android.mk40
-rw-r--r--AndroidManifest.xml249
-rw-r--r--proguard.flags16
-rw-r--r--res/layout-land/dialpad_fragment.xml97
-rw-r--r--res/layout-land/dialtacts_activity.xml27
-rw-r--r--res/layout/call_detail.xml218
-rw-r--r--res/layout/call_detail_history_header.xml61
-rw-r--r--res/layout/call_detail_history_item.xml63
-rw-r--r--res/layout/call_log_fragment.xml78
-rw-r--r--res/layout/call_log_list_item.xml167
-rw-r--r--res/layout/call_log_voicemail_status.xml46
-rw-r--r--res/layout/dialpad.xml98
-rw-r--r--res/layout/dialpad_chooser_list_item.xml35
-rw-r--r--res/layout/dialpad_fragment.xml98
-rw-r--r--res/layout/dialtacts_activity.xml47
-rw-r--r--res/layout/dialtacts_custom_action_bar.xml43
-rw-r--r--res/values/strings.xml27
-rw-r--r--src/com/android/dialer/CallDetailActivity.java942
-rw-r--r--src/com/android/dialer/CallDetailActivityQueryHandler.java104
-rw-r--r--src/com/android/dialer/DialtactsActivity.java1267
-rw-r--r--src/com/android/dialer/NonPhoneActivity.java109
-rw-r--r--src/com/android/dialer/PhoneCallDetails.java86
-rw-r--r--src/com/android/dialer/PhoneCallDetailsHelper.java195
-rw-r--r--src/com/android/dialer/PhoneCallDetailsViews.java73
-rw-r--r--src/com/android/dialer/ViewNotificationService.java75
-rw-r--r--src/com/android/dialer/calllog/CallDetailHistoryAdapter.java175
-rw-r--r--src/com/android/dialer/calllog/CallLogAdapter.java802
-rw-r--r--src/com/android/dialer/calllog/CallLogFragment.java549
-rw-r--r--src/com/android/dialer/calllog/CallLogGroupBuilder.java159
-rw-r--r--src/com/android/dialer/calllog/CallLogListItemHelper.java109
-rw-r--r--src/com/android/dialer/calllog/CallLogListItemView.java46
-rw-r--r--src/com/android/dialer/calllog/CallLogListItemViews.java83
-rw-r--r--src/com/android/dialer/calllog/CallLogNotificationsService.java82
-rw-r--r--src/com/android/dialer/calllog/CallLogQuery.java103
-rw-r--r--src/com/android/dialer/calllog/CallLogQueryHandler.java364
-rw-r--r--src/com/android/dialer/calllog/CallLogReceiver.java50
-rw-r--r--src/com/android/dialer/calllog/CallTypeHelper.java92
-rw-r--r--src/com/android/dialer/calllog/CallTypeIconsView.java126
-rw-r--r--src/com/android/dialer/calllog/ClearCallLogDialog.java78
-rw-r--r--src/com/android/dialer/calllog/ContactInfo.java71
-rw-r--r--src/com/android/dialer/calllog/ContactInfoHelper.java215
-rw-r--r--src/com/android/dialer/calllog/DefaultVoicemailNotifier.java340
-rw-r--r--src/com/android/dialer/calllog/ExtendedCursor.java154
-rw-r--r--src/com/android/dialer/calllog/IntentProvider.java102
-rw-r--r--src/com/android/dialer/calllog/PhoneNumberHelper.java93
-rw-r--r--src/com/android/dialer/calllog/PhoneQuery.java45
-rw-r--r--src/com/android/dialer/calllog/VoicemailNotifier.java38
-rw-r--r--src/com/android/dialer/dialpad/DialpadFragment.java1629
-rw-r--r--src/com/android/dialer/dialpad/DialpadImageButton.java58
-rw-r--r--src/com/android/dialer/dialpad/DigitsEditText.java86
-rw-r--r--src/com/android/dialer/util/ExpirableCache.java266
-rw-r--r--src/com/android/dialer/voicemail/VoicemailPlaybackFragment.java474
-rw-r--r--src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java630
-rw-r--r--src/com/android/dialer/voicemail/VoicemailStatusHelper.java86
-rw-r--r--src/com/android/dialer/voicemail/VoicemailStatusHelperImpl.java272
-rw-r--r--tests/Android.mk19
-rw-r--r--tests/AndroidManifest.xml62
-rw-r--r--tests/proguard.flags20
-rw-r--r--tests/res/drawable/default_icon.pngbin0 -> 4005 bytes
-rw-r--r--tests/res/drawable/phone_icon.pngbin0 -> 3621 bytes
-rw-r--r--tests/res/layout/fill_call_log_test.xml56
-rw-r--r--tests/res/values/donottranslate_strings.xml41
-rw-r--r--tests/res/xml/iconset.xml24
-rw-r--r--tests/src/com/android/dialer/CallDetailActivityTest.java336
-rw-r--r--tests/src/com/android/dialer/DialerLaunchPerformance.java49
-rw-r--r--tests/src/com/android/dialer/PhoneCallDetailsHelperTest.java328
-rw-r--r--tests/src/com/android/dialer/calllog/CallLogAdapterTest.java224
-rw-r--r--tests/src/com/android/dialer/calllog/CallLogFragmentTest.java632
-rw-r--r--tests/src/com/android/dialer/calllog/CallLogGroupBuilderTest.java345
-rw-r--r--tests/src/com/android/dialer/calllog/CallLogListItemHelperTest.java151
-rw-r--r--tests/src/com/android/dialer/calllog/CallLogQueryTestUtils.java46
-rw-r--r--tests/src/com/android/dialer/calllog/TestPhoneNumberHelper.java37
-rw-r--r--tests/src/com/android/dialer/tests/calllog/FillCallLogTestActivity.java308
-rw-r--r--tests/src/com/android/dialer/util/ExpirableCacheTest.java125
-rw-r--r--tests/src/com/android/dialer/util/FakeAsyncTaskExecutor.java233
-rw-r--r--tests/src/com/android/dialer/util/LocaleTestUtils.java119
-rw-r--r--tests/src/com/android/dialer/voicemail/VoicemailStatusHelperImplTest.java274
77 files changed, 15067 insertions, 0 deletions
diff --git a/Android.mk b/Android.mk
new file mode 100644
index 000000000..ce67d7562
--- /dev/null
+++ b/Android.mk
@@ -0,0 +1,40 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := optional
+
+# This should become ContactsCommon
+contacts_common_dir := ../Contacts
+
+src_dirs := src $(contacts_common_dir)/src
+res_dirs := res $(contacts_common_dir)/res
+
+LOCAL_SRC_FILES := $(call all-java-files-under, $(src_dirs))
+LOCAL_RESOURCE_DIR := $(addprefix $(LOCAL_PATH)/, $(res_dirs))
+
+LOCAL_AAPT_FLAGS := \
+ --auto-add-overlay \
+ --extra-packages com.android.contacts
+
+LOCAL_JAVA_LIBRARIES := telephony-common
+LOCAL_STATIC_JAVA_LIBRARIES := \
+ com.android.phone.common \
+ com.android.phone.shared \
+ com.android.vcard \
+ android-common \
+ guava \
+ android-support-v13 \
+ android-support-v4 \
+ android-ex-variablespeed \
+
+LOCAL_REQUIRED_MODULES := libvariablespeed
+
+LOCAL_PACKAGE_NAME := Dialer
+LOCAL_CERTIFICATE := shared
+
+LOCAL_PROGUARD_FLAG_FILES := proguard.flags
+
+include $(BUILD_PACKAGE)
+
+# Use the folloing include to make our test apk.
+include $(call all-makefiles-under,$(LOCAL_PATH))
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 000000000..5e0e63ffe
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,249 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2006 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.dialer"
+ android:sharedUserId="android.uid.shared">
+
+ <uses-permission android:name="android.permission.CALL_PRIVILEGED" />
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
+ <uses-permission android:name="android.permission.WRITE_CONTACTS" />
+ <uses-permission android:name="android.permission.READ_CALL_LOG" />
+ <uses-permission android:name="android.permission.WRITE_CALL_LOG" />
+ <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
+ <uses-permission android:name="android.permission.GET_ACCOUNTS" />
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+ <uses-permission android:name="android.permission.READ_PROFILE" />
+ <uses-permission android:name="android.permission.WRITE_PROFILE" />
+ <uses-permission android:name="android.permission.READ_SOCIAL_STREAM" />
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.NFC" />
+ <uses-permission android:name="android.permission.READ_PHONE_STATE" />
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+ <uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
+ <uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH.mail" />
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+ <uses-permission android:name="android.permission.WRITE_SETTINGS" />
+ <uses-permission android:name="android.permission.USE_CREDENTIALS" />
+ <uses-permission android:name="android.permission.VIBRATE" />
+ <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
+ <uses-permission android:name="com.android.voicemail.permission.ADD_VOICEMAIL" />
+ <uses-permission android:name="com.android.voicemail.permission.READ_WRITE_ALL_VOICEMAIL" />
+ <uses-permission android:name="android.permission.ALLOW_ANY_CODEC_FOR_PLAYBACK" />
+ <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
+ <!-- allow broadcasting secret code intents that reboot the phone -->
+ <uses-permission android:name="android.permission.REBOOT" />
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+ <application
+ android:label="@string/applicationLabel"
+ android:icon="@mipmap/ic_launcher_contacts"
+ android:taskAffinity="android.task.contacts"
+ android:hardwareAccelerated="true"
+ >
+
+ <!-- Intercept Dialer Intents for devices without a phone.
+ This activity should have the same intent filters as the DialtactsActivity,
+ so that its capturing the same events. Omit android.intent.category.LAUNCHER, because we
+ don't want this to show up in the Launcher. The priorities of the intent-filters
+ are set lower, so that the user does not see a disambig dialog -->
+ <activity
+ android:name=".NonPhoneActivity"
+ android:theme="@style/NonPhoneActivityTheme"
+ >
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:mimeType="vnd.android.cursor.item/phone" />
+ <data android:mimeType="vnd.android.cursor.item/person" />
+ </intent-filter>
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="voicemail" />
+ </intent-filter>
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ </intent-filter>
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.VIEW" />
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="tel" />
+ </intent-filter>
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:mimeType="vnd.android.cursor.dir/calls" />
+ </intent-filter>
+ <intent-filter android:priority="-1">
+ <action android:name="android.intent.action.CALL_BUTTON" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ </intent-filter>
+ </activity>
+
+ <!-- The entrance point for Phone UI.
+ stateAlwaysHidden is set to suppress keyboard show up on
+ dialpad screen. -->
+ <activity android:name=".DialtactsActivity"
+ android:label="@string/launcherDialer"
+ android:theme="@style/DialtactsTheme"
+ android:uiOptions="splitActionBarWhenNarrow"
+ android:launchMode="singleTask"
+ android:clearTaskOnLaunch="true"
+ android:icon="@mipmap/ic_launcher_phone"
+ android:screenOrientation="nosensor"
+ android:enabled="@*android:bool/config_voice_capable"
+ android:taskAffinity="android.task.contacts.phone"
+ android:windowSoftInputMode="stateAlwaysHidden|adjustNothing">
+ <intent-filter>
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:mimeType="vnd.android.cursor.item/phone" />
+ <data android:mimeType="vnd.android.cursor.item/person" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="voicemail" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <action android:name="android.intent.action.DIAL" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="tel" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:mimeType="vnd.android.cursor.dir/calls" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.CALL_BUTTON" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ </intent-filter>
+ <!-- This was never intended to be public, but is here for backward
+ compatibility. Use Intent.ACTION_DIAL instead. -->
+ <intent-filter>
+ <action android:name="com.android.phone.action.TOUCH_DIALER" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.TAB" />
+ </intent-filter>
+ <intent-filter android:label="@string/recentCallsIconLabel">
+ <action android:name="com.android.phone.action.RECENT_CALLS" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.TAB" />
+ </intent-filter>
+ </activity>
+
+ <activity android:name="com.android.dialer.CallDetailActivity"
+ android:label="@string/callDetailTitle"
+ android:theme="@style/CallDetailActivityTheme"
+ android:screenOrientation="portrait"
+ android:icon="@mipmap/ic_launcher_phone"
+ android:taskAffinity="android.task.contacts.phone"
+ >
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW"/>
+ <category android:name="android.intent.category.DEFAULT"/>
+ <data android:mimeType="vnd.android.cursor.item/calls"/>
+ </intent-filter>
+ </activity>
+
+ <activity android:name="com.android.contacts.common.test.FragmentTestActivity">
+ <intent-filter>
+ <category android:name="android.intent.category.TEST"/>
+ </intent-filter>
+ </activity>
+
+ <!-- Backwards compatibility: "Phone" from Gingerbread and earlier -->
+ <activity-alias android:name="DialtactsActivity"
+ android:targetActivity=".DialtactsActivity"
+ android:exported="true"
+ />
+
+ <!-- Backwards compatibility: "Call log" from Gingerbread and earlier -->
+ <activity-alias android:name="RecentCallsListActivity"
+ android:targetActivity=".DialtactsActivity"
+ android:exported="true"
+ />
+
+ <!-- Backwards compatibility: "Call log" from ICS -->
+ <activity-alias android:name=".activities.CallLogActivity"
+ android:targetActivity=".DialtactsActivity"
+ android:exported="true"
+ />
+
+ <receiver android:name=".calllog.CallLogReceiver"
+ android:enabled="@*android:bool/config_voice_capable">
+ <intent-filter>
+ <action android:name="android.intent.action.NEW_VOICEMAIL" />
+ <data
+ android:scheme="content"
+ android:host="com.android.voicemail"
+ android:mimeType="vnd.android.cursor.item/voicemail"
+ />
+ </intent-filter>
+ <intent-filter android:priority="100">
+ <action android:name="android.intent.action.BOOT_COMPLETED"/>
+ </intent-filter>
+ </receiver>
+
+ <service
+ android:name=".calllog.CallLogNotificationsService"
+ android:exported="false"
+ />
+
+ <!-- Service that is exclusively for the Phone application that sends out a view
+ notification. This service might be removed in future versions of the app -->
+ <service android:name=".ViewNotificationService"
+ android:permission="android.permission.WRITE_CONTACTS"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="com.android.contacts.VIEW_NOTIFICATION" />
+ <data android:mimeType="vnd.android.cursor.item/contact" />
+ </intent-filter>
+ </service>
+ </application>
+</manifest>
diff --git a/proguard.flags b/proguard.flags
new file mode 100644
index 000000000..9e9ed6441
--- /dev/null
+++ b/proguard.flags
@@ -0,0 +1,16 @@
+# Xml files containing onClick (menus and layouts) require that proguard not
+# remove their handlers.
+-keepclassmembers class * extends android.app.Activity {
+ public void *(android.view.View);
+ public void *(android.view.MenuItem);
+}
+
+# Any class or method annotated with NeededForTesting or NeededForReflection.
+-keep @com.android.contacts.test.NeededForTesting class *
+-keep @com.android.contacts.test.NeededForReflection class *
+-keepclassmembers class * {
+@com.android.contacts.test.NeededForTesting *;
+@com.android.contacts.test.NeededForReflection *;
+}
+
+-verbose
diff --git a/res/layout-land/dialpad_fragment.xml b/res/layout-land/dialpad_fragment.xml
new file mode 100644
index 000000000..d1cf3a405
--- /dev/null
+++ b/res/layout-land/dialpad_fragment.xml
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 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.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/top"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" >
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="3"
+ android:orientation="vertical" >
+
+ <LinearLayout
+ android:id="@+id/digits_container"
+ android:layout_width="match_parent"
+ android:layout_height="0px"
+ android:layout_weight="@integer/dialpad_layout_weight_digits"
+ android:layout_marginTop="@dimen/dialpad_vertical_margin"
+ android:background="@drawable/dialpad_background"
+ android:gravity="center">
+
+ <com.android.dialer.dialpad.DigitsEditText
+ android:id="@+id/digits"
+ android:layout_width="0dip"
+ android:layout_weight="1"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:textAppearance="@style/DialtactsDigitsTextAppearance"
+ android:textColor="?android:attr/textColorPrimary"
+ android:nextFocusRight="@+id/overflow_menu"
+ android:background="@android:color/transparent" />
+
+ <ImageButton
+ android:id="@+id/deleteButton"
+ android:layout_width="56dip"
+ android:layout_height="match_parent"
+ android:layout_gravity="center_vertical"
+ android:gravity="center"
+ android:state_enabled="false"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/description_delete_button"
+ android:src="@drawable/ic_dial_action_delete" />
+
+
+ </LinearLayout>
+ <!-- "Dialpad chooser" UI, shown only when the user brings up the
+ Dialer while a call is already in progress.
+ When this UI is visible, the other Dialer elements
+ (the textfield and button) are hidden. -->
+ <ListView android:id="@+id/dialpadChooser"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:footerDividersEnabled="true" />
+
+ <!-- Keypad section -->
+ <include layout="@layout/dialpad" />
+ </LinearLayout>
+ <View
+ android:layout_width="@dimen/dialpad_center_margin"
+ android:layout_height="match_parent"
+ android:background="#66000000"/>
+ <RelativeLayout
+ android:id="@+id/dialButtonContainer"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="2"
+ android:background="@drawable/dialpad_background">
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/dialpad_button_margin"
+ android:layout_above="@id/dialButton"
+ android:background="#33000000" />
+ <ImageButton android:id="@+id/dialButton"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/call_button_height"
+ android:layout_alignParentBottom="true"
+ android:state_enabled="false"
+ android:background="@drawable/btn_call"
+ android:contentDescription="@string/description_dial_button"
+ android:src="@drawable/ic_dial_action_call" />
+ </RelativeLayout>
+</LinearLayout>
diff --git a/res/layout-land/dialtacts_activity.xml b/res/layout-land/dialtacts_activity.xml
new file mode 100644
index 000000000..f43fe5f06
--- /dev/null
+++ b/res/layout-land/dialtacts_activity.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2012 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.
+-->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginTop="?android:attr/actionBarSize"
+ android:id="@+id/dialtacts_frame"
+ >
+ <android.support.v4.view.ViewPager
+ android:id="@+id/pager"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+</FrameLayout>
diff --git a/res/layout/call_detail.xml b/res/layout/call_detail.xml
new file mode 100644
index 000000000..8f38a19dc
--- /dev/null
+++ b/res/layout/call_detail.xml
@@ -0,0 +1,218 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 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.
+-->
+
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:ex="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/call_detail"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ android:background="@android:color/black"
+>
+ <!--
+ The list view is under everything.
+ It contains a first header element which is hidden under the controls UI.
+ When scrolling, the controls move up until the name bar hits the top.
+ -->
+ <ListView
+ android:id="@+id/history"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentTop="true"
+ />
+
+ <!-- All the controls which are part of the pinned header are in this layout. -->
+ <RelativeLayout
+ android:id="@+id/controls"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentTop="true"
+ >
+ <FrameLayout
+ android:id="@+id/voicemail_status"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentTop="true"
+ android:visibility="gone"
+ >
+ <include layout="@layout/call_log_voicemail_status"/>
+ </FrameLayout>
+
+ <view
+ class="com.android.contacts.widget.ProportionalLayout"
+ android:id="@+id/contact_background_sizer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentLeft="true"
+ android:layout_below="@id/voicemail_status"
+ ex:ratio="0.5"
+ ex:direction="widthToHeight"
+ >
+ <ImageView
+ android:id="@+id/contact_background"
+ android:layout_width="match_parent"
+ android:layout_height="0dip"
+ android:adjustViewBounds="true"
+ android:scaleType="centerCrop"
+ />
+ </view>
+ <LinearLayout
+ android:id="@+id/blue_separator"
+ android:layout_width="match_parent"
+ android:layout_height="1dip"
+ android:background="@android:color/holo_blue_light"
+ android:layout_below="@+id/contact_background_sizer"
+ />
+ <View
+ android:id="@+id/photo_text_bar"
+ android:layout_width="match_parent"
+ android:layout_height="42dip"
+ android:background="#7F000000"
+ android:layout_alignParentLeft="true"
+ android:layout_alignBottom="@id/contact_background_sizer"
+ />
+ <ImageView
+ android:id="@+id/main_action"
+ android:layout_width="wrap_content"
+ android:layout_height="0dip"
+ android:scaleType="center"
+ android:layout_alignRight="@id/photo_text_bar"
+ android:layout_alignBottom="@id/photo_text_bar"
+ android:layout_alignTop="@id/photo_text_bar"
+ android:layout_marginRight="@dimen/call_log_outer_margin"
+ />
+ <TextView
+ android:id="@+id/header_text"
+ android:layout_width="wrap_content"
+ android:layout_height="0dip"
+ android:layout_alignLeft="@id/photo_text_bar"
+ android:layout_toLeftOf="@id/main_action"
+ android:layout_alignTop="@id/photo_text_bar"
+ android:layout_alignBottom="@id/photo_text_bar"
+ android:layout_marginRight="@dimen/call_log_inner_margin"
+ android:layout_marginLeft="@dimen/call_detail_contact_name_margin"
+ android:gravity="center_vertical"
+ android:textColor="?attr/call_log_primary_text_color"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:singleLine="true"
+ />
+ <ImageButton
+ android:id="@+id/main_action_push_layer"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_alignLeft="@id/contact_background_sizer"
+ android:layout_alignTop="@id/contact_background_sizer"
+ android:layout_alignRight="@id/contact_background_sizer"
+ android:layout_alignBottom="@id/contact_background_sizer"
+ android:background="?android:attr/selectableItemBackground"
+ />
+ <LinearLayout
+ android:id="@+id/voicemail_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingBottom="@dimen/call_detail_button_spacing"
+ android:layout_below="@id/blue_separator"
+ >
+ <!-- The voicemail fragment will be put here. -->
+ </LinearLayout>
+ <FrameLayout
+ android:id="@+id/call_and_sms"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/call_log_list_item_height"
+ android:layout_marginBottom="@dimen/call_detail_button_spacing"
+ android:layout_below="@id/voicemail_container"
+ android:gravity="center_vertical"
+ android:background="@drawable/dialpad_background"
+ >
+ <LinearLayout
+ android:id="@+id/call_and_sms_main_action"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="horizontal"
+ android:focusable="true"
+ android:background="?android:attr/selectableItemBackground"
+ >
+
+ <LinearLayout
+ android:layout_width="0dip"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:paddingLeft="@dimen/call_log_indent_margin"
+ android:orientation="vertical"
+ android:gravity="center_vertical"
+ >
+
+ <TextView android:id="@+id/call_and_sms_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:paddingRight="@dimen/call_log_icon_margin"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:textColor="?attr/call_log_primary_text_color"
+ android:singleLine="true"
+ android:ellipsize="end"
+ />
+
+ <TextView android:id="@+id/call_and_sms_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:paddingRight="@dimen/call_log_icon_margin"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?attr/call_log_primary_text_color"
+ android:textAllCaps="true"
+ android:singleLine="true"
+ android:ellipsize="end"
+ />
+ </LinearLayout>
+
+ <View android:id="@+id/call_and_sms_divider"
+ android:layout_width="1px"
+ android:layout_height="32dip"
+ android:background="@drawable/ic_divider_dashed_holo_dark"
+ android:layout_gravity="center_vertical"
+ />
+
+ <ImageView android:id="@+id/call_and_sms_icon"
+ android:layout_width="@color/call_log_voicemail_highlight_color"
+ android:layout_height="match_parent"
+ android:paddingLeft="@dimen/call_log_inner_margin"
+ android:paddingRight="@dimen/call_log_outer_margin"
+ android:gravity="center"
+ android:scaleType="centerInside"
+ android:focusable="true"
+ android:background="?android:attr/selectableItemBackground"
+ />
+ </LinearLayout>
+ </FrameLayout>
+ </RelativeLayout>
+
+ <!--
+ Used to hide the UI when playing a voicemail and the proximity sensor
+ is detecting something near the screen.
+ -->
+ <View
+ android:id="@+id/blank"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentTop="true"
+ android:background="@android:color/black"
+ android:visibility="gone"
+ android:clickable="true"
+ />
+</RelativeLayout>
diff --git a/res/layout/call_detail_history_header.xml b/res/layout/call_detail_history_header.xml
new file mode 100644
index 000000000..09047c59c
--- /dev/null
+++ b/res/layout/call_detail_history_header.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 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.
+-->
+
+<!-- This layout is supposed to match the content of the controls in call_detail.xml -->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:ex="http://schemas.android.com/apk/res-auto"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <!-- Contact photo. -->
+ <view
+ class="com.android.contacts.widget.ProportionalLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentLeft="true"
+ android:layout_below="@id/voicemail_status"
+ ex:ratio="0.5"
+ ex:direction="widthToHeight"
+ >
+ <!-- Proportional layout requires a view in it. -->
+ <View
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ />
+ </view>
+ <!-- Separator line -->
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dip"
+ />
+ <!-- Voicemail controls -->
+ <!-- TODO: Make the height be based on a constant. -->
+ <View
+ android:id="@+id/header_voicemail_container"
+ android:layout_width="match_parent"
+ android:layout_height="140dip"
+ android:layout_marginBottom="@dimen/call_detail_button_spacing"
+ />
+ <!-- Call and SMS -->
+ <View
+ android:id="@+id/header_call_and_sms_container"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/call_log_list_item_height"
+ />
+
+</LinearLayout>
diff --git a/res/layout/call_detail_history_item.xml b/res/layout/call_detail_history_item.xml
new file mode 100644
index 000000000..01b9517b6
--- /dev/null
+++ b/res/layout/call_detail_history_item.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 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.
+-->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="@dimen/call_log_list_item_height"
+ android:paddingTop="@dimen/call_log_inner_margin"
+ android:paddingBottom="@dimen/call_log_inner_margin"
+ android:paddingLeft="@dimen/call_log_indent_margin"
+ android:paddingRight="@dimen/call_log_outer_margin"
+ android:orientation="vertical"
+>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ >
+ <view
+ class="com.android.dialer.calllog.CallTypeIconsView"
+ android:id="@+id/call_type_icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ />
+ <TextView
+ android:id="@+id/call_type_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="@dimen/call_log_icon_margin"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="@color/secondary_text_color"
+ />
+ </LinearLayout>
+ <TextView
+ android:id="@+id/date"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="@color/secondary_text_color"
+ />
+ <TextView
+ android:id="@+id/duration"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="@color/secondary_text_color"
+ />
+</LinearLayout>
diff --git a/res/layout/call_log_fragment.xml b/res/layout/call_log_fragment.xml
new file mode 100644
index 000000000..34b4b7fd9
--- /dev/null
+++ b/res/layout/call_log_fragment.xml
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 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.
+-->
+
+<!-- Layout parameters are set programmatically. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ style="@style/FragmentActionBarPadding"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:divider="?android:attr/dividerHorizontal"
+ android:showDividers="end">
+
+ <FrameLayout
+ android:id="@+id/voicemail_status"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone">
+ <include layout="@layout/call_log_voicemail_status"
+ />
+ </FrameLayout>
+
+ <FrameLayout>
+ <TextView
+ android:id="@+id/filter_status"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingLeft="@dimen/call_log_outer_margin"
+ android:paddingRight="@dimen/call_log_outer_margin"
+ android:paddingTop="@dimen/call_log_inner_margin"
+ android:paddingBottom="@dimen/call_log_inner_margin"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentBottom="true"
+ android:visibility="gone"
+ />
+ <View
+ android:id="@+id/call_log_divider"
+ android:layout_width="match_parent"
+ android:layout_height="1px"
+ android:layout_marginLeft="@dimen/call_log_outer_margin"
+ android:layout_marginRight="@dimen/call_log_outer_margin"
+ android:layout_gravity="bottom"
+ android:background="#55ffffff"
+ />
+ </FrameLayout>
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <ListView android:id="@android:id/list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fadingEdge="none"
+ android:scrollbarStyle="outsideOverlay"
+ android:divider="@null"
+ />
+ <TextView android:id="@android:id/empty"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:text="@string/recentCalls_empty"
+ android:gravity="center"
+ android:layout_marginTop="@dimen/empty_message_top_margin"
+ android:textColor="?android:attr/textColorSecondary"
+ android:textAppearance="?android:attr/textAppearanceLarge"
+ />
+ </FrameLayout>
+</LinearLayout>
diff --git a/res/layout/call_log_list_item.xml b/res/layout/call_log_list_item.xml
new file mode 100644
index 000000000..8564c0d37
--- /dev/null
+++ b/res/layout/call_log_list_item.xml
@@ -0,0 +1,167 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 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.
+-->
+
+<view
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ class="com.android.dialer.calllog.CallLogListItemView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+>
+ <!--
+ This layout may represent either a call log item or one of the
+ headers in the call log.
+
+ The former will make the @id/call_log_item visible and the
+ @id/call_log_header gone.
+
+ The latter will make the @id/call_log_header visible and the
+ @id/call_log_item gone
+ -->
+
+ <LinearLayout
+ android:id="@+id/primary_action_view"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_centerVertical="true"
+ android:layout_marginLeft="@dimen/call_log_outer_margin"
+ android:layout_marginRight="@dimen/call_log_outer_margin"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:background="?android:attr/selectableItemBackground"
+ android:focusable="true"
+ android:nextFocusRight="@+id/secondary_action_icon"
+ android:nextFocusLeft="@+id/quick_contact_photo"
+ >
+ <QuickContactBadge
+ android:id="@+id/quick_contact_photo"
+ android:layout_width="@dimen/call_log_list_contact_photo_size"
+ android:layout_height="@dimen/call_log_list_contact_photo_size"
+ android:nextFocusRight="@id/primary_action_view"
+ android:layout_alignParentLeft="true"
+ android:layout_centerVertical="true"
+ android:focusable="true"
+ />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:paddingTop="@dimen/call_log_inner_margin"
+ android:paddingBottom="@dimen/call_log_inner_margin"
+ android:orientation="vertical"
+ android:gravity="center_vertical"
+ android:layout_marginLeft="@dimen/call_log_inner_margin"
+ >
+ <TextView
+ android:id="@+id/name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="@dimen/call_log_icon_margin"
+ android:textColor="?attr/call_log_primary_text_color"
+ android:textSize="18sp"
+ android:singleLine="true"
+ />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ >
+ <TextView
+ android:id="@+id/number"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="@dimen/call_log_icon_margin"
+ android:textColor="?attr/call_log_secondary_text_color"
+ android:textSize="14sp"
+ android:singleLine="true"
+ android:ellipsize="marquee"
+ />
+ <TextView
+ android:id="@+id/label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="@dimen/call_log_icon_margin"
+ android:textColor="?attr/call_log_secondary_text_color"
+ android:textStyle="bold"
+ android:textSize="14sp"
+ android:singleLine="true"
+ android:ellipsize="marquee"
+ />
+ </LinearLayout>
+ <LinearLayout
+ android:id="@+id/call_type"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ >
+ <view
+ class="com.android.dialer.calllog.CallTypeIconsView"
+ android:id="@+id/call_type_icons"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="@dimen/call_log_icon_margin"
+ android:layout_gravity="center_vertical"
+ />
+ <TextView
+ android:id="@+id/call_count_and_date"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="@dimen/call_log_icon_margin"
+ android:layout_gravity="center_vertical"
+ android:textColor="?attr/call_log_secondary_text_color"
+ android:textSize="14sp"
+ android:singleLine="true"
+ />
+ </LinearLayout>
+ </LinearLayout>
+ <View
+ android:id="@+id/divider"
+ android:layout_width="1px"
+ android:layout_height="@dimen/call_log_call_action_size"
+ android:background="@drawable/ic_divider_dashed_holo_dark"
+ android:layout_gravity="center_vertical"
+ />
+ <ImageButton
+ android:id="@+id/secondary_action_icon"
+ android:layout_width="@dimen/call_log_call_action_width"
+ android:layout_height="match_parent"
+ android:paddingLeft="@dimen/call_log_inner_margin"
+ android:paddingTop="@dimen/call_log_inner_margin"
+ android:paddingBottom="@dimen/call_log_inner_margin"
+ android:paddingRight="@dimen/call_log_inner_margin"
+ android:scaleType="center"
+ android:background="?android:attr/selectableItemBackground"
+ android:nextFocusLeft="@id/primary_action_view"
+ />
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/call_log_header"
+ style="@style/ContactListSeparatorTextViewStyle"
+ android:layout_marginLeft="@dimen/call_log_outer_margin"
+ android:layout_marginRight="@dimen/call_log_outer_margin"
+ android:paddingTop="@dimen/call_log_inner_margin"
+ android:paddingBottom="@dimen/call_log_inner_margin" />
+
+ <View
+ android:id="@+id/call_log_divider"
+ android:layout_width="match_parent"
+ android:layout_height="1px"
+ android:layout_marginLeft="@dimen/call_log_outer_margin"
+ android:layout_marginRight="@dimen/call_log_outer_margin"
+ android:background="#55ffffff"
+ />
+</view>
diff --git a/res/layout/call_log_voicemail_status.xml b/res/layout/call_log_voicemail_status.xml
new file mode 100644
index 000000000..191c821f4
--- /dev/null
+++ b/res/layout/call_log_voicemail_status.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 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.
+-->
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="?attr/call_log_voicemail_status_height"
+ android:background="?attr/call_log_voicemail_status_background_color"
+ >
+ <TextView
+ android:id="@+id/voicemail_status_message"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_weight="1"
+ android:paddingLeft="@dimen/call_log_outer_margin"
+ android:paddingRight="@dimen/call_log_inner_margin"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:textColor="?attr/call_log_voicemail_status_text_color"
+ />
+ <TextView
+ android:id="@+id/voicemail_status_action"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:gravity="center_vertical"
+ android:paddingLeft="@dimen/call_log_inner_margin"
+ android:paddingRight="@dimen/call_log_outer_margin"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:textColor="?attr/call_log_voicemail_status_action_text_color"
+ android:background="?android:attr/selectableItemBackground"
+ android:clickable="true"
+ />
+ </LinearLayout>
+</merge>
diff --git a/res/layout/dialpad.xml b/res/layout/dialpad.xml
new file mode 100644
index 000000000..3ccb42dbf
--- /dev/null
+++ b/res/layout/dialpad.xml
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2006 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.
+-->
+
+<!-- Dialpad in the Phone app. -->
+<TableLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/dialpad"
+ android:layout_width="match_parent"
+ android:layout_height="0px"
+ android:layout_weight="@integer/dialpad_layout_weight_dialpad"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="@dimen/dialpad_vertical_margin"
+ android:paddingLeft="5dip"
+ android:paddingRight="5dip"
+ android:paddingBottom="10dip"
+ android:background="@drawable/dialpad_background">
+
+ <TableRow
+ android:layout_height="0px"
+ android:layout_weight="1">
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/one" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_1_wht"
+ android:contentDescription="@string/description_image_button_one" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/two" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_2_wht"
+ android:contentDescription="@string/description_image_button_two" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/three" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_3_wht"
+ android:contentDescription="@string/description_image_button_three" />
+ </TableRow>
+
+ <TableRow
+ android:layout_height="0px"
+ android:layout_weight="1">
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/four" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_4_wht"
+ android:contentDescription="@string/description_image_button_four" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/five" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_5_wht"
+ android:contentDescription="@string/description_image_button_five" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/six" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_6_wht"
+ android:contentDescription="@string/description_image_button_six" />
+ </TableRow>
+
+ <TableRow
+ android:layout_height="0px"
+ android:layout_weight="1">
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/seven" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_7_wht"
+ android:contentDescription="@string/description_image_button_seven" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/eight" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_8_wht"
+ android:contentDescription="@string/description_image_button_eight" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/nine" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_9_wht"
+ android:contentDescription="@string/description_image_button_nine" />
+ </TableRow>
+
+ <TableRow
+ android:layout_height="0px"
+ android:layout_weight="1">
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/star" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_star_wht"
+ android:contentDescription="@string/description_image_button_star" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/zero" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_0_wht"
+ android:contentDescription="@string/description_image_button_zero" />
+ <com.android.dialer.dialpad.DialpadImageButton
+ android:id="@+id/pound" style="@style/DialtactsDialpadButtonStyle"
+ android:src="@drawable/dial_num_pound_wht"
+ android:contentDescription="@string/description_image_button_pound" />
+ </TableRow>
+</TableLayout>
diff --git a/res/layout/dialpad_chooser_list_item.xml b/res/layout/dialpad_chooser_list_item.xml
new file mode 100644
index 000000000..853ca4734
--- /dev/null
+++ b/res/layout/dialpad_chooser_list_item.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2008 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.
+-->
+
+<!-- Layout of a single item in the Dialer's "Dialpad chooser" UI. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="horizontal"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <ImageView android:id="@+id/icon"
+ android:layout_width="64dp"
+ android:layout_height="64dp"
+ android:scaleType="center" />
+
+ <TextView android:id="@+id/text"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:layout_gravity="center_vertical"
+ android:layout_width="0dip"
+ android:layout_weight="1"
+ android:layout_height="wrap_content" />
+
+</LinearLayout>
diff --git a/res/layout/dialpad_fragment.xml b/res/layout/dialpad_fragment.xml
new file mode 100644
index 000000000..6423638a3
--- /dev/null
+++ b/res/layout/dialpad_fragment.xml
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 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.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/top"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:paddingLeft="@dimen/dialpad_horizontal_margin"
+ android:paddingRight="@dimen/dialpad_horizontal_margin">
+
+ <!-- Text field and possibly soft menu button above the keypad where
+ the digits are displayed. -->
+ <LinearLayout
+ android:id="@+id/digits_container"
+ android:layout_width="match_parent"
+ android:layout_height="0px"
+ android:layout_weight="@integer/dialpad_layout_weight_digits"
+ android:layout_marginTop="@dimen/dialpad_vertical_margin"
+ android:gravity="center"
+ android:background="@drawable/dialpad_background" >
+
+ <com.android.dialer.dialpad.DigitsEditText
+ android:id="@+id/digits"
+ android:layout_width="0dip"
+ android:layout_weight="1"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:textAppearance="@style/DialtactsDigitsTextAppearance"
+ android:textColor="?android:attr/textColorPrimary"
+ android:nextFocusRight="@+id/overflow_menu"
+ android:background="@android:color/transparent" />
+
+ <ImageButton
+ android:id="@+id/deleteButton"
+ android:layout_width="56dip"
+ android:layout_height="match_parent"
+ android:layout_gravity="center_vertical"
+ android:gravity="center"
+ android:state_enabled="false"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/description_delete_button"
+ android:src="@drawable/ic_dial_action_delete" />
+ </LinearLayout>
+
+ <!-- Keypad section -->
+ <include layout="@layout/dialpad" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/dialpad_vertical_margin"
+ android:background="#66000000"/>
+
+ <!-- left and right paddings will be modified by the code. See DialpadFragment. -->
+ <FrameLayout
+ android:id="@+id/dialButtonContainer"
+ android:layout_width="match_parent"
+ android:layout_height="0px"
+ android:layout_weight="@integer/dialpad_layout_weight_additional_buttons"
+ android:layout_gravity="center_horizontal"
+ android:background="@drawable/dialpad_background">
+
+ <ImageButton
+ android:id="@+id/dialButton"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"
+ android:state_enabled="false"
+ android:background="@drawable/btn_call"
+ android:contentDescription="@string/description_dial_button"
+ android:src="@drawable/ic_dial_action_call" />
+
+ </FrameLayout>
+
+ <!-- "Dialpad chooser" UI, shown only when the user brings up the
+ Dialer while a call is already in progress.
+ When this UI is visible, the other Dialer elements
+ (the textfield/button and the dialpad) are hidden. -->
+ <ListView android:id="@+id/dialpadChooser"
+ android:layout_width="match_parent"
+ android:layout_height="1dip"
+ android:layout_weight="1"
+ />
+
+</LinearLayout>
diff --git a/res/layout/dialtacts_activity.xml b/res/layout/dialtacts_activity.xml
new file mode 100644
index 000000000..35fa00f08
--- /dev/null
+++ b/res/layout/dialtacts_activity.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2006 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.
+-->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginTop="?android:attr/actionBarSize"
+ android:id="@+id/dialtacts_frame"
+ >
+ <android.support.v4.view.ViewPager
+ android:id="@+id/pager"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+
+ <ImageButton
+ android:id="@+id/searchButton"
+ android:layout_width="wrap_content"
+ android:layout_height="?android:attr/actionBarSize"
+ android:layout_gravity="bottom|left"
+ android:state_enabled="false"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/description_search_button"
+ android:src="@drawable/ic_dial_action_search"/>
+
+ <ImageButton
+ android:id="@+id/overflow_menu"
+ android:layout_width="wrap_content"
+ android:layout_height="?android:attr/actionBarSize"
+ android:layout_gravity="bottom|right"
+ android:src="@drawable/ic_menu_overflow"
+ android:contentDescription="@string/action_menu_overflow_description"
+ android:nextFocusLeft="@id/digits"
+ android:background="?android:attr/selectableItemBackground"/>
+</FrameLayout>
diff --git a/res/layout/dialtacts_custom_action_bar.xml b/res/layout/dialtacts_custom_action_bar.xml
new file mode 100644
index 000000000..0af8eaae3
--- /dev/null
+++ b/res/layout/dialtacts_custom_action_bar.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 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.
+-->
+
+<!-- Dimensions are set at runtime in ActionBarAdapter -->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="0dip"
+ android:layout_height="0dip"
+ android:orientation="horizontal">
+
+ <SearchView
+ android:id="@+id/search_view"
+ android:layout_width="0px"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:iconifiedByDefault="false"
+ android:inputType="textFilter" />
+
+ <ImageButton
+ android:id="@+id/search_option"
+ android:layout_width="wrap_content"
+ android:paddingLeft="4dip"
+ android:paddingRight="4dip"
+ android:layout_height="match_parent"
+ android:layout_alignParentRight="true"
+ android:src="@drawable/ic_menu_overflow"
+ android:background="?android:attr/selectableItemBackground"
+ android:visibility="gone" />
+
+</LinearLayout>
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 000000000..2daa236ed
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2012 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
+ -->
+
+
+<resources>
+ <!-- Directory partition name -->
+ <string name="applicationLabel">Dialer</string>
+
+ <!-- Title for the activity that dials the phone. This is the name
+ used in the Launcher icon. -->
+ <string name="launcherDialer">Phone</string>
+
+</resources>
diff --git a/src/com/android/dialer/CallDetailActivity.java b/src/com/android/dialer/CallDetailActivity.java
new file mode 100644
index 000000000..f4ca21305
--- /dev/null
+++ b/src/com/android/dialer/CallDetailActivity.java
@@ -0,0 +1,942 @@
+/*
+ * Copyright (C) 2009 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;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.provider.CallLog;
+import android.provider.CallLog.Calls;
+import android.provider.Contacts.Intents.Insert;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+import android.provider.VoicemailContract.Voicemails;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ActionMode;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.contacts.BackScrollManager;
+import com.android.contacts.BackScrollManager.ScrollableHeader;
+import com.android.contacts.ContactPhotoManager;
+import com.android.contacts.ContactsUtils;
+import com.android.contacts.ProximitySensorAware;
+import com.android.contacts.ProximitySensorManager;
+import com.android.contacts.R;
+import com.android.dialer.calllog.CallDetailHistoryAdapter;
+import com.android.dialer.calllog.CallTypeHelper;
+import com.android.dialer.calllog.ContactInfo;
+import com.android.dialer.calllog.ContactInfoHelper;
+import com.android.dialer.calllog.PhoneNumberHelper;
+import com.android.contacts.format.FormatUtils;
+import com.android.contacts.util.AsyncTaskExecutor;
+import com.android.contacts.util.AsyncTaskExecutors;
+import com.android.contacts.util.ClipboardUtils;
+import com.android.contacts.util.Constants;
+import com.android.dialer.voicemail.VoicemailPlaybackFragment;
+import com.android.dialer.voicemail.VoicemailStatusHelper;
+import com.android.dialer.voicemail.VoicemailStatusHelper.StatusMessage;
+import com.android.dialer.voicemail.VoicemailStatusHelperImpl;
+
+import java.util.List;
+
+/**
+ * Displays the details of a specific call log entry.
+ * <p>
+ * This activity can be either started with the URI of a single call log entry, or with the
+ * {@link #EXTRA_CALL_LOG_IDS} extra to specify a group of call log entries.
+ */
+public class CallDetailActivity extends Activity implements ProximitySensorAware {
+ private static final String TAG = "CallDetail";
+
+ /** The time to wait before enabling the blank the screen due to the proximity sensor. */
+ private static final long PROXIMITY_BLANK_DELAY_MILLIS = 100;
+ /** The time to wait before disabling the blank the screen due to the proximity sensor. */
+ private static final long PROXIMITY_UNBLANK_DELAY_MILLIS = 500;
+
+ /** The enumeration of {@link AsyncTask} objects used in this class. */
+ public enum Tasks {
+ MARK_VOICEMAIL_READ,
+ DELETE_VOICEMAIL_AND_FINISH,
+ REMOVE_FROM_CALL_LOG_AND_FINISH,
+ UPDATE_PHONE_CALL_DETAILS,
+ }
+
+ /** A long array extra containing ids of call log entries to display. */
+ public static final String EXTRA_CALL_LOG_IDS = "EXTRA_CALL_LOG_IDS";
+ /** If we are started with a voicemail, we'll find the uri to play with this extra. */
+ public static final String EXTRA_VOICEMAIL_URI = "EXTRA_VOICEMAIL_URI";
+ /** If we should immediately start playback of the voicemail, this extra will be set to true. */
+ public static final String EXTRA_VOICEMAIL_START_PLAYBACK = "EXTRA_VOICEMAIL_START_PLAYBACK";
+ /** If the activity was triggered from a notification. */
+ public static final String EXTRA_FROM_NOTIFICATION = "EXTRA_FROM_NOTIFICATION";
+
+ private CallTypeHelper mCallTypeHelper;
+ private PhoneNumberHelper mPhoneNumberHelper;
+ private PhoneCallDetailsHelper mPhoneCallDetailsHelper;
+ private TextView mHeaderTextView;
+ private View mHeaderOverlayView;
+ private ImageView mMainActionView;
+ private ImageButton mMainActionPushLayerView;
+ private ImageView mContactBackgroundView;
+ private AsyncTaskExecutor mAsyncTaskExecutor;
+ private ContactInfoHelper mContactInfoHelper;
+
+ private String mNumber = null;
+ private String mDefaultCountryIso;
+
+ /* package */ LayoutInflater mInflater;
+ /* package */ Resources mResources;
+ /** Helper to load contact photos. */
+ private ContactPhotoManager mContactPhotoManager;
+ /** Helper to make async queries to content resolver. */
+ private CallDetailActivityQueryHandler mAsyncQueryHandler;
+ /** Helper to get voicemail status messages. */
+ private VoicemailStatusHelper mVoicemailStatusHelper;
+ // Views related to voicemail status message.
+ private View mStatusMessageView;
+ private TextView mStatusMessageText;
+ private TextView mStatusMessageAction;
+
+ /** Whether we should show "edit number before call" in the options menu. */
+ private boolean mHasEditNumberBeforeCallOption;
+ /** Whether we should show "trash" in the options menu. */
+ private boolean mHasTrashOption;
+ /** Whether we should show "remove from call log" in the options menu. */
+ private boolean mHasRemoveFromCallLogOption;
+
+ private ProximitySensorManager mProximitySensorManager;
+ private final ProximitySensorListener mProximitySensorListener = new ProximitySensorListener();
+
+ /**
+ * The action mode used when the phone number is selected. This will be non-null only when the
+ * phone number is selected.
+ */
+ private ActionMode mPhoneNumberActionMode;
+
+ private CharSequence mPhoneNumberLabelToCopy;
+ private CharSequence mPhoneNumberToCopy;
+
+ /** Listener to changes in the proximity sensor state. */
+ private class ProximitySensorListener implements ProximitySensorManager.Listener {
+ /** Used to show a blank view and hide the action bar. */
+ private final Runnable mBlankRunnable = new Runnable() {
+ @Override
+ public void run() {
+ View blankView = findViewById(R.id.blank);
+ blankView.setVisibility(View.VISIBLE);
+ getActionBar().hide();
+ }
+ };
+ /** Used to remove the blank view and show the action bar. */
+ private final Runnable mUnblankRunnable = new Runnable() {
+ @Override
+ public void run() {
+ View blankView = findViewById(R.id.blank);
+ blankView.setVisibility(View.GONE);
+ getActionBar().show();
+ }
+ };
+
+ @Override
+ public synchronized void onNear() {
+ clearPendingRequests();
+ postDelayed(mBlankRunnable, PROXIMITY_BLANK_DELAY_MILLIS);
+ }
+
+ @Override
+ public synchronized void onFar() {
+ clearPendingRequests();
+ postDelayed(mUnblankRunnable, PROXIMITY_UNBLANK_DELAY_MILLIS);
+ }
+
+ /** Removed any delayed requests that may be pending. */
+ public synchronized void clearPendingRequests() {
+ View blankView = findViewById(R.id.blank);
+ blankView.removeCallbacks(mBlankRunnable);
+ blankView.removeCallbacks(mUnblankRunnable);
+ }
+
+ /** Post a {@link Runnable} with a delay on the main thread. */
+ private synchronized void postDelayed(Runnable runnable, long delayMillis) {
+ // Post these instead of executing immediately so that:
+ // - They are guaranteed to be executed on the main thread.
+ // - If the sensor values changes rapidly for some time, the UI will not be
+ // updated immediately.
+ View blankView = findViewById(R.id.blank);
+ blankView.postDelayed(runnable, delayMillis);
+ }
+ }
+
+ static final String[] CALL_LOG_PROJECTION = new String[] {
+ CallLog.Calls.DATE,
+ CallLog.Calls.DURATION,
+ CallLog.Calls.NUMBER,
+ CallLog.Calls.TYPE,
+ CallLog.Calls.COUNTRY_ISO,
+ CallLog.Calls.GEOCODED_LOCATION,
+ };
+
+ static final int DATE_COLUMN_INDEX = 0;
+ static final int DURATION_COLUMN_INDEX = 1;
+ static final int NUMBER_COLUMN_INDEX = 2;
+ static final int CALL_TYPE_COLUMN_INDEX = 3;
+ static final int COUNTRY_ISO_COLUMN_INDEX = 4;
+ static final int GEOCODED_LOCATION_COLUMN_INDEX = 5;
+
+ private final View.OnClickListener mPrimaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (finishPhoneNumerSelectedActionModeIfShown()) {
+ return;
+ }
+ startActivity(((ViewEntry) view.getTag()).primaryIntent);
+ }
+ };
+
+ private final View.OnClickListener mSecondaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (finishPhoneNumerSelectedActionModeIfShown()) {
+ return;
+ }
+ startActivity(((ViewEntry) view.getTag()).secondaryIntent);
+ }
+ };
+
+ private final View.OnLongClickListener mPrimaryLongClickListener =
+ new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ if (finishPhoneNumerSelectedActionModeIfShown()) {
+ return true;
+ }
+ startPhoneNumberSelectedActionMode(v);
+ return true;
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ setContentView(R.layout.call_detail);
+
+ mAsyncTaskExecutor = AsyncTaskExecutors.createThreadPoolExecutor();
+ mInflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
+ mResources = getResources();
+
+ mCallTypeHelper = new CallTypeHelper(getResources());
+ mPhoneNumberHelper = new PhoneNumberHelper(mResources);
+ mPhoneCallDetailsHelper = new PhoneCallDetailsHelper(mResources, mCallTypeHelper,
+ mPhoneNumberHelper);
+ mVoicemailStatusHelper = new VoicemailStatusHelperImpl();
+ mAsyncQueryHandler = new CallDetailActivityQueryHandler(this);
+ mHeaderTextView = (TextView) findViewById(R.id.header_text);
+ mHeaderOverlayView = findViewById(R.id.photo_text_bar);
+ mStatusMessageView = findViewById(R.id.voicemail_status);
+ mStatusMessageText = (TextView) findViewById(R.id.voicemail_status_message);
+ mStatusMessageAction = (TextView) findViewById(R.id.voicemail_status_action);
+ mMainActionView = (ImageView) findViewById(R.id.main_action);
+ mMainActionPushLayerView = (ImageButton) findViewById(R.id.main_action_push_layer);
+ mContactBackgroundView = (ImageView) findViewById(R.id.contact_background);
+ mDefaultCountryIso = ContactsUtils.getCurrentCountryIso(this);
+ mContactPhotoManager = ContactPhotoManager.getInstance(this);
+ mProximitySensorManager = new ProximitySensorManager(this, mProximitySensorListener);
+ mContactInfoHelper = new ContactInfoHelper(this, ContactsUtils.getCurrentCountryIso(this));
+ configureActionBar();
+ optionallyHandleVoicemail();
+ if (getIntent().getBooleanExtra(EXTRA_FROM_NOTIFICATION, false)) {
+ closeSystemDialogs();
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ updateData(getCallLogEntryUris());
+ }
+
+ /**
+ * Handle voicemail playback or hide voicemail ui.
+ * <p>
+ * If the Intent used to start this Activity contains the suitable extras, then start voicemail
+ * playback. If it doesn't, then hide the voicemail ui.
+ */
+ private void optionallyHandleVoicemail() {
+ View voicemailContainer = findViewById(R.id.voicemail_container);
+ if (hasVoicemail()) {
+ // Has voicemail: add the voicemail fragment. Add suitable arguments to set the uri
+ // to play and optionally start the playback.
+ // Do a query to fetch the voicemail status messages.
+ VoicemailPlaybackFragment playbackFragment = new VoicemailPlaybackFragment();
+ Bundle fragmentArguments = new Bundle();
+ fragmentArguments.putParcelable(EXTRA_VOICEMAIL_URI, getVoicemailUri());
+ if (getIntent().getBooleanExtra(EXTRA_VOICEMAIL_START_PLAYBACK, false)) {
+ fragmentArguments.putBoolean(EXTRA_VOICEMAIL_START_PLAYBACK, true);
+ }
+ playbackFragment.setArguments(fragmentArguments);
+ voicemailContainer.setVisibility(View.VISIBLE);
+ getFragmentManager().beginTransaction()
+ .add(R.id.voicemail_container, playbackFragment).commitAllowingStateLoss();
+ mAsyncQueryHandler.startVoicemailStatusQuery(getVoicemailUri());
+ markVoicemailAsRead(getVoicemailUri());
+ } else {
+ // No voicemail uri: hide the status view.
+ mStatusMessageView.setVisibility(View.GONE);
+ voicemailContainer.setVisibility(View.GONE);
+ }
+ }
+
+ private boolean hasVoicemail() {
+ return getVoicemailUri() != null;
+ }
+
+ private Uri getVoicemailUri() {
+ return getIntent().getParcelableExtra(EXTRA_VOICEMAIL_URI);
+ }
+
+ private void markVoicemailAsRead(final Uri voicemailUri) {
+ mAsyncTaskExecutor.submit(Tasks.MARK_VOICEMAIL_READ, new AsyncTask<Void, Void, Void>() {
+ @Override
+ public Void doInBackground(Void... params) {
+ ContentValues values = new ContentValues();
+ values.put(Voicemails.IS_READ, true);
+ getContentResolver().update(voicemailUri, values,
+ Voicemails.IS_READ + " = 0", null);
+ return null;
+ }
+ });
+ }
+
+ /**
+ * Returns the list of URIs to show.
+ * <p>
+ * There are two ways the URIs can be provided to the activity: as the data on the intent, or as
+ * a list of ids in the call log added as an extra on the URI.
+ * <p>
+ * If both are available, the data on the intent takes precedence.
+ */
+ private Uri[] getCallLogEntryUris() {
+ Uri uri = getIntent().getData();
+ if (uri != null) {
+ // If there is a data on the intent, it takes precedence over the extra.
+ return new Uri[]{ uri };
+ }
+ long[] ids = getIntent().getLongArrayExtra(EXTRA_CALL_LOG_IDS);
+ Uri[] uris = new Uri[ids.length];
+ for (int index = 0; index < ids.length; ++index) {
+ uris[index] = ContentUris.withAppendedId(Calls.CONTENT_URI_WITH_VOICEMAIL, ids[index]);
+ }
+ return uris;
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_CALL: {
+ // Make sure phone isn't already busy before starting direct call
+ TelephonyManager tm = (TelephonyManager)
+ getSystemService(Context.TELEPHONY_SERVICE);
+ if (tm.getCallState() == TelephonyManager.CALL_STATE_IDLE) {
+ startActivity(ContactsUtils.getCallIntent(
+ Uri.fromParts(Constants.SCHEME_TEL, mNumber, null)));
+ return true;
+ }
+ }
+ }
+
+ return super.onKeyDown(keyCode, event);
+ }
+
+ /**
+ * Update user interface with details of given call.
+ *
+ * @param callUris URIs into {@link CallLog.Calls} of the calls to be displayed
+ */
+ private void updateData(final Uri... callUris) {
+ class UpdateContactDetailsTask extends AsyncTask<Void, Void, PhoneCallDetails[]> {
+ @Override
+ public PhoneCallDetails[] doInBackground(Void... params) {
+ // TODO: All phone calls correspond to the same person, so we can make a single
+ // lookup.
+ final int numCalls = callUris.length;
+ PhoneCallDetails[] details = new PhoneCallDetails[numCalls];
+ try {
+ for (int index = 0; index < numCalls; ++index) {
+ details[index] = getPhoneCallDetailsForUri(callUris[index]);
+ }
+ return details;
+ } catch (IllegalArgumentException e) {
+ // Something went wrong reading in our primary data.
+ Log.w(TAG, "invalid URI starting call details", e);
+ return null;
+ }
+ }
+
+ @Override
+ public void onPostExecute(PhoneCallDetails[] details) {
+ if (details == null) {
+ // Somewhere went wrong: we're going to bail out and show error to users.
+ Toast.makeText(CallDetailActivity.this, R.string.toast_call_detail_error,
+ Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ // We know that all calls are from the same number and the same contact, so pick the
+ // first.
+ PhoneCallDetails firstDetails = details[0];
+ mNumber = firstDetails.number.toString();
+ final Uri contactUri = firstDetails.contactUri;
+ final Uri photoUri = firstDetails.photoUri;
+
+ // Set the details header, based on the first phone call.
+ mPhoneCallDetailsHelper.setCallDetailsHeader(mHeaderTextView, firstDetails);
+
+ // Cache the details about the phone number.
+ final boolean canPlaceCallsTo = mPhoneNumberHelper.canPlaceCallsTo(mNumber);
+ final boolean isVoicemailNumber = mPhoneNumberHelper.isVoicemailNumber(mNumber);
+ final boolean isSipNumber = mPhoneNumberHelper.isSipNumber(mNumber);
+
+ // Let user view contact details if they exist, otherwise add option to create new
+ // contact from this number.
+ final Intent mainActionIntent;
+ final int mainActionIcon;
+ final String mainActionDescription;
+
+ final CharSequence nameOrNumber;
+ if (!TextUtils.isEmpty(firstDetails.name)) {
+ nameOrNumber = firstDetails.name;
+ } else {
+ nameOrNumber = firstDetails.number;
+ }
+
+ if (contactUri != null) {
+ mainActionIntent = new Intent(Intent.ACTION_VIEW, contactUri);
+ // This will launch People's detail contact screen, so we probably want to
+ // treat it as a separate People task.
+ mainActionIntent.setFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ mainActionIcon = R.drawable.ic_contacts_holo_dark;
+ mainActionDescription =
+ getString(R.string.description_view_contact, nameOrNumber);
+ } else if (isVoicemailNumber) {
+ mainActionIntent = null;
+ mainActionIcon = 0;
+ mainActionDescription = null;
+ } else if (isSipNumber) {
+ // TODO: This item is currently disabled for SIP addresses, because
+ // the Insert.PHONE extra only works correctly for PSTN numbers.
+ //
+ // To fix this for SIP addresses, we need to:
+ // - define ContactsContract.Intents.Insert.SIP_ADDRESS, and use it here if
+ // the current number is a SIP address
+ // - update the contacts UI code to handle Insert.SIP_ADDRESS by
+ // updating the SipAddress field
+ // and then we can remove the "!isSipNumber" check above.
+ mainActionIntent = null;
+ mainActionIcon = 0;
+ mainActionDescription = null;
+ } else if (canPlaceCallsTo) {
+ mainActionIntent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
+ mainActionIntent.setType(Contacts.CONTENT_ITEM_TYPE);
+ mainActionIntent.putExtra(Insert.PHONE, mNumber);
+ mainActionIcon = R.drawable.ic_add_contact_holo_dark;
+ mainActionDescription = getString(R.string.description_add_contact);
+ } else {
+ // If we cannot call the number, when we probably cannot add it as a contact either.
+ // This is usually the case of private, unknown, or payphone numbers.
+ mainActionIntent = null;
+ mainActionIcon = 0;
+ mainActionDescription = null;
+ }
+
+ if (mainActionIntent == null) {
+ mMainActionView.setVisibility(View.INVISIBLE);
+ mMainActionPushLayerView.setVisibility(View.GONE);
+ mHeaderTextView.setVisibility(View.INVISIBLE);
+ mHeaderOverlayView.setVisibility(View.INVISIBLE);
+ } else {
+ mMainActionView.setVisibility(View.VISIBLE);
+ mMainActionView.setImageResource(mainActionIcon);
+ mMainActionPushLayerView.setVisibility(View.VISIBLE);
+ mMainActionPushLayerView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(mainActionIntent);
+ }
+ });
+ mMainActionPushLayerView.setContentDescription(mainActionDescription);
+ mHeaderTextView.setVisibility(View.VISIBLE);
+ mHeaderOverlayView.setVisibility(View.VISIBLE);
+ }
+
+ // This action allows to call the number that places the call.
+ if (canPlaceCallsTo) {
+ final CharSequence displayNumber =
+ mPhoneNumberHelper.getDisplayNumber(
+ firstDetails.number, firstDetails.formattedNumber);
+
+ ViewEntry entry = new ViewEntry(
+ getString(R.string.menu_callNumber,
+ FormatUtils.forceLeftToRight(displayNumber)),
+ ContactsUtils.getCallIntent(mNumber),
+ getString(R.string.description_call, nameOrNumber));
+
+ // Only show a label if the number is shown and it is not a SIP address.
+ if (!TextUtils.isEmpty(firstDetails.name)
+ && !TextUtils.isEmpty(firstDetails.number)
+ && !PhoneNumberUtils.isUriNumber(firstDetails.number.toString())) {
+ entry.label = Phone.getTypeLabel(mResources, firstDetails.numberType,
+ firstDetails.numberLabel);
+ }
+
+ // The secondary action allows to send an SMS to the number that placed the
+ // call.
+ if (mPhoneNumberHelper.canSendSmsTo(mNumber)) {
+ entry.setSecondaryAction(
+ R.drawable.ic_text_holo_dark,
+ new Intent(Intent.ACTION_SENDTO,
+ Uri.fromParts("sms", mNumber, null)),
+ getString(R.string.description_send_text_message, nameOrNumber));
+ }
+
+ configureCallButton(entry);
+ mPhoneNumberToCopy = displayNumber;
+ mPhoneNumberLabelToCopy = entry.label;
+ } else {
+ disableCallButton();
+ mPhoneNumberToCopy = null;
+ mPhoneNumberLabelToCopy = null;
+ }
+
+ mHasEditNumberBeforeCallOption =
+ canPlaceCallsTo && !isSipNumber && !isVoicemailNumber;
+ mHasTrashOption = hasVoicemail();
+ mHasRemoveFromCallLogOption = !hasVoicemail();
+ invalidateOptionsMenu();
+
+ ListView historyList = (ListView) findViewById(R.id.history);
+ historyList.setAdapter(
+ new CallDetailHistoryAdapter(CallDetailActivity.this, mInflater,
+ mCallTypeHelper, details, hasVoicemail(), canPlaceCallsTo,
+ findViewById(R.id.controls)));
+ BackScrollManager.bind(
+ new ScrollableHeader() {
+ private View mControls = findViewById(R.id.controls);
+ private View mPhoto = findViewById(R.id.contact_background_sizer);
+ private View mHeader = findViewById(R.id.photo_text_bar);
+ private View mSeparator = findViewById(R.id.blue_separator);
+
+ @Override
+ public void setOffset(int offset) {
+ mControls.setY(-offset);
+ }
+
+ @Override
+ public int getMaximumScrollableHeaderOffset() {
+ // We can scroll the photo out, but we should keep the header if
+ // present.
+ if (mHeader.getVisibility() == View.VISIBLE) {
+ return mPhoto.getHeight() - mHeader.getHeight();
+ } else {
+ // If the header is not present, we should also scroll out the
+ // separator line.
+ return mPhoto.getHeight() + mSeparator.getHeight();
+ }
+ }
+ },
+ historyList);
+ loadContactPhotos(photoUri);
+ findViewById(R.id.call_detail).setVisibility(View.VISIBLE);
+ }
+ }
+ mAsyncTaskExecutor.submit(Tasks.UPDATE_PHONE_CALL_DETAILS, new UpdateContactDetailsTask());
+ }
+
+ /** Return the phone call details for a given call log URI. */
+ private PhoneCallDetails getPhoneCallDetailsForUri(Uri callUri) {
+ ContentResolver resolver = getContentResolver();
+ Cursor callCursor = resolver.query(callUri, CALL_LOG_PROJECTION, null, null, null);
+ try {
+ if (callCursor == null || !callCursor.moveToFirst()) {
+ throw new IllegalArgumentException("Cannot find content: " + callUri);
+ }
+
+ // Read call log specifics.
+ String number = callCursor.getString(NUMBER_COLUMN_INDEX);
+ long date = callCursor.getLong(DATE_COLUMN_INDEX);
+ long duration = callCursor.getLong(DURATION_COLUMN_INDEX);
+ int callType = callCursor.getInt(CALL_TYPE_COLUMN_INDEX);
+ String countryIso = callCursor.getString(COUNTRY_ISO_COLUMN_INDEX);
+ final String geocode = callCursor.getString(GEOCODED_LOCATION_COLUMN_INDEX);
+
+ if (TextUtils.isEmpty(countryIso)) {
+ countryIso = mDefaultCountryIso;
+ }
+
+ // Formatted phone number.
+ final CharSequence formattedNumber;
+ // Read contact specifics.
+ final CharSequence nameText;
+ final int numberType;
+ final CharSequence numberLabel;
+ final Uri photoUri;
+ final Uri lookupUri;
+ // If this is not a regular number, there is no point in looking it up in the contacts.
+ ContactInfo info =
+ mPhoneNumberHelper.canPlaceCallsTo(number)
+ && !mPhoneNumberHelper.isVoicemailNumber(number)
+ ? mContactInfoHelper.lookupNumber(number, countryIso)
+ : null;
+ if (info == null) {
+ formattedNumber = mPhoneNumberHelper.getDisplayNumber(number, null);
+ nameText = "";
+ numberType = 0;
+ numberLabel = "";
+ photoUri = null;
+ lookupUri = null;
+ } else {
+ formattedNumber = info.formattedNumber;
+ nameText = info.name;
+ numberType = info.type;
+ numberLabel = info.label;
+ photoUri = info.photoUri;
+ lookupUri = info.lookupUri;
+ }
+ return new PhoneCallDetails(number, formattedNumber, countryIso, geocode,
+ new int[]{ callType }, date, duration,
+ nameText, numberType, numberLabel, lookupUri, photoUri);
+ } finally {
+ if (callCursor != null) {
+ callCursor.close();
+ }
+ }
+ }
+
+ /** Load the contact photos and places them in the corresponding views. */
+ private void loadContactPhotos(Uri photoUri) {
+ mContactPhotoManager.loadPhoto(mContactBackgroundView, photoUri,
+ mContactBackgroundView.getWidth(), true);
+ }
+
+ static final class ViewEntry {
+ public final String text;
+ public final Intent primaryIntent;
+ /** The description for accessibility of the primary action. */
+ public final String primaryDescription;
+
+ public CharSequence label = null;
+ /** Icon for the secondary action. */
+ public int secondaryIcon = 0;
+ /** Intent for the secondary action. If not null, an icon must be defined. */
+ public Intent secondaryIntent = null;
+ /** The description for accessibility of the secondary action. */
+ public String secondaryDescription = null;
+
+ public ViewEntry(String text, Intent intent, String description) {
+ this.text = text;
+ primaryIntent = intent;
+ primaryDescription = description;
+ }
+
+ public void setSecondaryAction(int icon, Intent intent, String description) {
+ secondaryIcon = icon;
+ secondaryIntent = intent;
+ secondaryDescription = description;
+ }
+ }
+
+ /** Disables the call button area, e.g., for private numbers. */
+ private void disableCallButton() {
+ findViewById(R.id.call_and_sms).setVisibility(View.GONE);
+ }
+
+ /** Configures the call button area using the given entry. */
+ private void configureCallButton(ViewEntry entry) {
+ View convertView = findViewById(R.id.call_and_sms);
+ convertView.setVisibility(View.VISIBLE);
+
+ ImageView icon = (ImageView) convertView.findViewById(R.id.call_and_sms_icon);
+ View divider = convertView.findViewById(R.id.call_and_sms_divider);
+ TextView text = (TextView) convertView.findViewById(R.id.call_and_sms_text);
+
+ View mainAction = convertView.findViewById(R.id.call_and_sms_main_action);
+ mainAction.setOnClickListener(mPrimaryActionListener);
+ mainAction.setTag(entry);
+ mainAction.setContentDescription(entry.primaryDescription);
+ mainAction.setOnLongClickListener(mPrimaryLongClickListener);
+
+ if (entry.secondaryIntent != null) {
+ icon.setOnClickListener(mSecondaryActionListener);
+ icon.setImageResource(entry.secondaryIcon);
+ icon.setVisibility(View.VISIBLE);
+ icon.setTag(entry);
+ icon.setContentDescription(entry.secondaryDescription);
+ divider.setVisibility(View.VISIBLE);
+ } else {
+ icon.setVisibility(View.GONE);
+ divider.setVisibility(View.GONE);
+ }
+ text.setText(entry.text);
+
+ TextView label = (TextView) convertView.findViewById(R.id.call_and_sms_label);
+ if (TextUtils.isEmpty(entry.label)) {
+ label.setVisibility(View.GONE);
+ } else {
+ label.setText(entry.label);
+ label.setVisibility(View.VISIBLE);
+ }
+ }
+
+ protected void updateVoicemailStatusMessage(Cursor statusCursor) {
+ if (statusCursor == null) {
+ mStatusMessageView.setVisibility(View.GONE);
+ return;
+ }
+ final StatusMessage message = getStatusMessage(statusCursor);
+ if (message == null || !message.showInCallDetails()) {
+ mStatusMessageView.setVisibility(View.GONE);
+ return;
+ }
+
+ mStatusMessageView.setVisibility(View.VISIBLE);
+ mStatusMessageText.setText(message.callDetailsMessageId);
+ if (message.actionMessageId != -1) {
+ mStatusMessageAction.setText(message.actionMessageId);
+ }
+ if (message.actionUri != null) {
+ mStatusMessageAction.setClickable(true);
+ mStatusMessageAction.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(Intent.ACTION_VIEW, message.actionUri));
+ }
+ });
+ } else {
+ mStatusMessageAction.setClickable(false);
+ }
+ }
+
+ private StatusMessage getStatusMessage(Cursor statusCursor) {
+ List<StatusMessage> messages = mVoicemailStatusHelper.getStatusMessages(statusCursor);
+ if (messages.size() == 0) {
+ return null;
+ }
+ // There can only be a single status message per source package, so num of messages can
+ // at most be 1.
+ if (messages.size() > 1) {
+ Log.w(TAG, String.format("Expected 1, found (%d) num of status messages." +
+ " Will use the first one.", messages.size()));
+ }
+ return messages.get(0);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.call_details_options, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ // This action deletes all elements in the group from the call log.
+ // We don't have this action for voicemails, because you can just use the trash button.
+ menu.findItem(R.id.menu_remove_from_call_log).setVisible(mHasRemoveFromCallLogOption);
+ menu.findItem(R.id.menu_edit_number_before_call).setVisible(mHasEditNumberBeforeCallOption);
+ menu.findItem(R.id.menu_trash).setVisible(mHasTrashOption);
+ return super.onPrepareOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onMenuItemSelected(int featureId, MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home: {
+ onHomeSelected();
+ return true;
+ }
+
+ // All the options menu items are handled by onMenu... methods.
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ public void onMenuRemoveFromCallLog(MenuItem menuItem) {
+ final StringBuilder callIds = new StringBuilder();
+ for (Uri callUri : getCallLogEntryUris()) {
+ if (callIds.length() != 0) {
+ callIds.append(",");
+ }
+ callIds.append(ContentUris.parseId(callUri));
+ }
+ mAsyncTaskExecutor.submit(Tasks.REMOVE_FROM_CALL_LOG_AND_FINISH,
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ public Void doInBackground(Void... params) {
+ getContentResolver().delete(Calls.CONTENT_URI_WITH_VOICEMAIL,
+ Calls._ID + " IN (" + callIds + ")", null);
+ return null;
+ }
+
+ @Override
+ public void onPostExecute(Void result) {
+ finish();
+ }
+ });
+ }
+
+ public void onMenuEditNumberBeforeCall(MenuItem menuItem) {
+ startActivity(new Intent(Intent.ACTION_DIAL, ContactsUtils.getCallUri(mNumber)));
+ }
+
+ public void onMenuTrashVoicemail(MenuItem menuItem) {
+ final Uri voicemailUri = getVoicemailUri();
+ mAsyncTaskExecutor.submit(Tasks.DELETE_VOICEMAIL_AND_FINISH,
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ public Void doInBackground(Void... params) {
+ getContentResolver().delete(voicemailUri, null, null);
+ return null;
+ }
+ @Override
+ public void onPostExecute(Void result) {
+ finish();
+ }
+ });
+ }
+
+ private void configureActionBar() {
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME);
+ }
+ }
+
+ /** Invoked when the user presses the home button in the action bar. */
+ private void onHomeSelected() {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Calls.CONTENT_URI);
+ // This will open the call log even if the detail view has been opened directly.
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ finish();
+ }
+
+ @Override
+ protected void onPause() {
+ // Immediately stop the proximity sensor.
+ disableProximitySensor(false);
+ mProximitySensorListener.clearPendingRequests();
+ super.onPause();
+ }
+
+ @Override
+ public void enableProximitySensor() {
+ mProximitySensorManager.enable();
+ }
+
+ @Override
+ public void disableProximitySensor(boolean waitForFarState) {
+ mProximitySensorManager.disable(waitForFarState);
+ }
+
+ /**
+ * If the phone number is selected, unselect it and return {@code true}.
+ * Otherwise, just {@code false}.
+ */
+ private boolean finishPhoneNumerSelectedActionModeIfShown() {
+ if (mPhoneNumberActionMode == null) return false;
+ mPhoneNumberActionMode.finish();
+ return true;
+ }
+
+ private void startPhoneNumberSelectedActionMode(View targetView) {
+ mPhoneNumberActionMode = startActionMode(new PhoneNumberActionModeCallback(targetView));
+ }
+
+ private void closeSystemDialogs() {
+ sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
+ }
+
+ private class PhoneNumberActionModeCallback implements ActionMode.Callback {
+ private final View mTargetView;
+ private final Drawable mOriginalViewBackground;
+
+ public PhoneNumberActionModeCallback(View targetView) {
+ mTargetView = targetView;
+
+ // Highlight the phone number view. Remember the old background, and put a new one.
+ mOriginalViewBackground = mTargetView.getBackground();
+ mTargetView.setBackgroundColor(getResources().getColor(R.color.item_selected));
+ }
+
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ if (TextUtils.isEmpty(mPhoneNumberToCopy)) return false;
+
+ getMenuInflater().inflate(R.menu.call_details_cab, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.copy_phone_number:
+ ClipboardUtils.copyText(CallDetailActivity.this, mPhoneNumberLabelToCopy,
+ mPhoneNumberToCopy, true);
+ mode.finish(); // Close the CAB
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ mPhoneNumberActionMode = null;
+
+ // Restore the view background.
+ mTargetView.setBackground(mOriginalViewBackground);
+ }
+ }
+}
diff --git a/src/com/android/dialer/CallDetailActivityQueryHandler.java b/src/com/android/dialer/CallDetailActivityQueryHandler.java
new file mode 100644
index 000000000..08510f96f
--- /dev/null
+++ b/src/com/android/dialer/CallDetailActivityQueryHandler.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2011 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;
+
+import android.content.AsyncQueryHandler;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.VoicemailContract.Status;
+import android.provider.VoicemailContract.Voicemails;
+import android.util.Log;
+
+import com.android.common.io.MoreCloseables;
+import com.android.dialer.voicemail.VoicemailStatusHelperImpl;
+
+/**
+ * Class used by {@link CallDetailActivity} to fire async content resolver queries.
+ */
+public class CallDetailActivityQueryHandler extends AsyncQueryHandler {
+ private static final String TAG = "CallDetail";
+ private static final int QUERY_VOICEMAIL_CONTENT_TOKEN = 101;
+ private static final int QUERY_VOICEMAIL_STATUS_TOKEN = 102;
+
+ private final String[] VOICEMAIL_CONTENT_PROJECTION = new String[] {
+ Voicemails.SOURCE_PACKAGE,
+ Voicemails.HAS_CONTENT
+ };
+ private static final int SOURCE_PACKAGE_COLUMN_INDEX = 0;
+ private static final int HAS_CONTENT_COLUMN_INDEX = 1;
+
+ private final CallDetailActivity mCallDetailActivity;
+
+ public CallDetailActivityQueryHandler(CallDetailActivity callDetailActivity) {
+ super(callDetailActivity.getContentResolver());
+ mCallDetailActivity = callDetailActivity;
+ }
+
+ /**
+ * Fires a query to update voicemail status for the given voicemail record. On completion of the
+ * query a call to {@link CallDetailActivity#updateVoicemailStatusMessage(Cursor)} is made.
+ * <p>
+ * if this is a voicemail record then it makes up to two asynchronous content resolver queries.
+ * The first one to fetch voicemail content details and check if the voicemail record has audio.
+ * If the voicemail record does not have an audio yet then it fires the second query to get the
+ * voicemail status of the associated source.
+ */
+ public void startVoicemailStatusQuery(Uri voicemailUri) {
+ startQuery(QUERY_VOICEMAIL_CONTENT_TOKEN, null, voicemailUri, VOICEMAIL_CONTENT_PROJECTION,
+ null, null, null);
+ }
+
+ @Override
+ protected synchronized void onQueryComplete(int token, Object cookie, Cursor cursor) {
+ try {
+ if (token == QUERY_VOICEMAIL_CONTENT_TOKEN) {
+ // Query voicemail status only if this voicemail record does not have audio.
+ if (moveToFirst(cursor) && hasNoAudio(cursor)) {
+ startQuery(QUERY_VOICEMAIL_STATUS_TOKEN, null,
+ Status.buildSourceUri(getSourcePackage(cursor)),
+ VoicemailStatusHelperImpl.PROJECTION, null, null, null);
+ } else {
+ // nothing to show in status
+ mCallDetailActivity.updateVoicemailStatusMessage(null);
+ }
+ } else if (token == QUERY_VOICEMAIL_STATUS_TOKEN) {
+ mCallDetailActivity.updateVoicemailStatusMessage(cursor);
+ } else {
+ Log.w(TAG, "Unknown query completed: ignoring: " + token);
+ }
+ } finally {
+ MoreCloseables.closeQuietly(cursor);
+ }
+ }
+
+ /** Check that the cursor is non-null and can be moved to first. */
+ private boolean moveToFirst(Cursor cursor) {
+ if (cursor == null || !cursor.moveToFirst()) {
+ Log.e(TAG, "Cursor not valid, could not move to first");
+ return false;
+ }
+ return true;
+ }
+
+ private boolean hasNoAudio(Cursor voicemailCursor) {
+ return voicemailCursor.getInt(HAS_CONTENT_COLUMN_INDEX) == 0;
+ }
+
+ private String getSourcePackage(Cursor voicemailCursor) {
+ return voicemailCursor.getString(SOURCE_PACKAGE_COLUMN_INDEX);
+ }
+}
diff --git a/src/com/android/dialer/DialtactsActivity.java b/src/com/android/dialer/DialtactsActivity.java
new file mode 100644
index 000000000..380b265ce
--- /dev/null
+++ b/src/com/android/dialer/DialtactsActivity.java
@@ -0,0 +1,1267 @@
+/*
+ * Copyright (C) 2008 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;
+
+import android.app.ActionBar;
+import android.app.ActionBar.LayoutParams;
+import android.app.ActionBar.Tab;
+import android.app.ActionBar.TabListener;
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.preference.PreferenceManager;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Intents.UI;
+import android.support.v13.app.FragmentPagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.support.v4.view.ViewPager.OnPageChangeListener;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MenuItem.OnMenuItemClickListener;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnFocusChangeListener;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.PopupMenu;
+import android.widget.SearchView;
+import android.widget.SearchView.OnCloseListener;
+import android.widget.SearchView.OnQueryTextListener;
+
+import com.android.contacts.ContactsUtils;
+import com.android.contacts.R;
+import com.android.contacts.activities.TransactionSafeActivity;
+import com.android.dialer.calllog.CallLogFragment;
+import com.android.dialer.dialpad.DialpadFragment;
+import com.android.contacts.interactions.PhoneNumberInteraction;
+import com.android.contacts.list.ContactListFilterController;
+import com.android.contacts.list.ContactListFilterController.ContactListFilterListener;
+import com.android.contacts.list.ContactListItemView;
+import com.android.contacts.list.OnPhoneNumberPickerActionListener;
+import com.android.contacts.list.PhoneFavoriteFragment;
+import com.android.contacts.list.PhoneNumberPickerFragment;
+import com.android.contacts.util.AccountFilterUtil;
+import com.android.contacts.util.Constants;
+import com.android.internal.telephony.ITelephony;
+
+/**
+ * The dialer activity that has one tab with the virtual 12key
+ * dialer, a tab with recent calls in it, a tab with the contacts and
+ * a tab with the favorite. This is the container and the tabs are
+ * embedded using intents.
+ * The dialer tab's title is 'phone', a more common name (see strings.xml).
+ */
+public class DialtactsActivity extends TransactionSafeActivity
+ implements View.OnClickListener {
+ private static final String TAG = "DialtactsActivity";
+
+ public static final boolean DEBUG = false;
+
+ /** Used to open Call Setting */
+ private static final String PHONE_PACKAGE = "com.android.phone";
+ private static final String CALL_SETTINGS_CLASS_NAME =
+ "com.android.phone.CallFeaturesSetting";
+
+ /** @see #getCallOrigin() */
+ private static final String CALL_ORIGIN_DIALTACTS =
+ "com.android.dialer.DialtactsActivity";
+
+ /**
+ * Just for backward compatibility. Should behave as same as {@link Intent#ACTION_DIAL}.
+ */
+ private static final String ACTION_TOUCH_DIALER = "com.android.phone.action.TOUCH_DIALER";
+
+ /** Used both by {@link ActionBar} and {@link ViewPagerAdapter} */
+ private static final int TAB_INDEX_DIALER = 0;
+ private static final int TAB_INDEX_CALL_LOG = 1;
+ private static final int TAB_INDEX_FAVORITES = 2;
+
+ private static final int TAB_INDEX_COUNT = 3;
+
+ private SharedPreferences mPrefs;
+
+ /** Last manually selected tab index */
+ private static final String PREF_LAST_MANUALLY_SELECTED_TAB =
+ "DialtactsActivity_last_manually_selected_tab";
+ private static final int PREF_LAST_MANUALLY_SELECTED_TAB_DEFAULT = TAB_INDEX_DIALER;
+
+ private static final int SUBACTIVITY_ACCOUNT_FILTER = 1;
+
+ public class ViewPagerAdapter extends FragmentPagerAdapter {
+ public ViewPagerAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ switch (position) {
+ case TAB_INDEX_DIALER:
+ return new DialpadFragment();
+ case TAB_INDEX_CALL_LOG:
+ return new CallLogFragment();
+ case TAB_INDEX_FAVORITES:
+ return new PhoneFavoriteFragment();
+ }
+ throw new IllegalStateException("No fragment at position " + position);
+ }
+
+ @Override
+ public void setPrimaryItem(ViewGroup container, int position, Object object) {
+ // The parent's setPrimaryItem() also calls setMenuVisibility(), so we want to know
+ // when it happens.
+ if (DEBUG) {
+ Log.d(TAG, "FragmentPagerAdapter#setPrimaryItem(), position: " + position);
+ }
+ super.setPrimaryItem(container, position, object);
+ }
+
+ @Override
+ public int getCount() {
+ return TAB_INDEX_COUNT;
+ }
+ }
+
+ /**
+ * True when the app detects user's drag event. This variable should not become true when
+ * mUserTabClick is true.
+ *
+ * During user's drag or tab click, we shouldn't show fake buttons but just show real
+ * ActionBar at the bottom of the screen, for transition animation.
+ */
+ boolean mDuringSwipe = false;
+ /**
+ * True when the app detects user's tab click (at the top of the screen). This variable should
+ * not become true when mDuringSwipe is true.
+ *
+ * During user's drag or tab click, we shouldn't show fake buttons but just show real
+ * ActionBar at the bottom of the screen, for transition animation.
+ */
+ boolean mUserTabClick = false;
+
+ private class PageChangeListener implements OnPageChangeListener {
+ private int mCurrentPosition = -1;
+ /**
+ * Used during page migration, to remember the next position {@link #onPageSelected(int)}
+ * specified.
+ */
+ private int mNextPosition = -1;
+
+ @Override
+ public void onPageScrolled(
+ int position, float positionOffset, int positionOffsetPixels) {
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ if (DEBUG) Log.d(TAG, "onPageSelected: position: " + position);
+ final ActionBar actionBar = getActionBar();
+ if (mDialpadFragment != null) {
+ if (mDuringSwipe && position == TAB_INDEX_DIALER) {
+ // TODO: Figure out if we want this or not. Right now
+ // - with this call, both fake buttons and real action bar overlap
+ // - without this call, there's tiny flicker happening to search/menu buttons.
+ // If we can reduce the flicker without this call, it would be much better.
+ // updateFakeMenuButtonsVisibility(true);
+ }
+ }
+
+ if (mCurrentPosition == position) {
+ Log.w(TAG, "Previous position and next position became same (" + position + ")");
+ }
+
+ actionBar.selectTab(actionBar.getTabAt(position));
+ mNextPosition = position;
+ }
+
+ public void setCurrentPosition(int position) {
+ mCurrentPosition = position;
+ }
+
+ public int getCurrentPosition() {
+ return mCurrentPosition;
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ switch (state) {
+ case ViewPager.SCROLL_STATE_IDLE: {
+ if (mNextPosition == -1) {
+ // This happens when the user drags the screen just after launching the
+ // application, and settle down the same screen without actually swiping it.
+ // At that moment mNextPosition is apparently -1 yet, and we expect it
+ // being updated by onPageSelected(), which is *not* called if the user
+ // settle down the exact same tab after the dragging.
+ if (DEBUG) {
+ Log.d(TAG, "Next position is not specified correctly. Use current tab ("
+ + mViewPager.getCurrentItem() + ")");
+ }
+ mNextPosition = mViewPager.getCurrentItem();
+ }
+ if (DEBUG) {
+ Log.d(TAG, "onPageScrollStateChanged() with SCROLL_STATE_IDLE. "
+ + "mCurrentPosition: " + mCurrentPosition
+ + ", mNextPosition: " + mNextPosition);
+ }
+ // Interpret IDLE as the end of migration (both swipe and tab click)
+ mDuringSwipe = false;
+ mUserTabClick = false;
+
+ updateFakeMenuButtonsVisibility(mNextPosition == TAB_INDEX_DIALER);
+ sendFragmentVisibilityChange(mCurrentPosition, false);
+ sendFragmentVisibilityChange(mNextPosition, true);
+
+ invalidateOptionsMenu();
+
+ mCurrentPosition = mNextPosition;
+ break;
+ }
+ case ViewPager.SCROLL_STATE_DRAGGING: {
+ if (DEBUG) Log.d(TAG, "onPageScrollStateChanged() with SCROLL_STATE_DRAGGING");
+ mDuringSwipe = true;
+ mUserTabClick = false;
+ break;
+ }
+ case ViewPager.SCROLL_STATE_SETTLING: {
+ if (DEBUG) Log.d(TAG, "onPageScrollStateChanged() with SCROLL_STATE_SETTLING");
+ mDuringSwipe = true;
+ mUserTabClick = false;
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ }
+
+ private String mFilterText;
+
+ /** Enables horizontal swipe between Fragments. */
+ private ViewPager mViewPager;
+ private final PageChangeListener mPageChangeListener = new PageChangeListener();
+ private DialpadFragment mDialpadFragment;
+ private CallLogFragment mCallLogFragment;
+ private PhoneFavoriteFragment mPhoneFavoriteFragment;
+
+ private View mSearchButton;
+ private View mMenuButton;
+
+ private final ContactListFilterListener mContactListFilterListener =
+ new ContactListFilterListener() {
+ @Override
+ public void onContactListFilterChanged() {
+ boolean doInvalidateOptionsMenu = false;
+
+ if (mPhoneFavoriteFragment != null && mPhoneFavoriteFragment.isAdded()) {
+ mPhoneFavoriteFragment.setFilter(mContactListFilterController.getFilter());
+ doInvalidateOptionsMenu = true;
+ }
+
+ if (mSearchFragment != null && mSearchFragment.isAdded()) {
+ mSearchFragment.setFilter(mContactListFilterController.getFilter());
+ doInvalidateOptionsMenu = true;
+ } else {
+ Log.w(TAG, "Search Fragment isn't available when ContactListFilter is changed");
+ }
+
+ if (doInvalidateOptionsMenu) {
+ invalidateOptionsMenu();
+ }
+ }
+ };
+
+ private final TabListener mTabListener = new TabListener() {
+ @Override
+ public void onTabUnselected(Tab tab, FragmentTransaction ft) {
+ if (DEBUG) Log.d(TAG, "onTabUnselected(). tab: " + tab);
+ }
+
+ @Override
+ public void onTabSelected(Tab tab, FragmentTransaction ft) {
+ if (DEBUG) {
+ Log.d(TAG, "onTabSelected(). tab: " + tab + ", mDuringSwipe: " + mDuringSwipe);
+ }
+ // When the user swipes the screen horizontally, this method will be called after
+ // ViewPager.SCROLL_STATE_DRAGGING and ViewPager.SCROLL_STATE_SETTLING events, while
+ // when the user clicks a tab at the ActionBar at the top, this will be called before
+ // them. This logic interprets the order difference as a difference of the user action.
+ if (!mDuringSwipe) {
+ if (DEBUG) {
+ Log.d(TAG, "Tab select. from: " + mPageChangeListener.getCurrentPosition()
+ + ", to: " + tab.getPosition());
+ }
+ if (mDialpadFragment != null) {
+ updateFakeMenuButtonsVisibility(tab.getPosition() == TAB_INDEX_DIALER);
+ }
+ mUserTabClick = true;
+ }
+
+ if (mViewPager.getCurrentItem() != tab.getPosition()) {
+ mViewPager.setCurrentItem(tab.getPosition(), true);
+ }
+
+ // During the call, we don't remember the tab position.
+ if (!DialpadFragment.phoneIsInUse()) {
+ // Remember this tab index. This function is also called, if the tab is set
+ // automatically in which case the setter (setCurrentTab) has to set this to its old
+ // value afterwards
+ mLastManuallySelectedFragment = tab.getPosition();
+ }
+ }
+
+ @Override
+ public void onTabReselected(Tab tab, FragmentTransaction ft) {
+ if (DEBUG) Log.d(TAG, "onTabReselected");
+ }
+ };
+
+ /**
+ * Fragment for searching phone numbers. Unlike the other Fragments, this doesn't correspond
+ * to tab but is shown by a search action.
+ */
+ private PhoneNumberPickerFragment mSearchFragment;
+ /**
+ * True when this Activity is in its search UI (with a {@link SearchView} and
+ * {@link PhoneNumberPickerFragment}).
+ */
+ private boolean mInSearchUi;
+ private SearchView mSearchView;
+
+ private final OnClickListener mFilterOptionClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ final PopupMenu popupMenu = new PopupMenu(DialtactsActivity.this, view);
+ final Menu menu = popupMenu.getMenu();
+ popupMenu.inflate(R.menu.dialtacts_search_options);
+ final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option);
+ filterOptionMenuItem.setOnMenuItemClickListener(mFilterOptionsMenuItemClickListener);
+ final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact);
+ addContactOptionMenuItem.setIntent(
+ new Intent(Intent.ACTION_INSERT, Contacts.CONTENT_URI));
+ popupMenu.show();
+ }
+ };
+
+ /**
+ * The index of the Fragment (or, the tab) that has last been manually selected.
+ * This value does not keep track of programmatically set Tabs (e.g. Call Log after a Call)
+ */
+ private int mLastManuallySelectedFragment;
+
+ private ContactListFilterController mContactListFilterController;
+ private OnMenuItemClickListener mFilterOptionsMenuItemClickListener =
+ new OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ AccountFilterUtil.startAccountFilterActivityForResult(
+ DialtactsActivity.this, SUBACTIVITY_ACCOUNT_FILTER,
+ mContactListFilterController.getFilter());
+ return true;
+ }
+ };
+
+ private OnMenuItemClickListener mSearchMenuItemClickListener =
+ new OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ enterSearchUi();
+ return true;
+ }
+ };
+
+ /**
+ * Listener used when one of phone numbers in search UI is selected. This will initiate a
+ * phone call using the phone number.
+ */
+ private final OnPhoneNumberPickerActionListener mPhoneNumberPickerActionListener =
+ new OnPhoneNumberPickerActionListener() {
+ @Override
+ public void onPickPhoneNumberAction(Uri dataUri) {
+ // Specify call-origin so that users will see the previous tab instead of
+ // CallLog screen (search UI will be automatically exited).
+ PhoneNumberInteraction.startInteractionForPhoneCall(
+ DialtactsActivity.this, dataUri, getCallOrigin());
+ }
+
+ @Override
+ public void onShortcutIntentCreated(Intent intent) {
+ Log.w(TAG, "Unsupported intent has come (" + intent + "). Ignoring.");
+ }
+
+ @Override
+ public void onHomeInActionBarSelected() {
+ exitSearchUi();
+ }
+ };
+
+ /**
+ * Listener used to send search queries to the phone search fragment.
+ */
+ private final OnQueryTextListener mPhoneSearchQueryTextListener =
+ new OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ View view = getCurrentFocus();
+ if (view != null) {
+ hideInputMethod(view);
+ view.clearFocus();
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ // Show search result with non-empty text. Show a bare list otherwise.
+ if (mSearchFragment != null) {
+ mSearchFragment.setQueryString(newText, true);
+ }
+ return true;
+ }
+ };
+
+ /**
+ * Listener used to handle the "close" button on the right side of {@link SearchView}.
+ * If some text is in the search view, this will clean it up. Otherwise this will exit
+ * the search UI and let users go back to usual Phone UI.
+ *
+ * This does _not_ handle back button.
+ */
+ private final OnCloseListener mPhoneSearchCloseListener =
+ new OnCloseListener() {
+ @Override
+ public boolean onClose() {
+ if (!TextUtils.isEmpty(mSearchView.getQuery())) {
+ mSearchView.setQuery(null, true);
+ }
+ return true;
+ }
+ };
+
+ private final View.OnLayoutChangeListener mFirstLayoutListener
+ = new View.OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
+ int oldTop, int oldRight, int oldBottom) {
+ v.removeOnLayoutChangeListener(this); // Unregister self.
+ addSearchFragment();
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+
+ final Intent intent = getIntent();
+ fixIntent(intent);
+
+ setContentView(R.layout.dialtacts_activity);
+
+ mContactListFilterController = ContactListFilterController.getInstance(this);
+ mContactListFilterController.addListener(mContactListFilterListener);
+
+ findViewById(R.id.dialtacts_frame).addOnLayoutChangeListener(mFirstLayoutListener);
+
+ mViewPager = (ViewPager) findViewById(R.id.pager);
+ mViewPager.setAdapter(new ViewPagerAdapter(getFragmentManager()));
+ mViewPager.setOnPageChangeListener(mPageChangeListener);
+ mViewPager.setOffscreenPageLimit(2);
+
+ // Do same width calculation as ActionBar does
+ DisplayMetrics dm = getResources().getDisplayMetrics();
+ int minCellSize = getResources().getDimensionPixelSize(R.dimen.fake_menu_button_min_width);
+ int cellCount = dm.widthPixels / minCellSize;
+ int fakeMenuItemWidth = dm.widthPixels / cellCount;
+ if (DEBUG) Log.d(TAG, "The size of fake menu buttons (in pixel): " + fakeMenuItemWidth);
+
+ // Soft menu button should appear only when there's no hardware menu button.
+ mMenuButton = findViewById(R.id.overflow_menu);
+ if (mMenuButton != null) {
+ mMenuButton.setMinimumWidth(fakeMenuItemWidth);
+ if (ViewConfiguration.get(this).hasPermanentMenuKey()) {
+ // This is required for dialpad button's layout, so must not use GONE here.
+ mMenuButton.setVisibility(View.INVISIBLE);
+ } else {
+ mMenuButton.setOnClickListener(this);
+ }
+ }
+ mSearchButton = findViewById(R.id.searchButton);
+ if (mSearchButton != null) {
+ mSearchButton.setMinimumWidth(fakeMenuItemWidth);
+ mSearchButton.setOnClickListener(this);
+ }
+
+ // Setup the ActionBar tabs (the order matches the tab-index contants TAB_INDEX_*)
+ setupDialer();
+ setupCallLog();
+ setupFavorites();
+ getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
+ getActionBar().setDisplayShowTitleEnabled(false);
+ getActionBar().setDisplayShowHomeEnabled(false);
+
+ // Load the last manually loaded tab
+ mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
+ mLastManuallySelectedFragment = mPrefs.getInt(PREF_LAST_MANUALLY_SELECTED_TAB,
+ PREF_LAST_MANUALLY_SELECTED_TAB_DEFAULT);
+ if (mLastManuallySelectedFragment >= TAB_INDEX_COUNT) {
+ // Stored value may have exceeded the number of current tabs. Reset it.
+ mLastManuallySelectedFragment = PREF_LAST_MANUALLY_SELECTED_TAB_DEFAULT;
+ }
+
+ setCurrentTab(intent);
+
+ if (UI.FILTER_CONTACTS_ACTION.equals(intent.getAction())
+ && icicle == null) {
+ setupFilterText(intent);
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (mPhoneFavoriteFragment != null) {
+ mPhoneFavoriteFragment.setFilter(mContactListFilterController.getFilter());
+ }
+ if (mSearchFragment != null) {
+ mSearchFragment.setFilter(mContactListFilterController.getFilter());
+ }
+
+ if (mDuringSwipe || mUserTabClick) {
+ if (DEBUG) Log.d(TAG, "reset buggy flag state..");
+ mDuringSwipe = false;
+ mUserTabClick = false;
+ }
+
+ final int currentPosition = mPageChangeListener.getCurrentPosition();
+ if (DEBUG) {
+ Log.d(TAG, "onStart(). current position: " + mPageChangeListener.getCurrentPosition()
+ + ". Reset all menu visibility state.");
+ }
+ updateFakeMenuButtonsVisibility(currentPosition == TAB_INDEX_DIALER && !mInSearchUi);
+ for (int i = 0; i < TAB_INDEX_COUNT; i++) {
+ sendFragmentVisibilityChange(i, i == currentPosition);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mContactListFilterController.removeListener(mContactListFilterListener);
+ }
+
+ @Override
+ public void onClick(View view) {
+ switch (view.getId()) {
+ case R.id.searchButton: {
+ enterSearchUi();
+ break;
+ }
+ case R.id.overflow_menu: {
+ if (mDialpadFragment != null) {
+ PopupMenu popup = mDialpadFragment.constructPopupMenu(view);
+ if (popup != null) {
+ popup.show();
+ }
+ } else {
+ Log.w(TAG, "DialpadFragment is null during onClick() event for " + view);
+ }
+ break;
+ }
+ default: {
+ Log.wtf(TAG, "Unexpected onClick event from " + view);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Add search fragment. Note this is called during onLayout, so there's some restrictions,
+ * such as executePendingTransaction can't be used in it.
+ */
+ private void addSearchFragment() {
+ // In order to take full advantage of "fragment deferred start", we need to create the
+ // search fragment after all other fragments are created.
+ // The other fragments are created by the ViewPager on the first onMeasure().
+ // We use the first onLayout call, which is after onMeasure().
+
+ // Just return if the fragment is already created, which happens after configuration
+ // changes.
+ if (mSearchFragment != null) return;
+
+ final FragmentTransaction ft = getFragmentManager().beginTransaction();
+ final Fragment searchFragment = new PhoneNumberPickerFragment();
+
+ searchFragment.setUserVisibleHint(false);
+ ft.add(R.id.dialtacts_frame, searchFragment);
+ ft.hide(searchFragment);
+ ft.commitAllowingStateLoss();
+ }
+
+ private void prepareSearchView() {
+ final View searchViewLayout =
+ getLayoutInflater().inflate(R.layout.dialtacts_custom_action_bar, null);
+ mSearchView = (SearchView) searchViewLayout.findViewById(R.id.search_view);
+ mSearchView.setOnQueryTextListener(mPhoneSearchQueryTextListener);
+ mSearchView.setOnCloseListener(mPhoneSearchCloseListener);
+ // Since we're using a custom layout for showing SearchView instead of letting the
+ // search menu icon do that job, we need to manually configure the View so it looks
+ // "shown via search menu".
+ // - it should be iconified by default
+ // - it should not be iconified at this time
+ // See also comments for onActionViewExpanded()/onActionViewCollapsed()
+ mSearchView.setIconifiedByDefault(true);
+ mSearchView.setQueryHint(getString(R.string.hint_findContacts));
+ mSearchView.setIconified(false);
+ mSearchView.setOnQueryTextFocusChangeListener(new OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View view, boolean hasFocus) {
+ if (hasFocus) {
+ showInputMethod(view.findFocus());
+ }
+ }
+ });
+
+ if (!ViewConfiguration.get(this).hasPermanentMenuKey()) {
+ // Filter option menu should be shown on the right side of SearchView.
+ final View filterOptionView = searchViewLayout.findViewById(R.id.search_option);
+ filterOptionView.setVisibility(View.VISIBLE);
+ filterOptionView.setOnClickListener(mFilterOptionClickListener);
+ }
+
+ getActionBar().setCustomView(searchViewLayout,
+ new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ }
+
+ @Override
+ public void onAttachFragment(Fragment fragment) {
+ // This method can be called before onCreate(), at which point we cannot rely on ViewPager.
+ // In that case, we will setup the "current position" soon after the ViewPager is ready.
+ final int currentPosition = mViewPager != null ? mViewPager.getCurrentItem() : -1;
+
+ if (fragment instanceof DialpadFragment) {
+ mDialpadFragment = (DialpadFragment) fragment;
+ } else if (fragment instanceof CallLogFragment) {
+ mCallLogFragment = (CallLogFragment) fragment;
+ } else if (fragment instanceof PhoneFavoriteFragment) {
+ mPhoneFavoriteFragment = (PhoneFavoriteFragment) fragment;
+ mPhoneFavoriteFragment.setListener(mPhoneFavoriteListener);
+ if (mContactListFilterController != null
+ && mContactListFilterController.getFilter() != null) {
+ mPhoneFavoriteFragment.setFilter(mContactListFilterController.getFilter());
+ }
+ } else if (fragment instanceof PhoneNumberPickerFragment) {
+ mSearchFragment = (PhoneNumberPickerFragment) fragment;
+ mSearchFragment.setOnPhoneNumberPickerActionListener(mPhoneNumberPickerActionListener);
+ mSearchFragment.setQuickContactEnabled(true);
+ mSearchFragment.setDarkTheme(true);
+ mSearchFragment.setPhotoPosition(ContactListItemView.PhotoPosition.LEFT);
+ mSearchFragment.setUseCallableUri(true);
+ if (mContactListFilterController != null
+ && mContactListFilterController.getFilter() != null) {
+ mSearchFragment.setFilter(mContactListFilterController.getFilter());
+ }
+ // Here we assume that we're not on the search mode, so let's hide the fragment.
+ //
+ // We get here either when the fragment is created (normal case), or after configuration
+ // changes. In the former case, we're not in search mode because we can only
+ // enter search mode if the fragment is created. (see enterSearchUi())
+ // In the latter case we're not in search mode either because we don't retain
+ // mInSearchUi -- ideally we should but at this point it's not supported.
+ mSearchFragment.setUserVisibleHint(false);
+ // After configuration changes fragments will forget their "hidden" state, so make
+ // sure to hide it.
+ if (!mSearchFragment.isHidden()) {
+ final FragmentTransaction transaction = getFragmentManager().beginTransaction();
+ transaction.hide(mSearchFragment);
+ transaction.commitAllowingStateLoss();
+ }
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+
+ mPrefs.edit().putInt(PREF_LAST_MANUALLY_SELECTED_TAB, mLastManuallySelectedFragment)
+ .apply();
+ }
+
+ private void fixIntent(Intent intent) {
+ // This should be cleaned up: the call key used to send an Intent
+ // that just said to go to the recent calls list. It now sends this
+ // abstract action, but this class hasn't been rewritten to deal with it.
+ if (Intent.ACTION_CALL_BUTTON.equals(intent.getAction())) {
+ intent.setDataAndType(Calls.CONTENT_URI, Calls.CONTENT_TYPE);
+ intent.putExtra("call_key", true);
+ setIntent(intent);
+ }
+ }
+
+ private void setupDialer() {
+ final Tab tab = getActionBar().newTab();
+ tab.setContentDescription(R.string.dialerIconLabel);
+ tab.setTabListener(mTabListener);
+ tab.setIcon(R.drawable.ic_tab_dialer);
+ getActionBar().addTab(tab);
+ }
+
+ private void setupCallLog() {
+ final Tab tab = getActionBar().newTab();
+ tab.setContentDescription(R.string.recentCallsIconLabel);
+ tab.setIcon(R.drawable.ic_tab_recent);
+ tab.setTabListener(mTabListener);
+ getActionBar().addTab(tab);
+ }
+
+ private void setupFavorites() {
+ final Tab tab = getActionBar().newTab();
+ tab.setContentDescription(R.string.contactsFavoritesLabel);
+ tab.setIcon(R.drawable.ic_tab_all);
+ tab.setTabListener(mTabListener);
+ getActionBar().addTab(tab);
+ }
+
+ /**
+ * Returns true if the intent is due to hitting the green send key (hardware call button:
+ * KEYCODE_CALL) while in a call.
+ *
+ * @param intent the intent that launched this activity
+ * @param recentCallsRequest true if the intent is requesting to view recent calls
+ * @return true if the intent is due to hitting the green send key while in a call
+ */
+ private boolean isSendKeyWhileInCall(final Intent intent,
+ final boolean recentCallsRequest) {
+ // If there is a call in progress go to the call screen
+ if (recentCallsRequest) {
+ final boolean callKey = intent.getBooleanExtra("call_key", false);
+
+ try {
+ ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone"));
+ if (callKey && phone != null && phone.showCallScreen()) {
+ return true;
+ }
+ } catch (RemoteException e) {
+ Log.e(TAG, "Failed to handle send while in call", e);
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets the current tab based on the intent's request type
+ *
+ * @param intent Intent that contains information about which tab should be selected
+ */
+ private void setCurrentTab(Intent intent) {
+ // If we got here by hitting send and we're in call forward along to the in-call activity
+ boolean recentCallsRequest = Calls.CONTENT_TYPE.equals(intent.resolveType(
+ getContentResolver()));
+ if (isSendKeyWhileInCall(intent, recentCallsRequest)) {
+ finish();
+ return;
+ }
+
+ // Remember the old manually selected tab index so that it can be restored if it is
+ // overwritten by one of the programmatic tab selections
+ final int savedTabIndex = mLastManuallySelectedFragment;
+
+ final int tabIndex;
+ if (DialpadFragment.phoneIsInUse() || isDialIntent(intent)) {
+ tabIndex = TAB_INDEX_DIALER;
+ } else if (recentCallsRequest) {
+ tabIndex = TAB_INDEX_CALL_LOG;
+ } else {
+ tabIndex = mLastManuallySelectedFragment;
+ }
+
+ final int previousItemIndex = mViewPager.getCurrentItem();
+ mViewPager.setCurrentItem(tabIndex, false /* smoothScroll */);
+ if (previousItemIndex != tabIndex) {
+ sendFragmentVisibilityChange(previousItemIndex, false /* not visible */ );
+ }
+ mPageChangeListener.setCurrentPosition(tabIndex);
+ sendFragmentVisibilityChange(tabIndex, true /* visible */ );
+
+ // Restore to the previous manual selection
+ mLastManuallySelectedFragment = savedTabIndex;
+ mDuringSwipe = false;
+ mUserTabClick = false;
+ }
+
+ @Override
+ public void onNewIntent(Intent newIntent) {
+ setIntent(newIntent);
+ fixIntent(newIntent);
+ setCurrentTab(newIntent);
+ final String action = newIntent.getAction();
+ if (UI.FILTER_CONTACTS_ACTION.equals(action)) {
+ setupFilterText(newIntent);
+ }
+ if (mInSearchUi || (mSearchFragment != null && mSearchFragment.isVisible())) {
+ exitSearchUi();
+ }
+
+ if (mViewPager.getCurrentItem() == TAB_INDEX_DIALER) {
+ if (mDialpadFragment != null) {
+ mDialpadFragment.configureScreenFromIntent(newIntent);
+ } else {
+ Log.e(TAG, "DialpadFragment isn't ready yet when the tab is already selected.");
+ }
+ } else if (mViewPager.getCurrentItem() == TAB_INDEX_CALL_LOG) {
+ if (mCallLogFragment != null) {
+ mCallLogFragment.configureScreenFromIntent(newIntent);
+ } else {
+ Log.e(TAG, "CallLogFragment isn't ready yet when the tab is already selected.");
+ }
+ }
+ invalidateOptionsMenu();
+ }
+
+ /** Returns true if the given intent contains a phone number to populate the dialer with */
+ private boolean isDialIntent(Intent intent) {
+ final String action = intent.getAction();
+ if (Intent.ACTION_DIAL.equals(action) || ACTION_TOUCH_DIALER.equals(action)) {
+ return true;
+ }
+ if (Intent.ACTION_VIEW.equals(action)) {
+ final Uri data = intent.getData();
+ if (data != null && Constants.SCHEME_TEL.equals(data.getScheme())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns an appropriate call origin for this Activity. May return null when no call origin
+ * should be used (e.g. when some 3rd party application launched the screen. Call origin is
+ * for remembering the tab in which the user made a phone call, so the external app's DIAL
+ * request should not be counted.)
+ */
+ public String getCallOrigin() {
+ return !isDialIntent(getIntent()) ? CALL_ORIGIN_DIALTACTS : null;
+ }
+
+ /**
+ * Retrieves the filter text stored in {@link #setupFilterText(Intent)}.
+ * This text originally came from a FILTER_CONTACTS_ACTION intent received
+ * by this activity. The stored text will then be cleared after after this
+ * method returns.
+ *
+ * @return The stored filter text
+ */
+ public String getAndClearFilterText() {
+ String filterText = mFilterText;
+ mFilterText = null;
+ return filterText;
+ }
+
+ /**
+ * Stores the filter text associated with a FILTER_CONTACTS_ACTION intent.
+ * This is so child activities can check if they are supposed to display a filter.
+ *
+ * @param intent The intent received in {@link #onNewIntent(Intent)}
+ */
+ private void setupFilterText(Intent intent) {
+ // If the intent was relaunched from history, don't apply the filter text.
+ if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) {
+ return;
+ }
+ String filter = intent.getStringExtra(UI.FILTER_TEXT_EXTRA_KEY);
+ if (filter != null && filter.length() > 0) {
+ mFilterText = filter;
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mInSearchUi) {
+ // We should let the user go back to usual screens with tabs.
+ exitSearchUi();
+ } else if (isTaskRoot()) {
+ // Instead of stopping, simply push this to the back of the stack.
+ // This is only done when running at the top of the stack;
+ // otherwise, we have been launched by someone else so need to
+ // allow the user to go back to the caller.
+ moveTaskToBack(false);
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ private final PhoneFavoriteFragment.Listener mPhoneFavoriteListener =
+ new PhoneFavoriteFragment.Listener() {
+ @Override
+ public void onContactSelected(Uri contactUri) {
+ PhoneNumberInteraction.startInteractionForPhoneCall(
+ DialtactsActivity.this, contactUri, getCallOrigin());
+ }
+
+ @Override
+ public void onCallNumberDirectly(String phoneNumber) {
+ Intent intent = ContactsUtils.getCallIntent(phoneNumber, getCallOrigin());
+ startActivity(intent);
+ }
+ };
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.dialtacts_options, menu);
+
+ // set up intents and onClick listeners
+ final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings);
+ final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar);
+ final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option);
+ final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact);
+
+ callSettingsMenuItem.setIntent(DialtactsActivity.getCallSettingsIntent());
+ searchMenuItem.setOnMenuItemClickListener(mSearchMenuItemClickListener);
+ filterOptionMenuItem.setOnMenuItemClickListener(mFilterOptionsMenuItemClickListener);
+ addContactOptionMenuItem.setIntent(
+ new Intent(Intent.ACTION_INSERT, Contacts.CONTENT_URI));
+
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ if (mInSearchUi) {
+ prepareOptionsMenuInSearchMode(menu);
+ } else {
+ // get reference to the currently selected tab
+ final Tab tab = getActionBar().getSelectedTab();
+ if (tab != null) {
+ switch(tab.getPosition()) {
+ case TAB_INDEX_DIALER:
+ prepareOptionsMenuForDialerTab(menu);
+ break;
+ case TAB_INDEX_CALL_LOG:
+ prepareOptionsMenuForCallLogTab(menu);
+ break;
+ case TAB_INDEX_FAVORITES:
+ prepareOptionsMenuForFavoritesTab(menu);
+ break;
+ }
+ }
+ }
+ return true;
+ }
+
+ private void prepareOptionsMenuInSearchMode(Menu menu) {
+ // get references to menu items
+ final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar);
+ final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option);
+ final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact);
+ final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings);
+ final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item);
+
+ // prepare the menu items
+ searchMenuItem.setVisible(false);
+ filterOptionMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey());
+ addContactOptionMenuItem.setVisible(false);
+ callSettingsMenuItem.setVisible(false);
+ emptyRightMenuItem.setVisible(false);
+ }
+
+ private void prepareOptionsMenuForDialerTab(Menu menu) {
+ if (DEBUG) {
+ Log.d(TAG, "onPrepareOptionsMenu(dialer). swipe: " + mDuringSwipe
+ + ", user tab click: " + mUserTabClick);
+ }
+
+ // get references to menu items
+ final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar);
+ final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option);
+ final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact);
+ final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings);
+ final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item);
+
+ // prepare the menu items
+ filterOptionMenuItem.setVisible(false);
+ addContactOptionMenuItem.setVisible(false);
+ if (mDuringSwipe || mUserTabClick) {
+ // During horizontal movement, the real ActionBar menu items are shown
+ searchMenuItem.setVisible(true);
+ callSettingsMenuItem.setVisible(true);
+ // When there is a permanent menu key, there is no overflow icon on the right of
+ // the action bar which would force the search menu item (if it is visible) to the
+ // left. This is the purpose of showing the emptyRightMenuItem.
+ emptyRightMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey());
+ } else {
+ // This is when the user is looking at the dialer pad. In this case, the real
+ // ActionBar is hidden and fake menu items are shown.
+ // Except in landscape, in which case the real search menu item is shown.
+ searchMenuItem.setVisible(ContactsUtils.isLandscape(this));
+ // If a permanent menu key is available, then we need to show the call settings item
+ // so that the call settings item can be invoked by the permanent menu key.
+ callSettingsMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey());
+ emptyRightMenuItem.setVisible(false);
+ }
+ }
+
+ private void prepareOptionsMenuForCallLogTab(Menu menu) {
+ // get references to menu items
+ final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar);
+ final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option);
+ final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact);
+ final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings);
+ final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item);
+
+ // prepare the menu items
+ searchMenuItem.setVisible(true);
+ filterOptionMenuItem.setVisible(false);
+ addContactOptionMenuItem.setVisible(false);
+ callSettingsMenuItem.setVisible(true);
+ emptyRightMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey());
+ }
+
+ private void prepareOptionsMenuForFavoritesTab(Menu menu) {
+ // get references to menu items
+ final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar);
+ final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option);
+ final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact);
+ final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings);
+ final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item);
+
+ // prepare the menu items
+ searchMenuItem.setVisible(true);
+ filterOptionMenuItem.setVisible(true);
+ addContactOptionMenuItem.setVisible(true);
+ callSettingsMenuItem.setVisible(true);
+ emptyRightMenuItem.setVisible(false);
+ }
+
+ @Override
+ public void startSearch(String initialQuery, boolean selectInitialQuery,
+ Bundle appSearchData, boolean globalSearch) {
+ if (mSearchFragment != null && mSearchFragment.isAdded() && !globalSearch) {
+ if (mInSearchUi) {
+ if (mSearchView.hasFocus()) {
+ showInputMethod(mSearchView.findFocus());
+ } else {
+ mSearchView.requestFocus();
+ }
+ } else {
+ enterSearchUi();
+ }
+ } else {
+ super.startSearch(initialQuery, selectInitialQuery, appSearchData, globalSearch);
+ }
+ }
+
+ /**
+ * Hides every tab and shows search UI for phone lookup.
+ */
+ private void enterSearchUi() {
+ if (mSearchFragment == null) {
+ // We add the search fragment dynamically in the first onLayoutChange() and
+ // mSearchFragment is set sometime later when the fragment transaction is actually
+ // executed, which means there's a window when users are able to hit the (physical)
+ // search key but mSearchFragment is still null.
+ // It's quite hard to handle this case right, so let's just ignore the search key
+ // in this case. Users can just hit it again and it will work this time.
+ return;
+ }
+ if (mSearchView == null) {
+ prepareSearchView();
+ }
+
+ final ActionBar actionBar = getActionBar();
+
+ final Tab tab = actionBar.getSelectedTab();
+
+ // User can search during the call, but we don't want to remember the status.
+ if (tab != null && !DialpadFragment.phoneIsInUse()) {
+ mLastManuallySelectedFragment = tab.getPosition();
+ }
+
+ mSearchView.setQuery(null, true);
+
+ actionBar.setDisplayShowCustomEnabled(true);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ actionBar.setDisplayShowHomeEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+
+ updateFakeMenuButtonsVisibility(false);
+
+ for (int i = 0; i < TAB_INDEX_COUNT; i++) {
+ sendFragmentVisibilityChange(i, false /* not visible */ );
+ }
+
+ // Show the search fragment and hide everything else.
+ mSearchFragment.setUserVisibleHint(true);
+ final FragmentTransaction transaction = getFragmentManager().beginTransaction();
+ transaction.show(mSearchFragment);
+ transaction.commitAllowingStateLoss();
+ mViewPager.setVisibility(View.GONE);
+
+ // We need to call this and onActionViewCollapsed() manually, since we are using a custom
+ // layout instead of asking the search menu item to take care of SearchView.
+ mSearchView.onActionViewExpanded();
+ mInSearchUi = true;
+ }
+
+ private void showInputMethod(View view) {
+ InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (imm != null) {
+ if (!imm.showSoftInput(view, 0)) {
+ Log.w(TAG, "Failed to show soft input method.");
+ }
+ }
+ }
+
+ private void hideInputMethod(View view) {
+ InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (imm != null && view != null) {
+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
+ }
+ }
+
+ /**
+ * Goes back to usual Phone UI with tags. Previously selected Tag and associated Fragment
+ * should be automatically focused again.
+ */
+ private void exitSearchUi() {
+ final ActionBar actionBar = getActionBar();
+
+ // Hide the search fragment, if exists.
+ if (mSearchFragment != null) {
+ mSearchFragment.setUserVisibleHint(false);
+
+ final FragmentTransaction transaction = getFragmentManager().beginTransaction();
+ transaction.hide(mSearchFragment);
+ transaction.commitAllowingStateLoss();
+ }
+
+ // We want to hide SearchView and show Tabs. Also focus on previously selected one.
+ actionBar.setDisplayShowCustomEnabled(false);
+ actionBar.setDisplayShowHomeEnabled(false);
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
+
+ for (int i = 0; i < TAB_INDEX_COUNT; i++) {
+ sendFragmentVisibilityChange(i, i == mViewPager.getCurrentItem());
+ }
+
+ // Before exiting the search screen, reset swipe state.
+ mDuringSwipe = false;
+ mUserTabClick = false;
+
+ mViewPager.setVisibility(View.VISIBLE);
+
+ hideInputMethod(getCurrentFocus());
+
+ // Request to update option menu.
+ invalidateOptionsMenu();
+
+ // See comments in onActionViewExpanded()
+ mSearchView.onActionViewCollapsed();
+ mInSearchUi = false;
+ }
+
+ private Fragment getFragmentAt(int position) {
+ switch (position) {
+ case TAB_INDEX_DIALER:
+ return mDialpadFragment;
+ case TAB_INDEX_CALL_LOG:
+ return mCallLogFragment;
+ case TAB_INDEX_FAVORITES:
+ return mPhoneFavoriteFragment;
+ default:
+ throw new IllegalStateException("Unknown fragment index: " + position);
+ }
+ }
+
+ private void sendFragmentVisibilityChange(int position, boolean visibility) {
+ if (DEBUG) {
+ Log.d(TAG, "sendFragmentVisibiltyChange(). position: " + position
+ + ", visibility: " + visibility);
+ }
+ // Position can be -1 initially. See PageChangeListener.
+ if (position >= 0) {
+ final Fragment fragment = getFragmentAt(position);
+ if (fragment != null) {
+ fragment.setMenuVisibility(visibility);
+ fragment.setUserVisibleHint(visibility);
+ }
+ }
+ }
+
+ /**
+ * Update visibility of the search button and menu button at the bottom.
+ * They should be invisible when bottom ActionBar's real items are available, and be visible
+ * otherwise.
+ *
+ * @param visible True when visible.
+ */
+ private void updateFakeMenuButtonsVisibility(boolean visible) {
+ // Note: Landscape mode does not have the fake menu and search buttons.
+ if (DEBUG) {
+ Log.d(TAG, "updateFakeMenuButtonVisibility(" + visible + ")");
+ }
+
+ if (mSearchButton != null) {
+ if (visible) {
+ mSearchButton.setVisibility(View.VISIBLE);
+ } else {
+ mSearchButton.setVisibility(View.INVISIBLE);
+ }
+ }
+ if (mMenuButton != null) {
+ if (visible && !ViewConfiguration.get(this).hasPermanentMenuKey()) {
+ mMenuButton.setVisibility(View.VISIBLE);
+ } else {
+ mMenuButton.setVisibility(View.INVISIBLE);
+ }
+ }
+ }
+
+ /** Returns an Intent to launch Call Settings screen */
+ public static Intent getCallSettingsIntent() {
+ final Intent intent = new Intent(Intent.ACTION_MAIN);
+ intent.setClassName(PHONE_PACKAGE, CALL_SETTINGS_CLASS_NAME);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ return intent;
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (resultCode != Activity.RESULT_OK) {
+ return;
+ }
+ switch (requestCode) {
+ case SUBACTIVITY_ACCOUNT_FILTER: {
+ AccountFilterUtil.handleAccountFilterResult(
+ mContactListFilterController, resultCode, data);
+ }
+ break;
+ }
+ }
+}
diff --git a/src/com/android/dialer/NonPhoneActivity.java b/src/com/android/dialer/NonPhoneActivity.java
new file mode 100644
index 000000000..c7a744e4a
--- /dev/null
+++ b/src/com/android/dialer/NonPhoneActivity.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2010 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;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Intents.Insert;
+import android.text.TextUtils;
+
+import com.android.contacts.ContactsActivity;
+import com.android.contacts.R;
+import com.android.contacts.util.Constants;
+
+/**
+ * Activity that intercepts DIAL and VIEW intents for phone numbers for devices that can not
+ * be used as a phone. This allows the user to see the phone number
+ */
+public class NonPhoneActivity extends ContactsActivity {
+
+ private static final String PHONE_NUMBER_KEY = "PHONE_NUMBER";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final String phoneNumber = getPhoneNumber();
+ if (TextUtils.isEmpty(phoneNumber)) {
+ finish();
+ return;
+ }
+
+ final NonPhoneDialogFragment fragment = new NonPhoneDialogFragment();
+ Bundle bundle = new Bundle();
+ bundle.putString(PHONE_NUMBER_KEY, phoneNumber);
+ fragment.setArguments(bundle);
+ getFragmentManager().beginTransaction().add(fragment, "Fragment").commitAllowingStateLoss();
+ }
+
+ private String getPhoneNumber() {
+ if (getIntent() == null) return null;
+ final Uri data = getIntent().getData();
+ if (data == null) return null;
+ final String scheme = data.getScheme();
+ if (!Constants.SCHEME_TEL.equals(scheme)) return null;
+ return getIntent().getData().getSchemeSpecificPart();
+ }
+
+ public static final class NonPhoneDialogFragment extends DialogFragment
+ implements OnClickListener {
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final AlertDialog alertDialog;
+ alertDialog = new AlertDialog.Builder(getActivity(), R.style.NonPhoneDialogTheme)
+ .create();
+ alertDialog.setTitle(R.string.non_phone_caption);
+ alertDialog.setMessage(getArgumentPhoneNumber());
+ alertDialog.setButton(DialogInterface.BUTTON_POSITIVE,
+ getActivity().getString(R.string.non_phone_add_to_contacts), this);
+ alertDialog.setButton(DialogInterface.BUTTON_NEGATIVE,
+ getActivity().getString(R.string.non_phone_close), this);
+ return alertDialog;
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (which == DialogInterface.BUTTON_POSITIVE) {
+ final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
+ intent.setType(Contacts.CONTENT_ITEM_TYPE);
+ intent.putExtra(Insert.PHONE, getArgumentPhoneNumber());
+ startActivity(intent);
+ }
+ dismiss();
+ }
+
+ private String getArgumentPhoneNumber() {
+ return getArguments().getString(PHONE_NUMBER_KEY);
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ super.onDismiss(dialog);
+ // During screen rotation, getActivity returns null. In this case we do not
+ // want to close the Activity anyway
+ final Activity activity = getActivity();
+ if (activity != null) activity.finish();
+ }
+ }
+}
diff --git a/src/com/android/dialer/PhoneCallDetails.java b/src/com/android/dialer/PhoneCallDetails.java
new file mode 100644
index 000000000..45c29e461
--- /dev/null
+++ b/src/com/android/dialer/PhoneCallDetails.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2011 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;
+
+import android.net.Uri;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+
+/**
+ * The details of a phone call to be shown in the UI.
+ */
+public class PhoneCallDetails {
+ /** The number of the other party involved in the call. */
+ public final CharSequence number;
+ /** The formatted version of {@link #number}. */
+ public final CharSequence formattedNumber;
+ /** The country corresponding with the phone number. */
+ public final String countryIso;
+ /** The geocoded location for the phone number. */
+ public final String geocode;
+ /**
+ * The type of calls, as defined in the call log table, e.g., {@link Calls#INCOMING_TYPE}.
+ * <p>
+ * There might be multiple types if this represents a set of entries grouped together.
+ */
+ public final int[] callTypes;
+ /** The date of the call, in milliseconds since the epoch. */
+ public final long date;
+ /** The duration of the call in milliseconds, or 0 for missed calls. */
+ public final long duration;
+ /** The name of the contact, or the empty string. */
+ public final CharSequence name;
+ /** The type of phone, e.g., {@link Phone#TYPE_HOME}, 0 if not available. */
+ public final int numberType;
+ /** The custom label associated with the phone number in the contact, or the empty string. */
+ public final CharSequence numberLabel;
+ /** The URI of the contact associated with this phone call. */
+ public final Uri contactUri;
+ /**
+ * The photo URI of the picture of the contact that is associated with this phone call or
+ * null if there is none.
+ * <p>
+ * This is meant to store the high-res photo only.
+ */
+ public final Uri photoUri;
+
+ /** Create the details for a call with a number not associated with a contact. */
+ public PhoneCallDetails(CharSequence number, CharSequence formattedNumber,
+ String countryIso, String geocode, int[] callTypes, long date, long duration) {
+ this(number, formattedNumber, countryIso, geocode, callTypes, date, duration, "", 0, "",
+ null, null);
+ }
+
+ /** Create the details for a call with a number associated with a contact. */
+ public PhoneCallDetails(CharSequence number, CharSequence formattedNumber,
+ String countryIso, String geocode, int[] callTypes, long date, long duration,
+ CharSequence name, int numberType, CharSequence numberLabel, Uri contactUri,
+ Uri photoUri) {
+ this.number = number;
+ this.formattedNumber = formattedNumber;
+ this.countryIso = countryIso;
+ this.geocode = geocode;
+ this.callTypes = callTypes;
+ this.date = date;
+ this.duration = duration;
+ this.name = name;
+ this.numberType = numberType;
+ this.numberLabel = numberLabel;
+ this.contactUri = contactUri;
+ this.photoUri = photoUri;
+ }
+}
diff --git a/src/com/android/dialer/PhoneCallDetailsHelper.java b/src/com/android/dialer/PhoneCallDetailsHelper.java
new file mode 100644
index 000000000..8433ebcbb
--- /dev/null
+++ b/src/com/android/dialer/PhoneCallDetailsHelper.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2011 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;
+
+import android.content.res.Resources;
+import android.graphics.Typeface;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.telephony.PhoneNumberUtils;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.view.View;
+import android.widget.TextView;
+
+import com.android.contacts.R;
+import com.android.dialer.calllog.CallTypeHelper;
+import com.android.dialer.calllog.PhoneNumberHelper;
+import com.android.contacts.test.NeededForTesting;
+
+/**
+ * Helper class to fill in the views in {@link PhoneCallDetailsViews}.
+ */
+public class PhoneCallDetailsHelper {
+ /** The maximum number of icons will be shown to represent the call types in a group. */
+ private static final int MAX_CALL_TYPE_ICONS = 3;
+
+ private final Resources mResources;
+ /** The injected current time in milliseconds since the epoch. Used only by tests. */
+ private Long mCurrentTimeMillisForTest;
+ // Helper classes.
+ private final CallTypeHelper mCallTypeHelper;
+ private final PhoneNumberHelper mPhoneNumberHelper;
+
+ /**
+ * Creates a new instance of the helper.
+ * <p>
+ * Generally you should have a single instance of this helper in any context.
+ *
+ * @param resources used to look up strings
+ */
+ public PhoneCallDetailsHelper(Resources resources, CallTypeHelper callTypeHelper,
+ PhoneNumberHelper phoneNumberHelper) {
+ mResources = resources;
+ mCallTypeHelper = callTypeHelper;
+ mPhoneNumberHelper = phoneNumberHelper;
+ }
+
+ /** Fills the call details views with content. */
+ public void setPhoneCallDetails(PhoneCallDetailsViews views, PhoneCallDetails details,
+ boolean isHighlighted) {
+ // Display up to a given number of icons.
+ views.callTypeIcons.clear();
+ int count = details.callTypes.length;
+ for (int index = 0; index < count && index < MAX_CALL_TYPE_ICONS; ++index) {
+ views.callTypeIcons.add(details.callTypes[index]);
+ }
+ views.callTypeIcons.setVisibility(View.VISIBLE);
+
+ // Show the total call count only if there are more than the maximum number of icons.
+ final Integer callCount;
+ if (count > MAX_CALL_TYPE_ICONS) {
+ callCount = count;
+ } else {
+ callCount = null;
+ }
+ // The color to highlight the count and date in, if any. This is based on the first call.
+ Integer highlightColor =
+ isHighlighted ? mCallTypeHelper.getHighlightedColor(details.callTypes[0]) : null;
+
+ // The date of this call, relative to the current time.
+ CharSequence dateText =
+ DateUtils.getRelativeTimeSpanString(details.date,
+ getCurrentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE);
+
+ // Set the call count and date.
+ setCallCountAndDate(views, callCount, dateText, highlightColor);
+
+ CharSequence numberFormattedLabel = null;
+ // Only show a label if the number is shown and it is not a SIP address.
+ if (!TextUtils.isEmpty(details.number)
+ && !PhoneNumberUtils.isUriNumber(details.number.toString())) {
+ numberFormattedLabel = Phone.getTypeLabel(mResources, details.numberType,
+ details.numberLabel);
+ }
+
+ final CharSequence nameText;
+ final CharSequence numberText;
+ final CharSequence labelText;
+ final CharSequence displayNumber =
+ mPhoneNumberHelper.getDisplayNumber(details.number, details.formattedNumber);
+ if (TextUtils.isEmpty(details.name)) {
+ nameText = displayNumber;
+ if (TextUtils.isEmpty(details.geocode)
+ || mPhoneNumberHelper.isVoicemailNumber(details.number)) {
+ numberText = mResources.getString(R.string.call_log_empty_gecode);
+ } else {
+ numberText = details.geocode;
+ }
+ labelText = null;
+ } else {
+ nameText = details.name;
+ numberText = displayNumber;
+ labelText = numberFormattedLabel;
+ }
+
+ views.nameView.setText(nameText);
+ views.numberView.setText(numberText);
+ views.labelView.setText(labelText);
+ views.labelView.setVisibility(TextUtils.isEmpty(labelText) ? View.GONE : View.VISIBLE);
+ }
+
+ /** Sets the text of the header view for the details page of a phone call. */
+ public void setCallDetailsHeader(TextView nameView, PhoneCallDetails details) {
+ final CharSequence nameText;
+ final CharSequence displayNumber =
+ mPhoneNumberHelper.getDisplayNumber(details.number,
+ mResources.getString(R.string.recentCalls_addToContact));
+ if (TextUtils.isEmpty(details.name)) {
+ nameText = displayNumber;
+ } else {
+ nameText = details.name;
+ }
+
+ nameView.setText(nameText);
+ }
+
+ @NeededForTesting
+ public void setCurrentTimeForTest(long currentTimeMillis) {
+ mCurrentTimeMillisForTest = currentTimeMillis;
+ }
+
+ /**
+ * Returns the current time in milliseconds since the epoch.
+ * <p>
+ * It can be injected in tests using {@link #setCurrentTimeForTest(long)}.
+ */
+ private long getCurrentTimeMillis() {
+ if (mCurrentTimeMillisForTest == null) {
+ return System.currentTimeMillis();
+ } else {
+ return mCurrentTimeMillisForTest;
+ }
+ }
+
+ /** Sets the call count and date. */
+ private void setCallCountAndDate(PhoneCallDetailsViews views, Integer callCount,
+ CharSequence dateText, Integer highlightColor) {
+ // Combine the count (if present) and the date.
+ final CharSequence text;
+ if (callCount != null) {
+ text = mResources.getString(
+ R.string.call_log_item_count_and_date, callCount.intValue(), dateText);
+ } else {
+ text = dateText;
+ }
+
+ // Apply the highlight color if present.
+ final CharSequence formattedText;
+ if (highlightColor != null) {
+ formattedText = addBoldAndColor(text, highlightColor);
+ } else {
+ formattedText = text;
+ }
+
+ views.callTypeAndDate.setText(formattedText);
+ }
+
+ /** Creates a SpannableString for the given text which is bold and in the given color. */
+ private CharSequence addBoldAndColor(CharSequence text, int color) {
+ int flags = Spanned.SPAN_INCLUSIVE_INCLUSIVE;
+ SpannableString result = new SpannableString(text);
+ result.setSpan(new StyleSpan(Typeface.BOLD), 0, text.length(), flags);
+ result.setSpan(new ForegroundColorSpan(color), 0, text.length(), flags);
+ return result;
+ }
+}
diff --git a/src/com/android/dialer/PhoneCallDetailsViews.java b/src/com/android/dialer/PhoneCallDetailsViews.java
new file mode 100644
index 000000000..58246586f
--- /dev/null
+++ b/src/com/android/dialer/PhoneCallDetailsViews.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2011 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;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.TextView;
+
+import com.android.contacts.R;
+import com.android.dialer.calllog.CallTypeIconsView;
+
+/**
+ * Encapsulates the views that are used to display the details of a phone call in the call log.
+ */
+public final class PhoneCallDetailsViews {
+ public final TextView nameView;
+ public final View callTypeView;
+ public final CallTypeIconsView callTypeIcons;
+ public final TextView callTypeAndDate;
+ public final TextView numberView;
+ public final TextView labelView;
+
+ private PhoneCallDetailsViews(TextView nameView, View callTypeView,
+ CallTypeIconsView callTypeIcons, TextView callTypeAndDate, TextView numberView,
+ TextView labelView) {
+ this.nameView = nameView;
+ this.callTypeView = callTypeView;
+ this.callTypeIcons = callTypeIcons;
+ this.callTypeAndDate = callTypeAndDate;
+ this.numberView = numberView;
+ this.labelView = labelView;
+ }
+
+ /**
+ * Create a new instance by extracting the elements from the given view.
+ * <p>
+ * The view should contain three text views with identifiers {@code R.id.name},
+ * {@code R.id.date}, and {@code R.id.number}, and a linear layout with identifier
+ * {@code R.id.call_types}.
+ */
+ public static PhoneCallDetailsViews fromView(View view) {
+ return new PhoneCallDetailsViews((TextView) view.findViewById(R.id.name),
+ view.findViewById(R.id.call_type),
+ (CallTypeIconsView) view.findViewById(R.id.call_type_icons),
+ (TextView) view.findViewById(R.id.call_count_and_date),
+ (TextView) view.findViewById(R.id.number),
+ (TextView) view.findViewById(R.id.label));
+ }
+
+ public static PhoneCallDetailsViews createForTest(Context context) {
+ return new PhoneCallDetailsViews(
+ new TextView(context),
+ new View(context),
+ new CallTypeIconsView(context),
+ new TextView(context),
+ new TextView(context),
+ new TextView(context));
+ }
+}
diff --git a/src/com/android/dialer/ViewNotificationService.java b/src/com/android/dialer/ViewNotificationService.java
new file mode 100644
index 000000000..4fdb81590
--- /dev/null
+++ b/src/com/android/dialer/ViewNotificationService.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2012 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;
+
+import android.app.Service;
+import android.content.Intent;
+import android.content.Loader;
+import android.content.Loader.OnLoadCompleteListener;
+import android.os.IBinder;
+import android.util.Log;
+
+import com.android.contacts.model.Contact;
+import com.android.contacts.model.ContactLoader;
+
+
+/**
+ * Service that sends out a view notification for a contact. At the moment, this is only
+ * supposed to be used by the Phone app
+ */
+public class ViewNotificationService extends Service {
+ private static final String TAG = ViewNotificationService.class.getSimpleName();
+
+ private static final boolean DEBUG = false;
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, final int startId) {
+ if (DEBUG) { Log.d(TAG, "onHandleIntent(). Intent: " + intent); }
+
+ // We simply need to start a Loader here. When its done, it will send out the
+ // View-Notification automatically.
+ final ContactLoader contactLoader = new ContactLoader(this, intent.getData(), true);
+ contactLoader.registerListener(0, new OnLoadCompleteListener<Contact>() {
+ @Override
+ public void onLoadComplete(Loader<Contact> loader, Contact data) {
+ try {
+ loader.reset();
+ } catch (RuntimeException e) {
+ Log.e(TAG, "Error reseting loader", e);
+ }
+ try {
+ // This is not 100% accurate actually. If we get several calls quickly,
+ // we might be stopping out-of-order, in which case the call with the last
+ // startId will stop this service. In practice, this shouldn't be a problem,
+ // as this service is supposed to be called by the Phone app which only sends
+ // out the notification once per phonecall. And even if there is a problem,
+ // the worst that should happen is a missing view notification
+ stopSelfResult(startId);
+ } catch (RuntimeException e) {
+ Log.e(TAG, "Error stopping service", e);
+ }
+ }
+ });
+ contactLoader.startLoading();
+ return START_REDELIVER_INTENT;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java b/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java
new file mode 100644
index 000000000..38dc72722
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.provider.CallLog.Calls;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import com.android.dialer.PhoneCallDetails;
+import com.android.contacts.R;
+
+/**
+ * Adapter for a ListView containing history items from the details of a call.
+ */
+public class CallDetailHistoryAdapter extends BaseAdapter {
+ /** The top element is a blank header, which is hidden under the rest of the UI. */
+ private static final int VIEW_TYPE_HEADER = 0;
+ /** Each history item shows the detail of a call. */
+ private static final int VIEW_TYPE_HISTORY_ITEM = 1;
+
+ private final Context mContext;
+ private final LayoutInflater mLayoutInflater;
+ private final CallTypeHelper mCallTypeHelper;
+ private final PhoneCallDetails[] mPhoneCallDetails;
+ /** Whether the voicemail controls are shown. */
+ private final boolean mShowVoicemail;
+ /** Whether the call and SMS controls are shown. */
+ private final boolean mShowCallAndSms;
+ /** The controls that are shown on top of the history list. */
+ private final View mControls;
+ /** The listener to changes of focus of the header. */
+ private View.OnFocusChangeListener mHeaderFocusChangeListener =
+ new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ // When the header is focused, focus the controls above it instead.
+ if (hasFocus) {
+ mControls.requestFocus();
+ }
+ }
+ };
+
+ public CallDetailHistoryAdapter(Context context, LayoutInflater layoutInflater,
+ CallTypeHelper callTypeHelper, PhoneCallDetails[] phoneCallDetails,
+ boolean showVoicemail, boolean showCallAndSms, View controls) {
+ mContext = context;
+ mLayoutInflater = layoutInflater;
+ mCallTypeHelper = callTypeHelper;
+ mPhoneCallDetails = phoneCallDetails;
+ mShowVoicemail = showVoicemail;
+ mShowCallAndSms = showCallAndSms;
+ mControls = controls;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ // None of history will be clickable.
+ return false;
+ }
+
+ @Override
+ public int getCount() {
+ return mPhoneCallDetails.length + 1;
+ }
+
+ @Override
+ public Object getItem(int position) {
+ if (position == 0) {
+ return null;
+ }
+ return mPhoneCallDetails[position - 1];
+ }
+
+ @Override
+ public long getItemId(int position) {
+ if (position == 0) {
+ return -1;
+ }
+ return position - 1;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 2;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position == 0) {
+ return VIEW_TYPE_HEADER;
+ }
+ return VIEW_TYPE_HISTORY_ITEM;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ if (position == 0) {
+ final View header = convertView == null
+ ? mLayoutInflater.inflate(R.layout.call_detail_history_header, parent, false)
+ : convertView;
+ // Voicemail controls are only shown in the main UI if there is a voicemail.
+ View voicemailContainer = header.findViewById(R.id.header_voicemail_container);
+ voicemailContainer.setVisibility(mShowVoicemail ? View.VISIBLE : View.GONE);
+ // Call and SMS controls are only shown in the main UI if there is a known number.
+ View callAndSmsContainer = header.findViewById(R.id.header_call_and_sms_container);
+ callAndSmsContainer.setVisibility(mShowCallAndSms ? View.VISIBLE : View.GONE);
+ header.setFocusable(true);
+ header.setOnFocusChangeListener(mHeaderFocusChangeListener);
+ return header;
+ }
+
+ // Make sure we have a valid convertView to start with
+ final View result = convertView == null
+ ? mLayoutInflater.inflate(R.layout.call_detail_history_item, parent, false)
+ : convertView;
+
+ PhoneCallDetails details = mPhoneCallDetails[position - 1];
+ CallTypeIconsView callTypeIconView =
+ (CallTypeIconsView) result.findViewById(R.id.call_type_icon);
+ TextView callTypeTextView = (TextView) result.findViewById(R.id.call_type_text);
+ TextView dateView = (TextView) result.findViewById(R.id.date);
+ TextView durationView = (TextView) result.findViewById(R.id.duration);
+
+ int callType = details.callTypes[0];
+ callTypeIconView.clear();
+ callTypeIconView.add(callType);
+ callTypeTextView.setText(mCallTypeHelper.getCallTypeText(callType));
+ // Set the date.
+ CharSequence dateValue = DateUtils.formatDateRange(mContext, details.date, details.date,
+ DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE |
+ DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_YEAR);
+ dateView.setText(dateValue);
+ // Set the duration
+ if (callType == Calls.MISSED_TYPE || callType == Calls.VOICEMAIL_TYPE) {
+ durationView.setVisibility(View.GONE);
+ } else {
+ durationView.setVisibility(View.VISIBLE);
+ durationView.setText(formatDuration(details.duration));
+ }
+
+ return result;
+ }
+
+ private String formatDuration(long elapsedSeconds) {
+ long minutes = 0;
+ long seconds = 0;
+
+ if (elapsedSeconds >= 60) {
+ minutes = elapsedSeconds / 60;
+ elapsedSeconds -= minutes * 60;
+ }
+ seconds = elapsedSeconds;
+
+ return mContext.getString(R.string.callDetailsDurationFormat, minutes, seconds);
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogAdapter.java b/src/com/android/dialer/calllog/CallLogAdapter.java
new file mode 100644
index 000000000..217f59765
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogAdapter.java
@@ -0,0 +1,802 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.PhoneLookup;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+
+import com.android.common.widget.GroupingListAdapter;
+import com.android.contacts.ContactPhotoManager;
+import com.android.dialer.PhoneCallDetails;
+import com.android.dialer.PhoneCallDetailsHelper;
+import com.android.contacts.R;
+import com.android.dialer.util.ExpirableCache;
+import com.android.contacts.util.UriUtils;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+
+import java.util.LinkedList;
+
+/**
+ * Adapter class to fill in data for the Call Log.
+ */
+/*package*/ class CallLogAdapter extends GroupingListAdapter
+ implements ViewTreeObserver.OnPreDrawListener, CallLogGroupBuilder.GroupCreator {
+ /** Interface used to initiate a refresh of the content. */
+ public interface CallFetcher {
+ public void fetchCalls();
+ }
+
+ /**
+ * Stores a phone number of a call with the country code where it originally occurred.
+ * <p>
+ * Note the country does not necessarily specifies the country of the phone number itself, but
+ * it is the country in which the user was in when the call was placed or received.
+ */
+ private static 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() {
+ return (number == null ? 0 : number.hashCode())
+ ^ (countryIso == null ? 0 : countryIso.hashCode());
+ }
+ }
+
+ /** The time in millis to delay starting the thread processing requests. */
+ private static final int START_PROCESSING_REQUESTS_DELAY_MILLIS = 1000;
+
+ /** The size of the cache of contact info. */
+ private static final int CONTACT_INFO_CACHE_SIZE = 100;
+
+ private final Context mContext;
+ private final ContactInfoHelper mContactInfoHelper;
+ private final CallFetcher mCallFetcher;
+ private ViewTreeObserver mViewTreeObserver = null;
+
+ /**
+ * A cache of the contact details for the phone numbers in the call log.
+ * <p>
+ * The content of the cache is expired (but not purged) whenever the application comes to
+ * the foreground.
+ * <p>
+ * The key is number with the country in which the call was placed or received.
+ */
+ private ExpirableCache<NumberWithCountryIso, ContactInfo> mContactInfoCache;
+
+ /**
+ * A request for contact details for the given number.
+ */
+ private static final class ContactInfoRequest {
+ /** 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;
+
+ public ContactInfoRequest(String number, String countryIso, ContactInfo callLogInfo) {
+ this.number = number;
+ this.countryIso = countryIso;
+ this.callLogInfo = callLogInfo;
+ }
+
+ @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.equal(callLogInfo, other.callLogInfo)) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((callLogInfo == null) ? 0 : callLogInfo.hashCode());
+ result = prime * result + ((countryIso == null) ? 0 : countryIso.hashCode());
+ result = prime * result + ((number == null) ? 0 : number.hashCode());
+ return result;
+ }
+ }
+
+ /**
+ * List of requests to update contact details.
+ * <p>
+ * Each request is made of a phone number to look up, and the contact info currently stored in
+ * the call log for this number.
+ * <p>
+ * The requests are added when displaying the contacts and are processed by a background
+ * thread.
+ */
+ private final LinkedList<ContactInfoRequest> mRequests;
+
+ private boolean mLoading = true;
+ private static final int REDRAW = 1;
+ private static final int START_THREAD = 2;
+
+ private QueryThread mCallerIdThread;
+
+ /** Instance of helper class for managing views. */
+ private final CallLogListItemHelper mCallLogViewsHelper;
+
+ /** Helper to set up contact photos. */
+ private final ContactPhotoManager mContactPhotoManager;
+ /** Helper to parse and process phone numbers. */
+ private PhoneNumberHelper mPhoneNumberHelper;
+ /** Helper to group call log entries. */
+ private final CallLogGroupBuilder mCallLogGroupBuilder;
+
+ /** Can be set to true by tests to disable processing of requests. */
+ private volatile boolean mRequestProcessingDisabled = false;
+
+ /** Listener for the primary action in the list, opens the call details. */
+ private final View.OnClickListener mPrimaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ IntentProvider intentProvider = (IntentProvider) view.getTag();
+ if (intentProvider != null) {
+ mContext.startActivity(intentProvider.getIntent(mContext));
+ }
+ }
+ };
+ /** Listener for the secondary action in the list, either call or play. */
+ private final View.OnClickListener mSecondaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ IntentProvider intentProvider = (IntentProvider) view.getTag();
+ if (intentProvider != null) {
+ mContext.startActivity(intentProvider.getIntent(mContext));
+ }
+ }
+ };
+
+ @Override
+ public boolean onPreDraw() {
+ // We only wanted to listen for the first draw (and this is it).
+ unregisterPreDrawListener();
+
+ // Only schedule a thread-creation message if the thread hasn't been
+ // created yet. This is purely an optimization, to queue fewer messages.
+ if (mCallerIdThread == null) {
+ mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MILLIS);
+ }
+
+ return true;
+ }
+
+ private Handler mHandler = new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case REDRAW:
+ notifyDataSetChanged();
+ break;
+ case START_THREAD:
+ startRequestProcessing();
+ break;
+ }
+ }
+ };
+
+ CallLogAdapter(Context context, CallFetcher callFetcher,
+ ContactInfoHelper contactInfoHelper) {
+ super(context);
+
+ mContext = context;
+ mCallFetcher = callFetcher;
+ mContactInfoHelper = contactInfoHelper;
+
+ mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE);
+ mRequests = new LinkedList<ContactInfoRequest>();
+
+ Resources resources = mContext.getResources();
+ CallTypeHelper callTypeHelper = new CallTypeHelper(resources);
+
+ mContactPhotoManager = ContactPhotoManager.getInstance(mContext);
+ mPhoneNumberHelper = new PhoneNumberHelper(resources);
+ PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper(
+ resources, callTypeHelper, mPhoneNumberHelper);
+ mCallLogViewsHelper =
+ new CallLogListItemHelper(
+ phoneCallDetailsHelper, mPhoneNumberHelper, resources);
+ mCallLogGroupBuilder = new CallLogGroupBuilder(this);
+ }
+
+ /**
+ * Requery on background thread when {@link Cursor} changes.
+ */
+ @Override
+ protected void onContentChanged() {
+ mCallFetcher.fetchCalls();
+ }
+
+ void setLoading(boolean loading) {
+ mLoading = loading;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ if (mLoading) {
+ // We don't want the empty state to show when loading.
+ return false;
+ } else {
+ return super.isEmpty();
+ }
+ }
+
+ /**
+ * 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;
+
+ // Idempotence... if a thread is already started, don't start another.
+ if (mCallerIdThread != null) return;
+
+ mCallerIdThread = new QueryThread();
+ mCallerIdThread.setPriority(Thread.MIN_PRIORITY);
+ mCallerIdThread.start();
+ }
+
+ /**
+ * Stops the background thread that processes updates and cancels any
+ * pending requests to start it.
+ */
+ public synchronized void stopRequestProcessing() {
+ // Remove any pending requests to start the processing thread.
+ mHandler.removeMessages(START_THREAD);
+ if (mCallerIdThread != null) {
+ // Stop the thread; we are finished with it.
+ mCallerIdThread.stopProcessing();
+ mCallerIdThread.interrupt();
+ mCallerIdThread = null;
+ }
+ }
+
+ /**
+ * Stop receiving onPreDraw() notifications.
+ */
+ private void unregisterPreDrawListener() {
+ if (mViewTreeObserver != null && mViewTreeObserver.isAlive()) {
+ mViewTreeObserver.removeOnPreDrawListener(this);
+ }
+ mViewTreeObserver = null;
+ }
+
+ public void invalidateCache() {
+ mContactInfoCache.expireAll();
+
+ // Restart the request-processing thread after the next draw.
+ stopRequestProcessing();
+ unregisterPreDrawListener();
+ }
+
+ /**
+ * 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_MILLIS}.
+ */
+ @VisibleForTesting
+ void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo,
+ boolean immediate) {
+ ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo);
+ synchronized (mRequests) {
+ if (!mRequests.contains(request)) {
+ mRequests.add(request);
+ mRequests.notifyAll();
+ }
+ }
+ if (immediate) startRequestProcessing();
+ }
+
+ /**
+ * 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(String number, String countryIso, ContactInfo callLogInfo) {
+ final ContactInfo info = mContactInfoHelper.lookupNumber(number, 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(number, countryIso);
+ ContactInfo existingInfo = mContactInfoCache.getPossiblyExpired(numberCountryIso);
+ boolean updated = (existingInfo != ContactInfo.EMPTY) && !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.
+ mContactInfoCache.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.
+ updateCallLogContactInfoCache(number, countryIso, info, callLogInfo);
+ return updated;
+ }
+
+ /*
+ * Handles requests for contact name and number type.
+ */
+ private class QueryThread extends Thread {
+ private volatile boolean mDone = false;
+
+ public QueryThread() {
+ super("CallLogAdapter.QueryThread");
+ }
+
+ public void stopProcessing() {
+ mDone = true;
+ }
+
+ @Override
+ public void run() {
+ boolean needRedraw = false;
+ while (true) {
+ // Check if thread is finished, and if so return immediately.
+ if (mDone) return;
+
+ // Obtain next request, if any is available.
+ // Keep synchronized section small.
+ ContactInfoRequest req = null;
+ synchronized (mRequests) {
+ if (!mRequests.isEmpty()) {
+ req = mRequests.removeFirst();
+ }
+ }
+
+ if (req != null) {
+ // Process the request. If the lookup succeeds, schedule a
+ // redraw.
+ needRedraw |= queryContactInfo(req.number, req.countryIso, req.callLogInfo);
+ } else {
+ // Throttle redraw rate by only sending them when there are
+ // more requests.
+ if (needRedraw) {
+ needRedraw = false;
+ mHandler.sendEmptyMessage(REDRAW);
+ }
+
+ // Wait until another request is available, or until this
+ // thread is no longer needed (as indicated by being
+ // interrupted).
+ try {
+ synchronized (mRequests) {
+ mRequests.wait(1000);
+ }
+ } catch (InterruptedException ie) {
+ // Ignore, and attempt to continue processing requests.
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ protected void addGroups(Cursor cursor) {
+ mCallLogGroupBuilder.addGroups(cursor);
+ }
+
+ @Override
+ protected View newStandAloneView(Context context, ViewGroup parent) {
+ LayoutInflater inflater =
+ (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
+ findAndCacheViews(view);
+ return view;
+ }
+
+ @Override
+ protected void bindStandAloneView(View view, Context context, Cursor cursor) {
+ bindView(view, cursor, 1);
+ }
+
+ @Override
+ protected View newChildView(Context context, ViewGroup parent) {
+ LayoutInflater inflater =
+ (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
+ findAndCacheViews(view);
+ return view;
+ }
+
+ @Override
+ protected void bindChildView(View view, Context context, Cursor cursor) {
+ bindView(view, cursor, 1);
+ }
+
+ @Override
+ protected View newGroupView(Context context, ViewGroup parent) {
+ LayoutInflater inflater =
+ (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
+ findAndCacheViews(view);
+ return view;
+ }
+
+ @Override
+ protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
+ boolean expanded) {
+ bindView(view, cursor, groupSize);
+ }
+
+ private void findAndCacheViews(View view) {
+ // Get the views to bind to.
+ CallLogListItemViews views = CallLogListItemViews.fromView(view);
+ views.primaryActionView.setOnClickListener(mPrimaryActionListener);
+ views.secondaryActionView.setOnClickListener(mSecondaryActionListener);
+ view.setTag(views);
+ }
+
+ /**
+ * Binds the views in the entry to the data in the call log.
+ *
+ * @param view the view corresponding to this entry
+ * @param c the cursor pointing to the entry in the call log
+ * @param count the number of entries in the current item, greater than 1 if it is a group
+ */
+ private void bindView(View view, Cursor c, int count) {
+ final CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ final int section = c.getInt(CallLogQuery.SECTION);
+
+ // This might be a header: check the value of the section column in the cursor.
+ if (section == CallLogQuery.SECTION_NEW_HEADER
+ || section == CallLogQuery.SECTION_OLD_HEADER) {
+ views.primaryActionView.setVisibility(View.GONE);
+ views.bottomDivider.setVisibility(View.GONE);
+ views.listHeaderTextView.setVisibility(View.VISIBLE);
+ views.listHeaderTextView.setText(
+ section == CallLogQuery.SECTION_NEW_HEADER
+ ? R.string.call_log_new_header
+ : R.string.call_log_old_header);
+ // Nothing else to set up for a header.
+ return;
+ }
+ // Default case: an item in the call log.
+ views.primaryActionView.setVisibility(View.VISIBLE);
+ views.bottomDivider.setVisibility(isLastOfSection(c) ? View.GONE : View.VISIBLE);
+ views.listHeaderTextView.setVisibility(View.GONE);
+
+ final String number = c.getString(CallLogQuery.NUMBER);
+ final long date = c.getLong(CallLogQuery.DATE);
+ final long duration = c.getLong(CallLogQuery.DURATION);
+ final int callType = c.getInt(CallLogQuery.CALL_TYPE);
+ final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO);
+
+ final ContactInfo cachedContactInfo = getContactInfoFromCallLog(c);
+
+ views.primaryActionView.setTag(
+ IntentProvider.getCallDetailIntentProvider(
+ this, c.getPosition(), c.getLong(CallLogQuery.ID), count));
+ // Store away the voicemail information so we can play it directly.
+ if (callType == Calls.VOICEMAIL_TYPE) {
+ String voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI);
+ final long rowId = c.getLong(CallLogQuery.ID);
+ views.secondaryActionView.setTag(
+ IntentProvider.getPlayVoicemailIntentProvider(rowId, voicemailUri));
+ } else if (!TextUtils.isEmpty(number)) {
+ // Store away the number so we can call it directly if you click on the call icon.
+ views.secondaryActionView.setTag(
+ IntentProvider.getReturnCallIntentProvider(number));
+ } else {
+ // No action enabled.
+ views.secondaryActionView.setTag(null);
+ }
+
+ // Lookup contacts with this number
+ NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
+ ExpirableCache.CachedValue<ContactInfo> cachedInfo =
+ mContactInfoCache.getCachedValue(numberCountryIso);
+ ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue();
+ if (!mPhoneNumberHelper.canPlaceCallsTo(number)
+ || mPhoneNumberHelper.isVoicemailNumber(number)) {
+ // If this is a number that cannot be dialed, there is no point in looking up a contact
+ // for it.
+ info = ContactInfo.EMPTY;
+ } else if (cachedInfo == null) {
+ mContactInfoCache.put(numberCountryIso, ContactInfo.EMPTY);
+ // Use the cached contact info from the call log.
+ info = cachedContactInfo;
+ // The db request should happen on a non-UI thread.
+ // Request the contact details immediately since they are currently missing.
+ enqueueRequest(number, countryIso, cachedContactInfo, true);
+ // 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, cachedContactInfo, false);
+ } else if (!callLogInfoMatches(cachedContactInfo, 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, cachedContactInfo, false);
+ }
+
+ if (info == ContactInfo.EMPTY) {
+ // Use the cached contact info from the call log.
+ info = cachedContactInfo;
+ }
+ }
+
+ final Uri lookupUri = info.lookupUri;
+ final String name = info.name;
+ final int ntype = info.type;
+ final String label = info.label;
+ final long photoId = info.photoId;
+ CharSequence formattedNumber = info.formattedNumber;
+ final int[] callTypes = getCallTypes(c, count);
+ final String geocode = c.getString(CallLogQuery.GEOCODED_LOCATION);
+ final PhoneCallDetails details;
+ if (TextUtils.isEmpty(name)) {
+ details = new PhoneCallDetails(number, formattedNumber, countryIso, geocode,
+ callTypes, date, duration);
+ } else {
+ // We do not pass a photo id since we do not need the high-res picture.
+ details = new PhoneCallDetails(number, formattedNumber, countryIso, geocode,
+ callTypes, date, duration, name, ntype, label, lookupUri, null);
+ }
+
+ final boolean isNew = c.getInt(CallLogQuery.IS_READ) == 0;
+ // New items also use the highlighted version of the text.
+ final boolean isHighlighted = isNew;
+ mCallLogViewsHelper.setPhoneCallDetails(views, details, isHighlighted);
+ setPhoto(views, photoId, lookupUri);
+
+ // Listen for the first draw
+ if (mViewTreeObserver == null) {
+ mViewTreeObserver = view.getViewTreeObserver();
+ mViewTreeObserver.addOnPreDrawListener(this);
+ }
+ }
+
+ /** Returns true if this is the last item of a section. */
+ private boolean isLastOfSection(Cursor c) {
+ if (c.isLast()) return true;
+ final int section = c.getInt(CallLogQuery.SECTION);
+ if (!c.moveToNext()) return true;
+ final int nextSection = c.getInt(CallLogQuery.SECTION);
+ c.moveToPrevious();
+ return section != nextSection;
+ }
+
+ /** 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);
+ }
+
+ /** Stores the updated contact info in the call log if it is different from the current one. */
+ private void updateCallLogContactInfoCache(String number, String countryIso,
+ ContactInfo updatedInfo, ContactInfo callLogInfo) {
+ final ContentValues values = new ContentValues();
+ boolean needsUpdate = false;
+
+ if (callLogInfo != null) {
+ if (!TextUtils.equals(updatedInfo.name, callLogInfo.name)) {
+ values.put(Calls.CACHED_NAME, updatedInfo.name);
+ needsUpdate = true;
+ }
+
+ if (updatedInfo.type != callLogInfo.type) {
+ values.put(Calls.CACHED_NUMBER_TYPE, updatedInfo.type);
+ needsUpdate = true;
+ }
+
+ if (!TextUtils.equals(updatedInfo.label, callLogInfo.label)) {
+ values.put(Calls.CACHED_NUMBER_LABEL, updatedInfo.label);
+ needsUpdate = true;
+ }
+ if (!UriUtils.areEqual(updatedInfo.lookupUri, callLogInfo.lookupUri)) {
+ values.put(Calls.CACHED_LOOKUP_URI, UriUtils.uriToString(updatedInfo.lookupUri));
+ needsUpdate = true;
+ }
+ if (!TextUtils.equals(updatedInfo.normalizedNumber, callLogInfo.normalizedNumber)) {
+ values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber);
+ needsUpdate = true;
+ }
+ if (!TextUtils.equals(updatedInfo.number, callLogInfo.number)) {
+ values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number);
+ needsUpdate = true;
+ }
+ if (updatedInfo.photoId != callLogInfo.photoId) {
+ values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId);
+ needsUpdate = true;
+ }
+ if (!TextUtils.equals(updatedInfo.formattedNumber, callLogInfo.formattedNumber)) {
+ values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber);
+ needsUpdate = true;
+ }
+ } else {
+ // No previous values, store all of them.
+ values.put(Calls.CACHED_NAME, updatedInfo.name);
+ values.put(Calls.CACHED_NUMBER_TYPE, updatedInfo.type);
+ values.put(Calls.CACHED_NUMBER_LABEL, updatedInfo.label);
+ values.put(Calls.CACHED_LOOKUP_URI, UriUtils.uriToString(updatedInfo.lookupUri));
+ values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number);
+ values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber);
+ values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId);
+ values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber);
+ needsUpdate = true;
+ }
+
+ if (!needsUpdate) return;
+
+ if (countryIso == null) {
+ mContext.getContentResolver().update(Calls.CONTENT_URI_WITH_VOICEMAIL, values,
+ Calls.NUMBER + " = ? AND " + Calls.COUNTRY_ISO + " IS NULL",
+ new String[]{ number });
+ } else {
+ mContext.getContentResolver().update(Calls.CONTENT_URI_WITH_VOICEMAIL, values,
+ Calls.NUMBER + " = ? AND " + Calls.COUNTRY_ISO + " = ?",
+ new String[]{ number, countryIso });
+ }
+ }
+
+ /** Returns the contact information as stored in the call log. */
+ private ContactInfo getContactInfoFromCallLog(Cursor c) {
+ ContactInfo info = new ContactInfo();
+ info.lookupUri = UriUtils.parseUriOrNull(c.getString(CallLogQuery.CACHED_LOOKUP_URI));
+ info.name = c.getString(CallLogQuery.CACHED_NAME);
+ info.type = c.getInt(CallLogQuery.CACHED_NUMBER_TYPE);
+ info.label = c.getString(CallLogQuery.CACHED_NUMBER_LABEL);
+ String matchedNumber = c.getString(CallLogQuery.CACHED_MATCHED_NUMBER);
+ info.number = matchedNumber == null ? c.getString(CallLogQuery.NUMBER) : matchedNumber;
+ info.normalizedNumber = c.getString(CallLogQuery.CACHED_NORMALIZED_NUMBER);
+ info.photoId = c.getLong(CallLogQuery.CACHED_PHOTO_ID);
+ info.photoUri = null; // We do not cache the photo URI.
+ info.formattedNumber = c.getString(CallLogQuery.CACHED_FORMATTED_NUMBER);
+ return info;
+ }
+
+ /**
+ * Returns the call types for the given number of items in the cursor.
+ * <p>
+ * It uses the next {@code count} rows in the cursor to extract the types.
+ * <p>
+ * It position in the cursor is unchanged by this function.
+ */
+ private int[] getCallTypes(Cursor cursor, int count) {
+ int position = cursor.getPosition();
+ int[] callTypes = new int[count];
+ for (int index = 0; index < count; ++index) {
+ callTypes[index] = cursor.getInt(CallLogQuery.CALL_TYPE);
+ cursor.moveToNext();
+ }
+ cursor.moveToPosition(position);
+ return callTypes;
+ }
+
+ private void setPhoto(CallLogListItemViews views, long photoId, Uri contactUri) {
+ views.quickContactView.assignContactUri(contactUri);
+ mContactPhotoManager.loadThumbnail(views.quickContactView, photoId, true);
+ }
+
+ /**
+ * Sets whether processing of requests for contact details should be enabled.
+ * <p>
+ * This method should be called in tests to disable such processing of requests when not
+ * needed.
+ */
+ @VisibleForTesting
+ void disableRequestProcessingForTest() {
+ mRequestProcessingDisabled = true;
+ }
+
+ @VisibleForTesting
+ void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) {
+ NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
+ mContactInfoCache.put(numberCountryIso, contactInfo);
+ }
+
+ @Override
+ public void addGroup(int cursorPosition, int size, boolean expanded) {
+ super.addGroup(cursorPosition, size, expanded);
+ }
+
+ /*
+ * Get the number from the Contacts, if available, since sometimes
+ * the number provided by caller id may not be formatted properly
+ * depending on the carrier (roaming) in use at the time of the
+ * incoming call.
+ * Logic : If the caller-id number starts with a "+", use it
+ * Else if the number in the contacts starts with a "+", use that one
+ * Else if the number in the contacts is longer, use that one
+ */
+ public String getBetterNumberFromContacts(String number, String countryIso) {
+ String matchingNumber = null;
+ // Look in the cache first. If it's not found then query the Phones db
+ NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
+ ContactInfo ci = mContactInfoCache.getPossiblyExpired(numberCountryIso);
+ if (ci != null && ci != ContactInfo.EMPTY) {
+ matchingNumber = ci.number;
+ } else {
+ try {
+ Cursor phonesCursor = mContext.getContentResolver().query(
+ Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number),
+ PhoneQuery._PROJECTION, null, null, null);
+ if (phonesCursor != null) {
+ if (phonesCursor.moveToFirst()) {
+ matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER);
+ }
+ phonesCursor.close();
+ }
+ } catch (Exception e) {
+ // Use the number from the call log
+ }
+ }
+ if (!TextUtils.isEmpty(matchingNumber) &&
+ (matchingNumber.startsWith("+")
+ || matchingNumber.length() > number.length())) {
+ number = matchingNumber;
+ }
+ return number;
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogFragment.java b/src/com/android/dialer/calllog/CallLogFragment.java
new file mode 100644
index 000000000..4b3113403
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogFragment.java
@@ -0,0 +1,549 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.app.Activity;
+import android.app.KeyguardManager;
+import android.app.ListFragment;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.preference.PreferenceManager;
+import android.provider.CallLog;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.android.common.io.MoreCloseables;
+import com.android.contacts.ContactsUtils;
+import com.android.contacts.R;
+import com.android.contacts.util.Constants;
+import com.android.contacts.util.EmptyLoader;
+import com.android.dialer.voicemail.VoicemailStatusHelper;
+import com.android.dialer.voicemail.VoicemailStatusHelper.StatusMessage;
+import com.android.dialer.voicemail.VoicemailStatusHelperImpl;
+import com.android.internal.telephony.CallerInfo;
+import com.android.internal.telephony.ITelephony;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.List;
+
+/**
+ * Displays a list of call log entries.
+ */
+public class CallLogFragment extends ListFragment
+ implements CallLogQueryHandler.Listener, CallLogAdapter.CallFetcher {
+ private static final String TAG = "CallLogFragment";
+
+ /**
+ * ID of the empty loader to defer other fragments.
+ */
+ private static final int EMPTY_LOADER_ID = 0;
+
+ private static final String PREF_CALL_LOG_FILTER_LAST_CALL_TYPE = "CallLogFragment_last_filter";
+
+ private CallLogAdapter mAdapter;
+ private CallLogQueryHandler mCallLogQueryHandler;
+ private boolean mScrollToTop;
+
+ /** Whether there is at least one voicemail source installed. */
+ private boolean mVoicemailSourcesAvailable = false;
+ /** Whether we are currently filtering over voicemail. */
+ private boolean mShowingVoicemailOnly = false;
+
+ private VoicemailStatusHelper mVoicemailStatusHelper;
+ private View mStatusMessageView;
+ private TextView mStatusMessageText;
+ private TextView mStatusMessageAction;
+ private TextView mFilterStatusView;
+ private KeyguardManager mKeyguardManager;
+
+ private boolean mEmptyLoaderRunning;
+ private boolean mCallLogFetched;
+ private boolean mVoicemailStatusFetched;
+
+ private final Handler mHandler = new Handler();
+
+ private class CustomContentObserver extends ContentObserver {
+ public CustomContentObserver() {
+ super(mHandler);
+ }
+ @Override
+ public void onChange(boolean selfChange) {
+ mRefreshDataRequired = true;
+ }
+ }
+
+ // See issue 6363009
+ private final ContentObserver mCallLogObserver = new CustomContentObserver();
+ private final ContentObserver mContactsObserver = new CustomContentObserver();
+ private boolean mRefreshDataRequired = true;
+
+ // Exactly same variable is in Fragment as a package private.
+ private boolean mMenuVisible = true;
+
+ // Default to all calls.
+ private int mCallTypeFilter = CallLogQueryHandler.CALL_TYPE_ALL;
+
+ @Override
+ public void onCreate(Bundle state) {
+ super.onCreate(state);
+
+ mCallLogQueryHandler = new CallLogQueryHandler(getActivity().getContentResolver(), this);
+ mKeyguardManager =
+ (KeyguardManager) getActivity().getSystemService(Context.KEYGUARD_SERVICE);
+ getActivity().getContentResolver().registerContentObserver(
+ CallLog.CONTENT_URI, true, mCallLogObserver);
+ getActivity().getContentResolver().registerContentObserver(
+ ContactsContract.Contacts.CONTENT_URI, true, mContactsObserver);
+ setHasOptionsMenu(true);
+
+ // Load the last filter used.
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity());
+ mCallTypeFilter = prefs.getInt(PREF_CALL_LOG_FILTER_LAST_CALL_TYPE,
+ CallLogQueryHandler.CALL_TYPE_ALL);
+ }
+
+ /** Called by the CallLogQueryHandler when the list of calls has been fetched or updated. */
+ @Override
+ public void onCallsFetched(Cursor cursor) {
+ if (getActivity() == null || getActivity().isFinishing()) {
+ return;
+ }
+ mAdapter.setLoading(false);
+ mAdapter.changeCursor(cursor);
+ // This will update the state of the "Clear call log" menu item.
+ getActivity().invalidateOptionsMenu();
+ if (mScrollToTop) {
+ final ListView listView = getListView();
+ // The smooth-scroll animation happens over a fixed time period.
+ // As a result, if it scrolls through a large portion of the list,
+ // each frame will jump so far from the previous one that the user
+ // will not experience the illusion of downward motion. Instead,
+ // if we're not already near the top of the list, we instantly jump
+ // near the top, and animate from there.
+ if (listView.getFirstVisiblePosition() > 5) {
+ listView.setSelection(5);
+ }
+ // Workaround for framework issue: the smooth-scroll doesn't
+ // occur if setSelection() is called immediately before.
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (getActivity() == null || getActivity().isFinishing()) {
+ return;
+ }
+ listView.smoothScrollToPosition(0);
+ }
+ });
+
+ mScrollToTop = false;
+ }
+ mCallLogFetched = true;
+ destroyEmptyLoaderIfAllDataFetched();
+ }
+
+ /**
+ * Called by {@link CallLogQueryHandler} after a successful query to voicemail status provider.
+ */
+ @Override
+ public void onVoicemailStatusFetched(Cursor statusCursor) {
+ if (getActivity() == null || getActivity().isFinishing()) {
+ return;
+ }
+ updateVoicemailStatusMessage(statusCursor);
+
+ int activeSources = mVoicemailStatusHelper.getNumberActivityVoicemailSources(statusCursor);
+ setVoicemailSourcesAvailable(activeSources != 0);
+ MoreCloseables.closeQuietly(statusCursor);
+ mVoicemailStatusFetched = true;
+ destroyEmptyLoaderIfAllDataFetched();
+ }
+
+ private void destroyEmptyLoaderIfAllDataFetched() {
+ if (mCallLogFetched && mVoicemailStatusFetched && mEmptyLoaderRunning) {
+ mEmptyLoaderRunning = false;
+ getLoaderManager().destroyLoader(EMPTY_LOADER_ID);
+ }
+ }
+
+ /** Sets whether there are any voicemail sources available in the platform. */
+ private void setVoicemailSourcesAvailable(boolean voicemailSourcesAvailable) {
+ if (mVoicemailSourcesAvailable == voicemailSourcesAvailable) return;
+ mVoicemailSourcesAvailable = voicemailSourcesAvailable;
+
+ Activity activity = getActivity();
+ if (activity != null) {
+ // This is so that the options menu content is updated.
+ activity.invalidateOptionsMenu();
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
+ View view = inflater.inflate(R.layout.call_log_fragment, container, false);
+ mVoicemailStatusHelper = new VoicemailStatusHelperImpl();
+ mStatusMessageView = view.findViewById(R.id.voicemail_status);
+ mStatusMessageText = (TextView) view.findViewById(R.id.voicemail_status_message);
+ mStatusMessageAction = (TextView) view.findViewById(R.id.voicemail_status_action);
+ mFilterStatusView = (TextView) view.findViewById(R.id.filter_status);
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ String currentCountryIso = ContactsUtils.getCurrentCountryIso(getActivity());
+ mAdapter = new CallLogAdapter(getActivity(), this,
+ new ContactInfoHelper(getActivity(), currentCountryIso));
+ setListAdapter(mAdapter);
+ getListView().setItemsCanFocus(true);
+
+ updateFilterHeader();
+ }
+
+ /**
+ * Based on the new intent, decide whether the list should be configured
+ * to scroll up to display the first item.
+ */
+ public void configureScreenFromIntent(Intent newIntent) {
+ // Typically, when switching to the call-log we want to show the user
+ // the same section of the list that they were most recently looking
+ // at. However, under some circumstances, we want to automatically
+ // scroll to the top of the list to present the newest call items.
+ // For example, immediately after a call is finished, we want to
+ // display information about that call.
+ mScrollToTop = Calls.CONTENT_TYPE.equals(newIntent.getType());
+ }
+
+ @Override
+ public void onStart() {
+ // Start the empty loader now to defer other fragments. We destroy it when both calllog
+ // and the voicemail status are fetched.
+ getLoaderManager().initLoader(EMPTY_LOADER_ID, null,
+ new EmptyLoader.Callback(getActivity()));
+ mEmptyLoaderRunning = true;
+ super.onStart();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ refreshData();
+ }
+
+ private void updateVoicemailStatusMessage(Cursor statusCursor) {
+ List<StatusMessage> messages = mVoicemailStatusHelper.getStatusMessages(statusCursor);
+ if (messages.size() == 0) {
+ mStatusMessageView.setVisibility(View.GONE);
+ } else {
+ mStatusMessageView.setVisibility(View.VISIBLE);
+ // TODO: Change the code to show all messages. For now just pick the first message.
+ final StatusMessage message = messages.get(0);
+ if (message.showInCallLog()) {
+ mStatusMessageText.setText(message.callLogMessageId);
+ }
+ if (message.actionMessageId != -1) {
+ mStatusMessageAction.setText(message.actionMessageId);
+ }
+ if (message.actionUri != null) {
+ mStatusMessageAction.setVisibility(View.VISIBLE);
+ mStatusMessageAction.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getActivity().startActivity(
+ new Intent(Intent.ACTION_VIEW, message.actionUri));
+ }
+ });
+ } else {
+ mStatusMessageAction.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ // Kill the requests thread
+ mAdapter.stopRequestProcessing();
+
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity());
+ prefs.edit()
+ .putInt(PREF_CALL_LOG_FILTER_LAST_CALL_TYPE, mCallTypeFilter)
+ .apply();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ updateOnExit();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mAdapter.stopRequestProcessing();
+ mAdapter.changeCursor(null);
+ getActivity().getContentResolver().unregisterContentObserver(mCallLogObserver);
+ getActivity().getContentResolver().unregisterContentObserver(mContactsObserver);
+ }
+
+ @Override
+ public void fetchCalls() {
+ mCallLogQueryHandler.fetchCalls(mCallTypeFilter);
+ }
+
+ public void startCallsQuery() {
+ mAdapter.setLoading(true);
+ mCallLogQueryHandler.fetchCalls(mCallTypeFilter);
+ if (mShowingVoicemailOnly) {
+ mShowingVoicemailOnly = false;
+ getActivity().invalidateOptionsMenu();
+ }
+ }
+
+ private void startVoicemailStatusQuery() {
+ mCallLogQueryHandler.fetchVoicemailStatus();
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ inflater.inflate(R.menu.call_log_options, menu);
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ final MenuItem itemDeleteAll = menu.findItem(R.id.delete_all);
+ // Check if all the menu items are inflated correctly. As a shortcut, we assume all
+ // menu items are ready if the first item is non-null.
+ if (itemDeleteAll != null) {
+ itemDeleteAll.setEnabled(mAdapter != null && !mAdapter.isEmpty());
+ menu.findItem(R.id.show_voicemails_only).setVisible(mVoicemailSourcesAvailable);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.delete_all:
+ ClearCallLogDialog.show(getFragmentManager());
+ return true;
+
+ case R.id.show_outgoing_only:
+ mCallLogQueryHandler.fetchCalls(Calls.OUTGOING_TYPE);
+ mCallTypeFilter = Calls.OUTGOING_TYPE;
+ updateFilterHeader();
+ return true;
+
+ case R.id.show_incoming_only:
+ mCallLogQueryHandler.fetchCalls(Calls.INCOMING_TYPE);
+ mCallTypeFilter = Calls.INCOMING_TYPE;
+ updateFilterHeader();
+ return true;
+
+ case R.id.show_missed_only:
+ mCallLogQueryHandler.fetchCalls(Calls.MISSED_TYPE);
+ mCallTypeFilter = Calls.MISSED_TYPE;
+ updateFilterHeader();
+ return true;
+
+ case R.id.show_voicemails_only:
+ mCallLogQueryHandler.fetchCalls(Calls.VOICEMAIL_TYPE);
+ mCallTypeFilter = Calls.VOICEMAIL_TYPE;
+ updateFilterHeader();
+ mShowingVoicemailOnly = true;
+ return true;
+
+ case R.id.show_all_calls:
+ mCallLogQueryHandler.fetchCalls(CallLogQueryHandler.CALL_TYPE_ALL);
+ mCallTypeFilter = CallLogQueryHandler.CALL_TYPE_ALL;
+ updateFilterHeader();
+ mShowingVoicemailOnly = false;
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ private void updateFilterHeader() {
+ switch (mCallTypeFilter) {
+ case CallLogQueryHandler.CALL_TYPE_ALL:
+ mFilterStatusView.setVisibility(View.GONE);
+ break;
+ case Calls.INCOMING_TYPE:
+ showFilterStatus(R.string.call_log_incoming_header);
+ break;
+ case Calls.OUTGOING_TYPE:
+ showFilterStatus(R.string.call_log_outgoing_header);
+ break;
+ case Calls.MISSED_TYPE:
+ showFilterStatus(R.string.call_log_missed_header);
+ break;
+ case Calls.VOICEMAIL_TYPE:
+ showFilterStatus(R.string.call_log_voicemail_header);
+ break;
+ }
+ }
+
+ private void showFilterStatus(int resId) {
+ mFilterStatusView.setText(resId);
+ mFilterStatusView.setVisibility(View.VISIBLE);
+ }
+
+ public void callSelectedEntry() {
+ int position = getListView().getSelectedItemPosition();
+ if (position < 0) {
+ // In touch mode you may often not have something selected, so
+ // just call the first entry to make sure that [send] [send] calls the
+ // most recent entry.
+ position = 0;
+ }
+ final Cursor cursor = (Cursor)mAdapter.getItem(position);
+ if (cursor != null) {
+ String number = cursor.getString(CallLogQuery.NUMBER);
+ if (TextUtils.isEmpty(number)
+ || number.equals(CallerInfo.UNKNOWN_NUMBER)
+ || number.equals(CallerInfo.PRIVATE_NUMBER)
+ || number.equals(CallerInfo.PAYPHONE_NUMBER)) {
+ // This number can't be called, do nothing
+ return;
+ }
+ Intent intent;
+ // If "number" is really a SIP address, construct a sip: URI.
+ if (PhoneNumberUtils.isUriNumber(number)) {
+ intent = ContactsUtils.getCallIntent(
+ Uri.fromParts(Constants.SCHEME_SIP, number, null));
+ } else {
+ // We're calling a regular PSTN phone number.
+ // Construct a tel: URI, but do some other possible cleanup first.
+ int callType = cursor.getInt(CallLogQuery.CALL_TYPE);
+ if (!number.startsWith("+") &&
+ (callType == Calls.INCOMING_TYPE
+ || callType == Calls.MISSED_TYPE)) {
+ // If the caller-id matches a contact with a better qualified number, use it
+ String countryIso = cursor.getString(CallLogQuery.COUNTRY_ISO);
+ number = mAdapter.getBetterNumberFromContacts(number, countryIso);
+ }
+ intent = ContactsUtils.getCallIntent(
+ Uri.fromParts(Constants.SCHEME_TEL, number, null));
+ }
+ intent.setFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ startActivity(intent);
+ }
+ }
+
+ @VisibleForTesting
+ CallLogAdapter getAdapter() {
+ return mAdapter;
+ }
+
+ @Override
+ public void setMenuVisibility(boolean menuVisible) {
+ super.setMenuVisibility(menuVisible);
+ if (mMenuVisible != menuVisible) {
+ mMenuVisible = menuVisible;
+ if (!menuVisible) {
+ updateOnExit();
+ } else if (isResumed()) {
+ refreshData();
+ }
+ }
+ }
+
+ /** Requests updates to the data to be shown. */
+ private void refreshData() {
+ // Prevent unnecessary refresh.
+ if (mRefreshDataRequired) {
+ // Mark all entries in the contact info cache as out of date, so they will be looked up
+ // again once being shown.
+ mAdapter.invalidateCache();
+ startCallsQuery();
+ startVoicemailStatusQuery();
+ updateOnEntry();
+ mRefreshDataRequired = false;
+ }
+ }
+
+ /** Removes the missed call notifications. */
+ private void removeMissedCallNotifications() {
+ try {
+ ITelephony telephony =
+ ITelephony.Stub.asInterface(ServiceManager.getService("phone"));
+ if (telephony != null) {
+ telephony.cancelMissedCallsNotification();
+ } else {
+ Log.w(TAG, "Telephony service is null, can't call " +
+ "cancelMissedCallsNotification");
+ }
+ } catch (RemoteException e) {
+ Log.e(TAG, "Failed to clear missed calls notification due to remote exception");
+ }
+ }
+
+ /** Updates call data and notification state while leaving the call log tab. */
+ private void updateOnExit() {
+ updateOnTransition(false);
+ }
+
+ /** Updates call data and notification state while entering the call log tab. */
+ private void updateOnEntry() {
+ updateOnTransition(true);
+ }
+
+ private void updateOnTransition(boolean onEntry) {
+ // We don't want to update any call data when keyguard is on because the user has likely not
+ // seen the new calls yet.
+ // This might be called before onCreate() and thus we need to check null explicitly.
+ if (mKeyguardManager != null && !mKeyguardManager.inKeyguardRestrictedInputMode()) {
+ // On either of the transitions we reset the new flag and update the notifications.
+ // While exiting we additionally consume all missed calls (by marking them as read).
+ // This will ensure that they no more appear in the "new" section when we return back.
+ mCallLogQueryHandler.markNewCallsAsOld();
+ if (!onEntry) {
+ mCallLogQueryHandler.markMissedCallsAsRead();
+ }
+ removeMissedCallNotifications();
+ updateVoicemailNotifications();
+ }
+ }
+
+ private void updateVoicemailNotifications() {
+ Intent serviceIntent = new Intent(getActivity(), CallLogNotificationsService.class);
+ serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS);
+ getActivity().startService(serviceIntent);
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogGroupBuilder.java b/src/com/android/dialer/calllog/CallLogGroupBuilder.java
new file mode 100644
index 000000000..bf472bd7a
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogGroupBuilder.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.database.Cursor;
+import android.provider.CallLog.Calls;
+import android.telephony.PhoneNumberUtils;
+
+import com.android.common.widget.GroupingListAdapter;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Groups together calls in the call log.
+ * <p>
+ * This class is meant to be used in conjunction with {@link GroupingListAdapter}.
+ */
+public class CallLogGroupBuilder {
+ public interface GroupCreator {
+ public void addGroup(int cursorPosition, int size, boolean expanded);
+ }
+
+ /** The object on which the groups are created. */
+ private final GroupCreator mGroupCreator;
+
+ public CallLogGroupBuilder(GroupCreator groupCreator) {
+ mGroupCreator = groupCreator;
+ }
+
+ /**
+ * Finds all groups of adjacent entries in the call log which should be grouped together and
+ * calls {@link GroupCreator#addGroup(int, int, boolean)} on {@link #mGroupCreator} for each of
+ * them.
+ * <p>
+ * For entries that are not grouped with others, we do not need to create a group of size one.
+ * <p>
+ * It assumes that the cursor will not change during its execution.
+ *
+ * @see GroupingListAdapter#addGroups(Cursor)
+ */
+ public void addGroups(Cursor cursor) {
+ final int count = cursor.getCount();
+ if (count == 0) {
+ return;
+ }
+
+ int currentGroupSize = 1;
+ cursor.moveToFirst();
+ // The number of the first entry in the group.
+ String firstNumber = cursor.getString(CallLogQuery.NUMBER);
+ // This is the type of the first call in the group.
+ int firstCallType = cursor.getInt(CallLogQuery.CALL_TYPE);
+ while (cursor.moveToNext()) {
+ // The number of the current row in the cursor.
+ final String currentNumber = cursor.getString(CallLogQuery.NUMBER);
+ final int callType = cursor.getInt(CallLogQuery.CALL_TYPE);
+ final boolean sameNumber = equalNumbers(firstNumber, currentNumber);
+ final boolean shouldGroup;
+
+ if (CallLogQuery.isSectionHeader(cursor)) {
+ // Cannot group headers.
+ shouldGroup = false;
+ } else if (!sameNumber) {
+ // Should only group with calls from the same number.
+ shouldGroup = false;
+ } else if (firstCallType == Calls.VOICEMAIL_TYPE) {
+ // never group voicemail.
+ shouldGroup = false;
+ } else {
+ // Incoming, outgoing, and missed calls group together.
+ shouldGroup = (callType == Calls.INCOMING_TYPE || callType == Calls.OUTGOING_TYPE ||
+ callType == Calls.MISSED_TYPE);
+ }
+
+ if (shouldGroup) {
+ // Increment the size of the group to include the current call, but do not create
+ // the group until we find a call that does not match.
+ currentGroupSize++;
+ } else {
+ // Create a group for the previous set of calls, excluding the current one, but do
+ // not create a group for a single call.
+ if (currentGroupSize > 1) {
+ addGroup(cursor.getPosition() - currentGroupSize, currentGroupSize);
+ }
+ // Start a new group; it will include at least the current call.
+ currentGroupSize = 1;
+ // The current entry is now the first in the group.
+ firstNumber = currentNumber;
+ firstCallType = callType;
+ }
+ }
+ // If the last set of calls at the end of the call log was itself a group, create it now.
+ if (currentGroupSize > 1) {
+ addGroup(count - currentGroupSize, currentGroupSize);
+ }
+ }
+
+ /**
+ * Creates a group of items in the cursor.
+ * <p>
+ * The group is always unexpanded.
+ *
+ * @see CallLogAdapter#addGroup(int, int, boolean)
+ */
+ private void addGroup(int cursorPosition, int size) {
+ mGroupCreator.addGroup(cursorPosition, size, false);
+ }
+
+ @VisibleForTesting
+ boolean equalNumbers(String number1, String number2) {
+ if (PhoneNumberUtils.isUriNumber(number1) || PhoneNumberUtils.isUriNumber(number2)) {
+ return compareSipAddresses(number1, number2);
+ } else {
+ return PhoneNumberUtils.compare(number1, number2);
+ }
+ }
+
+ @VisibleForTesting
+ boolean compareSipAddresses(String number1, String number2) {
+ if (number1 == null || number2 == null) return number1 == number2;
+
+ int index1 = number1.indexOf('@');
+ final String userinfo1;
+ final String rest1;
+ if (index1 != -1) {
+ userinfo1 = number1.substring(0, index1);
+ rest1 = number1.substring(index1);
+ } else {
+ userinfo1 = number1;
+ rest1 = "";
+ }
+
+ int index2 = number2.indexOf('@');
+ final String userinfo2;
+ final String rest2;
+ if (index2 != -1) {
+ userinfo2 = number2.substring(0, index2);
+ rest2 = number2.substring(index2);
+ } else {
+ userinfo2 = number2;
+ rest2 = "";
+ }
+
+ return userinfo1.equals(userinfo2) && rest1.equalsIgnoreCase(rest2);
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogListItemHelper.java b/src/com/android/dialer/calllog/CallLogListItemHelper.java
new file mode 100644
index 000000000..7862a5679
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogListItemHelper.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.res.Resources;
+import android.provider.CallLog.Calls;
+import android.text.TextUtils;
+import android.view.View;
+
+import com.android.dialer.PhoneCallDetails;
+import com.android.dialer.PhoneCallDetailsHelper;
+import com.android.contacts.R;
+
+/**
+ * Helper class to fill in the views of a call log entry.
+ */
+/*package*/ class CallLogListItemHelper {
+ /** Helper for populating the details of a phone call. */
+ private final PhoneCallDetailsHelper mPhoneCallDetailsHelper;
+ /** Helper for handling phone numbers. */
+ private final PhoneNumberHelper mPhoneNumberHelper;
+ /** Resources to look up strings. */
+ private final Resources mResources;
+
+ /**
+ * Creates a new helper instance.
+ *
+ * @param phoneCallDetailsHelper used to set the details of a phone call
+ * @param phoneNumberHelper used to process phone number
+ */
+ public CallLogListItemHelper(PhoneCallDetailsHelper phoneCallDetailsHelper,
+ PhoneNumberHelper phoneNumberHelper, Resources resources) {
+ mPhoneCallDetailsHelper = phoneCallDetailsHelper;
+ mPhoneNumberHelper = phoneNumberHelper;
+ mResources = resources;
+ }
+
+ /**
+ * Sets the name, label, and number for a contact.
+ *
+ * @param views the views to populate
+ * @param details the details of a phone call needed to fill in the data
+ * @param isHighlighted whether to use the highlight text for the call
+ */
+ public void setPhoneCallDetails(CallLogListItemViews views, PhoneCallDetails details,
+ boolean isHighlighted) {
+ mPhoneCallDetailsHelper.setPhoneCallDetails(views.phoneCallDetailsViews, details,
+ isHighlighted);
+ boolean canCall = mPhoneNumberHelper.canPlaceCallsTo(details.number);
+ boolean canPlay = details.callTypes[0] == Calls.VOICEMAIL_TYPE;
+
+ if (canPlay) {
+ // Playback action takes preference.
+ configurePlaySecondaryAction(views, isHighlighted);
+ views.dividerView.setVisibility(View.VISIBLE);
+ } else if (canCall) {
+ // Call is the secondary action.
+ configureCallSecondaryAction(views, details);
+ views.dividerView.setVisibility(View.VISIBLE);
+ } else {
+ // No action available.
+ views.secondaryActionView.setVisibility(View.GONE);
+ views.dividerView.setVisibility(View.GONE);
+ }
+ }
+
+ /** Sets the secondary action to correspond to the call button. */
+ private void configureCallSecondaryAction(CallLogListItemViews views,
+ PhoneCallDetails details) {
+ views.secondaryActionView.setVisibility(View.VISIBLE);
+ views.secondaryActionView.setImageResource(R.drawable.ic_ab_dialer_holo_dark);
+ views.secondaryActionView.setContentDescription(getCallActionDescription(details));
+ }
+
+ /** Returns the description used by the call action for this phone call. */
+ private CharSequence getCallActionDescription(PhoneCallDetails details) {
+ final CharSequence recipient;
+ if (!TextUtils.isEmpty(details.name)) {
+ recipient = details.name;
+ } else {
+ recipient = mPhoneNumberHelper.getDisplayNumber(
+ details.number, details.formattedNumber);
+ }
+ return mResources.getString(R.string.description_call, recipient);
+ }
+
+ /** Sets the secondary action to correspond to the play button. */
+ private void configurePlaySecondaryAction(CallLogListItemViews views, boolean isHighlighted) {
+ views.secondaryActionView.setVisibility(View.VISIBLE);
+ views.secondaryActionView.setImageResource(
+ isHighlighted ? R.drawable.ic_play_active_holo_dark : R.drawable.ic_play_holo_dark);
+ views.secondaryActionView.setContentDescription(
+ mResources.getString(R.string.description_call_log_play_button));
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogListItemView.java b/src/com/android/dialer/calllog/CallLogListItemView.java
new file mode 100644
index 000000000..113b02a5b
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogListItemView.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+/**
+ * An entry in the call log.
+ */
+public class CallLogListItemView extends LinearLayout {
+ public CallLogListItemView(Context context) {
+ super(context);
+ }
+
+ public CallLogListItemView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public CallLogListItemView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void requestLayout() {
+ // We will assume that once measured this will not need to resize
+ // itself, so there is no need to pass the layout request to the parent
+ // view (ListView).
+ forceLayout();
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogListItemViews.java b/src/com/android/dialer/calllog/CallLogListItemViews.java
new file mode 100644
index 000000000..5b860efcb
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogListItemViews.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.QuickContactBadge;
+import android.widget.TextView;
+
+import com.android.dialer.PhoneCallDetailsViews;
+import com.android.contacts.R;
+import com.android.contacts.test.NeededForTesting;
+
+/**
+ * Simple value object containing the various views within a call log entry.
+ */
+public final class CallLogListItemViews {
+ /** The quick contact badge for the contact. */
+ public final QuickContactBadge quickContactView;
+ /** The primary action view of the entry. */
+ public final View primaryActionView;
+ /** The secondary action button on the entry. */
+ public final ImageView secondaryActionView;
+ /** The divider between the primary and secondary actions. */
+ public final View dividerView;
+ /** The details of the phone call. */
+ public final PhoneCallDetailsViews phoneCallDetailsViews;
+ /** The text of the header of a section. */
+ public final TextView listHeaderTextView;
+ /** The divider to be shown below items. */
+ public final View bottomDivider;
+
+ private CallLogListItemViews(QuickContactBadge quickContactView, View primaryActionView,
+ ImageView secondaryActionView, View dividerView,
+ PhoneCallDetailsViews phoneCallDetailsViews,
+ TextView listHeaderTextView, View bottomDivider) {
+ this.quickContactView = quickContactView;
+ this.primaryActionView = primaryActionView;
+ this.secondaryActionView = secondaryActionView;
+ this.dividerView = dividerView;
+ this.phoneCallDetailsViews = phoneCallDetailsViews;
+ this.listHeaderTextView = listHeaderTextView;
+ this.bottomDivider = bottomDivider;
+ }
+
+ public static CallLogListItemViews fromView(View view) {
+ return new CallLogListItemViews(
+ (QuickContactBadge) view.findViewById(R.id.quick_contact_photo),
+ view.findViewById(R.id.primary_action_view),
+ (ImageView) view.findViewById(R.id.secondary_action_icon),
+ view.findViewById(R.id.divider),
+ PhoneCallDetailsViews.fromView(view),
+ (TextView) view.findViewById(R.id.call_log_header),
+ view.findViewById(R.id.call_log_divider));
+ }
+
+ @NeededForTesting
+ public static CallLogListItemViews createForTest(Context context) {
+ return new CallLogListItemViews(
+ new QuickContactBadge(context),
+ new View(context),
+ new ImageView(context),
+ new View(context),
+ PhoneCallDetailsViews.createForTest(context),
+ new TextView(context),
+ new View(context));
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogNotificationsService.java b/src/com/android/dialer/calllog/CallLogNotificationsService.java
new file mode 100644
index 000000000..3270963cc
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogNotificationsService.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.app.IntentService;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Log;
+
+/**
+ * Provides operations for managing notifications.
+ * <p>
+ * It handles the following actions:
+ * <ul>
+ * <li>{@link #ACTION_MARK_NEW_VOICEMAILS_AS_OLD}: marks all the new voicemails in the call log as
+ * old; this is called when a notification is dismissed.</li>
+ * <li>{@link #ACTION_UPDATE_NOTIFICATIONS}: updates the content of the new items notification; it
+ * may include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}, containing the URI of the new
+ * voicemail that has triggered this update (if any).</li>
+ * </ul>
+ */
+public class CallLogNotificationsService extends IntentService {
+ private static final String TAG = "CallLogNotificationsService";
+
+ /** Action to mark all the new voicemails as old. */
+ public static final String ACTION_MARK_NEW_VOICEMAILS_AS_OLD =
+ "com.android.dialer.calllog.ACTION_MARK_NEW_VOICEMAILS_AS_OLD";
+
+ /**
+ * Action to update the notifications.
+ * <p>
+ * May include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}.
+ */
+ public static final String ACTION_UPDATE_NOTIFICATIONS =
+ "com.android.dialer.calllog.UPDATE_NOTIFICATIONS";
+
+ /**
+ * Extra to included with {@link #ACTION_UPDATE_NOTIFICATIONS} to identify the new voicemail
+ * that triggered an update.
+ * <p>
+ * It must be a {@link Uri}.
+ */
+ public static final String EXTRA_NEW_VOICEMAIL_URI = "NEW_VOICEMAIL_URI";
+
+ private CallLogQueryHandler mCallLogQueryHandler;
+
+ public CallLogNotificationsService() {
+ super("CallLogNotificationsService");
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mCallLogQueryHandler = new CallLogQueryHandler(getContentResolver(), null /*listener*/);
+ }
+
+ @Override
+ protected void onHandleIntent(Intent intent) {
+ if (ACTION_MARK_NEW_VOICEMAILS_AS_OLD.equals(intent.getAction())) {
+ mCallLogQueryHandler.markNewVoicemailsAsOld();
+ } else if (ACTION_UPDATE_NOTIFICATIONS.equals(intent.getAction())) {
+ Uri voicemailUri = (Uri) intent.getParcelableExtra(EXTRA_NEW_VOICEMAIL_URI);
+ DefaultVoicemailNotifier.getInstance(this).updateNotification(voicemailUri);
+ } else {
+ Log.d(TAG, "onHandleIntent: could not handle: " + intent);
+ }
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogQuery.java b/src/com/android/dialer/calllog/CallLogQuery.java
new file mode 100644
index 000000000..5f7b27b93
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogQuery.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.database.Cursor;
+import android.provider.CallLog.Calls;
+
+/**
+ * The query for the call log table.
+ */
+public final class CallLogQuery {
+ // If you alter this, you must also alter the method that inserts a fake row to the headers
+ // in the CallLogQueryHandler class called createHeaderCursorFor().
+ public static final String[] _PROJECTION = new String[] {
+ Calls._ID, // 0
+ Calls.NUMBER, // 1
+ Calls.DATE, // 2
+ Calls.DURATION, // 3
+ Calls.TYPE, // 4
+ Calls.COUNTRY_ISO, // 5
+ Calls.VOICEMAIL_URI, // 6
+ Calls.GEOCODED_LOCATION, // 7
+ Calls.CACHED_NAME, // 8
+ Calls.CACHED_NUMBER_TYPE, // 9
+ Calls.CACHED_NUMBER_LABEL, // 10
+ Calls.CACHED_LOOKUP_URI, // 11
+ Calls.CACHED_MATCHED_NUMBER, // 12
+ Calls.CACHED_NORMALIZED_NUMBER, // 13
+ Calls.CACHED_PHOTO_ID, // 14
+ Calls.CACHED_FORMATTED_NUMBER, // 15
+ Calls.IS_READ, // 16
+ };
+
+ public static final int ID = 0;
+ public static final int NUMBER = 1;
+ public static final int DATE = 2;
+ public static final int DURATION = 3;
+ public static final int CALL_TYPE = 4;
+ public static final int COUNTRY_ISO = 5;
+ public static final int VOICEMAIL_URI = 6;
+ public static final int GEOCODED_LOCATION = 7;
+ public static final int CACHED_NAME = 8;
+ public static final int CACHED_NUMBER_TYPE = 9;
+ public static final int CACHED_NUMBER_LABEL = 10;
+ public static final int CACHED_LOOKUP_URI = 11;
+ public static final int CACHED_MATCHED_NUMBER = 12;
+ public static final int CACHED_NORMALIZED_NUMBER = 13;
+ public static final int CACHED_PHOTO_ID = 14;
+ public static final int CACHED_FORMATTED_NUMBER = 15;
+ public static final int IS_READ = 16;
+ /** The index of the synthetic "section" column in the extended projection. */
+ public static final int SECTION = 17;
+
+ /**
+ * The name of the synthetic "section" column.
+ * <p>
+ * This column identifies whether a row is a header or an actual item, and whether it is
+ * part of the new or old calls.
+ */
+ public static final String SECTION_NAME = "section";
+ /** The value of the "section" column for the header of the new section. */
+ public static final int SECTION_NEW_HEADER = 0;
+ /** The value of the "section" column for the items of the new section. */
+ public static final int SECTION_NEW_ITEM = 1;
+ /** The value of the "section" column for the header of the old section. */
+ public static final int SECTION_OLD_HEADER = 2;
+ /** The value of the "section" column for the items of the old section. */
+ public static final int SECTION_OLD_ITEM = 3;
+
+ /** The call log projection including the section name. */
+ public static final String[] EXTENDED_PROJECTION;
+ static {
+ EXTENDED_PROJECTION = new String[_PROJECTION.length + 1];
+ System.arraycopy(_PROJECTION, 0, EXTENDED_PROJECTION, 0, _PROJECTION.length);
+ EXTENDED_PROJECTION[_PROJECTION.length] = SECTION_NAME;
+ }
+
+ public static boolean isSectionHeader(Cursor cursor) {
+ int section = cursor.getInt(CallLogQuery.SECTION);
+ return section == CallLogQuery.SECTION_NEW_HEADER
+ || section == CallLogQuery.SECTION_OLD_HEADER;
+ }
+
+ public static boolean isNewSection(Cursor cursor) {
+ int section = cursor.getInt(CallLogQuery.SECTION);
+ return section == CallLogQuery.SECTION_NEW_ITEM
+ || section == CallLogQuery.SECTION_NEW_HEADER;
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogQueryHandler.java b/src/com/android/dialer/calllog/CallLogQueryHandler.java
new file mode 100644
index 000000000..2e67e5a01
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogQueryHandler.java
@@ -0,0 +1,364 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.AsyncQueryHandler;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MergeCursor;
+import android.database.sqlite.SQLiteDatabaseCorruptException;
+import android.database.sqlite.SQLiteDiskIOException;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteFullException;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.provider.CallLog.Calls;
+import android.provider.VoicemailContract.Status;
+import android.util.Log;
+
+import com.android.common.io.MoreCloseables;
+import com.android.dialer.voicemail.VoicemailStatusHelperImpl;
+import com.google.common.collect.Lists;
+
+import java.lang.ref.WeakReference;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/** Handles asynchronous queries to the call log. */
+/*package*/ class CallLogQueryHandler extends AsyncQueryHandler {
+ private static final String[] EMPTY_STRING_ARRAY = new String[0];
+
+ private static final String TAG = "CallLogQueryHandler";
+ private static final int NUM_LOGS_TO_DISPLAY = 1000;
+
+ /** The token for the query to fetch the new entries from the call log. */
+ private static final int QUERY_NEW_CALLS_TOKEN = 53;
+ /** The token for the query to fetch the old entries from the call log. */
+ private static final int QUERY_OLD_CALLS_TOKEN = 54;
+ /** The token for the query to mark all missed calls as old after seeing the call log. */
+ private static final int UPDATE_MARK_AS_OLD_TOKEN = 55;
+ /** The token for the query to mark all new voicemails as old. */
+ private static final int UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN = 56;
+ /** The token for the query to mark all missed calls as read after seeing the call log. */
+ private static final int UPDATE_MARK_MISSED_CALL_AS_READ_TOKEN = 57;
+ /** The token for the query to fetch voicemail status messages. */
+ private static final int QUERY_VOICEMAIL_STATUS_TOKEN = 58;
+
+ /**
+ * Call type similar to Calls.INCOMING_TYPE used to specify all types instead of one particular
+ * type.
+ */
+ public static final int CALL_TYPE_ALL = -1;
+
+ /**
+ * The time window from the current time within which an unread entry will be added to the new
+ * section.
+ */
+ private static final long NEW_SECTION_TIME_WINDOW = TimeUnit.DAYS.toMillis(7);
+
+ private final WeakReference<Listener> mListener;
+
+ /** The cursor containing the new calls, or null if they have not yet been fetched. */
+ @GuardedBy("this") private Cursor mNewCallsCursor;
+ /** The cursor containing the old calls, or null if they have not yet been fetched. */
+ @GuardedBy("this") private Cursor mOldCallsCursor;
+ /**
+ * The identifier of the latest calls request.
+ * <p>
+ * A request for the list of calls requires two queries and hence the two cursor
+ * {@link #mNewCallsCursor} and {@link #mOldCallsCursor} above, corresponding to
+ * {@link #QUERY_NEW_CALLS_TOKEN} and {@link #QUERY_OLD_CALLS_TOKEN}.
+ * <p>
+ * When a new request is about to be started, existing cursors are closed. However, it is
+ * possible that one of the queries completes after the new request has started. This means that
+ * we might merge two cursors that do not correspond to the same request. Moreover, this may
+ * lead to a resource leak if the same query completes and we override the cursor without
+ * closing it first.
+ * <p>
+ * To make sure we only join two cursors from the same request, we use this variable to store
+ * the request id of the latest request and make sure we only process cursors corresponding to
+ * the this request.
+ */
+ @GuardedBy("this") private int mCallsRequestId;
+
+ /**
+ * Simple handler that wraps background calls to catch
+ * {@link SQLiteException}, such as when the disk is full.
+ */
+ protected class CatchingWorkerHandler extends AsyncQueryHandler.WorkerHandler {
+ public CatchingWorkerHandler(Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ try {
+ // Perform same query while catching any exceptions
+ super.handleMessage(msg);
+ } catch (SQLiteDiskIOException e) {
+ Log.w(TAG, "Exception on background worker thread", e);
+ } catch (SQLiteFullException e) {
+ Log.w(TAG, "Exception on background worker thread", e);
+ } catch (SQLiteDatabaseCorruptException e) {
+ Log.w(TAG, "Exception on background worker thread", e);
+ }
+ }
+ }
+
+ @Override
+ protected Handler createHandler(Looper looper) {
+ // Provide our special handler that catches exceptions
+ return new CatchingWorkerHandler(looper);
+ }
+
+ public CallLogQueryHandler(ContentResolver contentResolver, Listener listener) {
+ super(contentResolver);
+ mListener = new WeakReference<Listener>(listener);
+ }
+
+ /** Creates a cursor that contains a single row and maps the section to the given value. */
+ private Cursor createHeaderCursorFor(int section) {
+ MatrixCursor matrixCursor =
+ new MatrixCursor(CallLogQuery.EXTENDED_PROJECTION);
+ // The values in this row correspond to default values for _PROJECTION from CallLogQuery
+ // plus the section value.
+ matrixCursor.addRow(new Object[]{
+ 0L, "", 0L, 0L, 0, "", "", "", null, 0, null, null, null, null, 0L, null, 0,
+ section
+ });
+ return matrixCursor;
+ }
+
+ /** Returns a cursor for the old calls header. */
+ private Cursor createOldCallsHeaderCursor() {
+ return createHeaderCursorFor(CallLogQuery.SECTION_OLD_HEADER);
+ }
+
+ /** Returns a cursor for the new calls header. */
+ private Cursor createNewCallsHeaderCursor() {
+ return createHeaderCursorFor(CallLogQuery.SECTION_NEW_HEADER);
+ }
+
+ /**
+ * Fetches the list of calls from the call log for a given type.
+ * <p>
+ * It will asynchronously update the content of the list view when the fetch completes.
+ */
+ public void fetchCalls(int callType) {
+ cancelFetch();
+ int requestId = newCallsRequest();
+ fetchCalls(QUERY_NEW_CALLS_TOKEN, requestId, true /*isNew*/, callType);
+ fetchCalls(QUERY_OLD_CALLS_TOKEN, requestId, false /*isNew*/, callType);
+ }
+
+ public void fetchVoicemailStatus() {
+ startQuery(QUERY_VOICEMAIL_STATUS_TOKEN, null, Status.CONTENT_URI,
+ VoicemailStatusHelperImpl.PROJECTION, null, null, null);
+ }
+
+ /** Fetches the list of calls in the call log, either the new one or the old ones. */
+ private void fetchCalls(int token, int requestId, boolean isNew, int callType) {
+ // We need to check for NULL explicitly otherwise entries with where READ is NULL
+ // may not match either the query or its negation.
+ // We consider the calls that are not yet consumed (i.e. IS_READ = 0) as "new".
+ String selection = String.format("%s IS NOT NULL AND %s = 0 AND %s > ?",
+ Calls.IS_READ, Calls.IS_READ, Calls.DATE);
+ List<String> selectionArgs = Lists.newArrayList(
+ Long.toString(System.currentTimeMillis() - NEW_SECTION_TIME_WINDOW));
+ if (!isNew) {
+ // Negate the query.
+ selection = String.format("NOT (%s)", selection);
+ }
+ if (callType > CALL_TYPE_ALL) {
+ // Add a clause to fetch only items of type voicemail.
+ selection = String.format("(%s) AND (%s = ?)", selection, Calls.TYPE);
+ selectionArgs.add(Integer.toString(callType));
+ }
+ Uri uri = Calls.CONTENT_URI_WITH_VOICEMAIL.buildUpon()
+ .appendQueryParameter(Calls.LIMIT_PARAM_KEY, Integer.toString(NUM_LOGS_TO_DISPLAY))
+ .build();
+ startQuery(token, requestId, uri,
+ CallLogQuery._PROJECTION, selection, selectionArgs.toArray(EMPTY_STRING_ARRAY),
+ Calls.DEFAULT_SORT_ORDER);
+ }
+
+ /** Cancel any pending fetch request. */
+ private void cancelFetch() {
+ cancelOperation(QUERY_NEW_CALLS_TOKEN);
+ cancelOperation(QUERY_OLD_CALLS_TOKEN);
+ }
+
+ /** Updates all new calls to mark them as old. */
+ public void markNewCallsAsOld() {
+ // Mark all "new" calls as not new anymore.
+ StringBuilder where = new StringBuilder();
+ where.append(Calls.NEW);
+ where.append(" = 1");
+
+ ContentValues values = new ContentValues(1);
+ values.put(Calls.NEW, "0");
+
+ startUpdate(UPDATE_MARK_AS_OLD_TOKEN, null, Calls.CONTENT_URI_WITH_VOICEMAIL,
+ values, where.toString(), null);
+ }
+
+ /** Updates all new voicemails to mark them as old. */
+ public void markNewVoicemailsAsOld() {
+ // Mark all "new" voicemails as not new anymore.
+ StringBuilder where = new StringBuilder();
+ where.append(Calls.NEW);
+ where.append(" = 1 AND ");
+ where.append(Calls.TYPE);
+ where.append(" = ?");
+
+ ContentValues values = new ContentValues(1);
+ values.put(Calls.NEW, "0");
+
+ startUpdate(UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN, null, Calls.CONTENT_URI_WITH_VOICEMAIL,
+ values, where.toString(), new String[]{ Integer.toString(Calls.VOICEMAIL_TYPE) });
+ }
+
+ /** Updates all missed calls to mark them as read. */
+ public void markMissedCallsAsRead() {
+ // Mark all "new" calls as not new anymore.
+ StringBuilder where = new StringBuilder();
+ where.append(Calls.IS_READ).append(" = 0");
+ where.append(" AND ");
+ where.append(Calls.TYPE).append(" = ").append(Calls.MISSED_TYPE);
+
+ ContentValues values = new ContentValues(1);
+ values.put(Calls.IS_READ, "1");
+
+ startUpdate(UPDATE_MARK_MISSED_CALL_AS_READ_TOKEN, null, Calls.CONTENT_URI, values,
+ where.toString(), null);
+ }
+
+ /**
+ * Start a new request and return its id. The request id will be used as the cookie for the
+ * background request.
+ * <p>
+ * Closes any open cursor that has not yet been sent to the requester.
+ */
+ private synchronized int newCallsRequest() {
+ MoreCloseables.closeQuietly(mNewCallsCursor);
+ MoreCloseables.closeQuietly(mOldCallsCursor);
+ mNewCallsCursor = null;
+ mOldCallsCursor = null;
+ return ++mCallsRequestId;
+ }
+
+ @Override
+ protected synchronized void onQueryComplete(int token, Object cookie, Cursor cursor) {
+ if (token == QUERY_NEW_CALLS_TOKEN) {
+ int requestId = ((Integer) cookie).intValue();
+ if (requestId != mCallsRequestId) {
+ // Ignore this query since it does not correspond to the latest request.
+ return;
+ }
+
+ // Store the returned cursor.
+ MoreCloseables.closeQuietly(mNewCallsCursor);
+ mNewCallsCursor = new ExtendedCursor(
+ cursor, CallLogQuery.SECTION_NAME, CallLogQuery.SECTION_NEW_ITEM);
+ } else if (token == QUERY_OLD_CALLS_TOKEN) {
+ int requestId = ((Integer) cookie).intValue();
+ if (requestId != mCallsRequestId) {
+ // Ignore this query since it does not correspond to the latest request.
+ return;
+ }
+
+ // Store the returned cursor.
+ MoreCloseables.closeQuietly(mOldCallsCursor);
+ mOldCallsCursor = new ExtendedCursor(
+ cursor, CallLogQuery.SECTION_NAME, CallLogQuery.SECTION_OLD_ITEM);
+ } else if (token == QUERY_VOICEMAIL_STATUS_TOKEN) {
+ updateVoicemailStatus(cursor);
+ return;
+ } else {
+ Log.w(TAG, "Unknown query completed: ignoring: " + token);
+ return;
+ }
+
+ if (mNewCallsCursor != null && mOldCallsCursor != null) {
+ updateAdapterData(createMergedCursor());
+ }
+ }
+
+ /** Creates the merged cursor representing the data to show in the call log. */
+ @GuardedBy("this")
+ private Cursor createMergedCursor() {
+ try {
+ final boolean hasNewCalls = mNewCallsCursor.getCount() != 0;
+ final boolean hasOldCalls = mOldCallsCursor.getCount() != 0;
+
+ if (!hasNewCalls) {
+ // Return only the old calls, without the header.
+ MoreCloseables.closeQuietly(mNewCallsCursor);
+ return mOldCallsCursor;
+ }
+
+ if (!hasOldCalls) {
+ // Return only the new calls.
+ MoreCloseables.closeQuietly(mOldCallsCursor);
+ return new MergeCursor(
+ new Cursor[]{ createNewCallsHeaderCursor(), mNewCallsCursor });
+ }
+
+ return new MergeCursor(new Cursor[]{
+ createNewCallsHeaderCursor(), mNewCallsCursor,
+ createOldCallsHeaderCursor(), mOldCallsCursor});
+ } finally {
+ // Any cursor still open is now owned, directly or indirectly, by the caller.
+ mNewCallsCursor = null;
+ mOldCallsCursor = null;
+ }
+ }
+
+ /**
+ * Updates the adapter in the call log fragment to show the new cursor data.
+ */
+ private void updateAdapterData(Cursor combinedCursor) {
+ final Listener listener = mListener.get();
+ if (listener != null) {
+ listener.onCallsFetched(combinedCursor);
+ }
+ }
+
+ private void updateVoicemailStatus(Cursor statusCursor) {
+ final Listener listener = mListener.get();
+ if (listener != null) {
+ listener.onVoicemailStatusFetched(statusCursor);
+ }
+ }
+
+ /** Listener to completion of various queries. */
+ public interface Listener {
+ /** Called when {@link CallLogQueryHandler#fetchVoicemailStatus()} completes. */
+ void onVoicemailStatusFetched(Cursor statusCursor);
+
+ /**
+ * Called when {@link CallLogQueryHandler#fetchCalls(int)}complete.
+ */
+ void onCallsFetched(Cursor combinedCursor);
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallLogReceiver.java b/src/com/android/dialer/calllog/CallLogReceiver.java
new file mode 100644
index 000000000..97d2951c1
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallLogReceiver.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.provider.VoicemailContract;
+import android.util.Log;
+
+/**
+ * Receiver for call log events.
+ * <p>
+ * It is currently used to handle {@link VoicemailContract#ACTION_NEW_VOICEMAIL} and
+ * {@link Intent#ACTION_BOOT_COMPLETED}.
+ */
+public class CallLogReceiver extends BroadcastReceiver {
+ private static final String TAG = "CallLogReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (VoicemailContract.ACTION_NEW_VOICEMAIL.equals(intent.getAction())) {
+ Intent serviceIntent = new Intent(context, CallLogNotificationsService.class);
+ serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS);
+ serviceIntent.putExtra(
+ CallLogNotificationsService.EXTRA_NEW_VOICEMAIL_URI, intent.getData());
+ context.startService(serviceIntent);
+ } else if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
+ Intent serviceIntent = new Intent(context, CallLogNotificationsService.class);
+ serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS);
+ context.startService(serviceIntent);
+ } else {
+ Log.w(TAG, "onReceive: could not handle: " + intent);
+ }
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallTypeHelper.java b/src/com/android/dialer/calllog/CallTypeHelper.java
new file mode 100644
index 000000000..255258e01
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallTypeHelper.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.res.Resources;
+import android.provider.CallLog.Calls;
+
+import com.android.contacts.R;
+
+/**
+ * Helper class to perform operations related to call types.
+ */
+public class CallTypeHelper {
+ /** Name used to identify incoming calls. */
+ private final CharSequence mIncomingName;
+ /** Name used to identify outgoing calls. */
+ private final CharSequence mOutgoingName;
+ /** Name used to identify missed calls. */
+ private final CharSequence mMissedName;
+ /** Name used to identify voicemail calls. */
+ private final CharSequence mVoicemailName;
+ /** Color used to identify new missed calls. */
+ private final int mNewMissedColor;
+ /** Color used to identify new voicemail calls. */
+ private final int mNewVoicemailColor;
+
+ public CallTypeHelper(Resources resources) {
+ // Cache these values so that we do not need to look them up each time.
+ mIncomingName = resources.getString(R.string.type_incoming);
+ mOutgoingName = resources.getString(R.string.type_outgoing);
+ mMissedName = resources.getString(R.string.type_missed);
+ mVoicemailName = resources.getString(R.string.type_voicemail);
+ mNewMissedColor = resources.getColor(R.color.call_log_missed_call_highlight_color);
+ mNewVoicemailColor = resources.getColor(R.color.call_log_voicemail_highlight_color);
+ }
+
+ /** Returns the text used to represent the given call type. */
+ public CharSequence getCallTypeText(int callType) {
+ switch (callType) {
+ case Calls.INCOMING_TYPE:
+ return mIncomingName;
+
+ case Calls.OUTGOING_TYPE:
+ return mOutgoingName;
+
+ case Calls.MISSED_TYPE:
+ return mMissedName;
+
+ case Calls.VOICEMAIL_TYPE:
+ return mVoicemailName;
+
+ default:
+ throw new IllegalArgumentException("invalid call type: " + callType);
+ }
+ }
+
+ /** Returns the color used to highlight the given call type, null if not highlight is needed. */
+ public Integer getHighlightedColor(int callType) {
+ switch (callType) {
+ case Calls.INCOMING_TYPE:
+ // New incoming calls are not highlighted.
+ return null;
+
+ case Calls.OUTGOING_TYPE:
+ // New outgoing calls are not highlighted.
+ return null;
+
+ case Calls.MISSED_TYPE:
+ return mNewMissedColor;
+
+ case Calls.VOICEMAIL_TYPE:
+ return mNewVoicemailColor;
+
+ default:
+ throw new IllegalArgumentException("invalid call type: " + callType);
+ }
+ }
+}
diff --git a/src/com/android/dialer/calllog/CallTypeIconsView.java b/src/com/android/dialer/calllog/CallTypeIconsView.java
new file mode 100644
index 000000000..e26d5a1bb
--- /dev/null
+++ b/src/com/android/dialer/calllog/CallTypeIconsView.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.provider.CallLog.Calls;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.android.contacts.R;
+import com.android.contacts.test.NeededForTesting;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+/**
+ * View that draws one or more symbols for different types of calls (missed calls, outgoing etc).
+ * The symbols are set up horizontally. As this view doesn't create subviews, it is better suited
+ * for ListView-recycling that a regular LinearLayout using ImageViews.
+ */
+public class CallTypeIconsView extends View {
+ private List<Integer> mCallTypes = Lists.newArrayListWithCapacity(3);
+ private Resources mResources;
+ private int mWidth;
+ private int mHeight;
+
+ public CallTypeIconsView(Context context) {
+ this(context, null);
+ }
+
+ public CallTypeIconsView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mResources = new Resources(context);
+ }
+
+ public void clear() {
+ mCallTypes.clear();
+ mWidth = 0;
+ mHeight = 0;
+ invalidate();
+ }
+
+ public void add(int callType) {
+ mCallTypes.add(callType);
+
+ final Drawable drawable = getCallTypeDrawable(callType);
+ mWidth += drawable.getIntrinsicWidth() + mResources.iconMargin;
+ mHeight = Math.max(mHeight, drawable.getIntrinsicHeight());
+ invalidate();
+ }
+
+ @NeededForTesting
+ public int getCount() {
+ return mCallTypes.size();
+ }
+
+ @NeededForTesting
+ public int getCallType(int index) {
+ return mCallTypes.get(index);
+ }
+
+ private Drawable getCallTypeDrawable(int callType) {
+ switch (callType) {
+ case Calls.INCOMING_TYPE:
+ return mResources.incoming;
+ case Calls.OUTGOING_TYPE:
+ return mResources.outgoing;
+ case Calls.MISSED_TYPE:
+ return mResources.missed;
+ case Calls.VOICEMAIL_TYPE:
+ return mResources.voicemail;
+ default:
+ throw new IllegalArgumentException("invalid call type: " + callType);
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ setMeasuredDimension(mWidth, mHeight);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int left = 0;
+ for (Integer callType : mCallTypes) {
+ final Drawable drawable = getCallTypeDrawable(callType);
+ final int right = left + drawable.getIntrinsicWidth();
+ drawable.setBounds(left, 0, right, drawable.getIntrinsicHeight());
+ drawable.draw(canvas);
+ left = right + mResources.iconMargin;
+ }
+ }
+
+ private static class Resources {
+ public final Drawable incoming;
+ public final Drawable outgoing;
+ public final Drawable missed;
+ public final Drawable voicemail;
+ public final int iconMargin;
+
+ public Resources(Context context) {
+ final android.content.res.Resources r = context.getResources();
+ incoming = r.getDrawable(R.drawable.ic_call_incoming_holo_dark);
+ outgoing = r.getDrawable(R.drawable.ic_call_outgoing_holo_dark);
+ missed = r.getDrawable(R.drawable.ic_call_missed_holo_dark);
+ voicemail = r.getDrawable(R.drawable.ic_call_voicemail_holo_dark);
+ iconMargin = r.getDimensionPixelSize(R.dimen.call_log_icon_margin);
+ }
+ }
+}
diff --git a/src/com/android/dialer/calllog/ClearCallLogDialog.java b/src/com/android/dialer/calllog/ClearCallLogDialog.java
new file mode 100644
index 000000000..e91c08fa8
--- /dev/null
+++ b/src/com/android/dialer/calllog/ClearCallLogDialog.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.FragmentManager;
+import android.app.ProgressDialog;
+import android.content.ContentResolver;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.provider.CallLog.Calls;
+
+import com.android.contacts.R;
+
+/**
+ * Dialog that clears the call log after confirming with the user
+ */
+public class ClearCallLogDialog extends DialogFragment {
+ /** Preferred way to show this dialog */
+ public static void show(FragmentManager fragmentManager) {
+ ClearCallLogDialog dialog = new ClearCallLogDialog();
+ dialog.show(fragmentManager, "deleteCallLog");
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final ContentResolver resolver = getActivity().getContentResolver();
+ final OnClickListener okListener = new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ final ProgressDialog progressDialog = ProgressDialog.show(getActivity(),
+ getString(R.string.clearCallLogProgress_title),
+ "", true, false);
+ final AsyncTask<Void, Void, Void> task = new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ resolver.delete(Calls.CONTENT_URI, null, null);
+ return null;
+ }
+ @Override
+ protected void onPostExecute(Void result) {
+ progressDialog.dismiss();
+ }
+ };
+ // TODO: Once we have the API, we should configure this ProgressDialog
+ // to only show up after a certain time (e.g. 150ms)
+ progressDialog.show();
+ task.execute();
+ }
+ };
+ return new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.clearCallLogConfirmation_title)
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setMessage(R.string.clearCallLogConfirmation)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok, okListener)
+ .setCancelable(true)
+ .create();
+ }
+}
diff --git a/src/com/android/dialer/calllog/ContactInfo.java b/src/com/android/dialer/calllog/ContactInfo.java
new file mode 100644
index 000000000..b48adef0f
--- /dev/null
+++ b/src/com/android/dialer/calllog/ContactInfo.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.contacts.util.UriUtils;
+
+/**
+ * Information for a contact as needed by the Call Log.
+ */
+public final class ContactInfo {
+ public Uri lookupUri;
+ public String name;
+ public int type;
+ public String label;
+ public String number;
+ public String formattedNumber;
+ public String normalizedNumber;
+ /** The photo for the contact, if available. */
+ public long photoId;
+ /** The high-res photo for the contact, if available. */
+ public Uri photoUri;
+
+ public static ContactInfo EMPTY = new ContactInfo();
+
+ @Override
+ public int hashCode() {
+ // Uses only name and contactUri to determine hashcode.
+ // This should be sufficient to have a reasonable distribution of hash codes.
+ // Moreover, there should be no two people with the same lookupUri.
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((lookupUri == null) ? 0 : lookupUri.hashCode());
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null) return false;
+ if (getClass() != obj.getClass()) return false;
+ ContactInfo other = (ContactInfo) obj;
+ if (!UriUtils.areEqual(lookupUri, other.lookupUri)) return false;
+ if (!TextUtils.equals(name, other.name)) return false;
+ if (type != other.type) return false;
+ if (!TextUtils.equals(label, other.label)) return false;
+ if (!TextUtils.equals(number, other.number)) return false;
+ if (!TextUtils.equals(formattedNumber, other.formattedNumber)) return false;
+ if (!TextUtils.equals(normalizedNumber, other.normalizedNumber)) return false;
+ if (photoId != other.photoId) return false;
+ if (!UriUtils.areEqual(photoUri, other.photoUri)) return false;
+ return true;
+ }
+}
diff --git a/src/com/android/dialer/calllog/ContactInfoHelper.java b/src/com/android/dialer/calllog/ContactInfoHelper.java
new file mode 100644
index 000000000..b6f0662f2
--- /dev/null
+++ b/src/com/android/dialer/calllog/ContactInfoHelper.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.PhoneLookup;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+
+import com.android.contacts.util.UriUtils;
+
+/**
+ * Utility class to look up the contact information for a given number.
+ */
+public class ContactInfoHelper {
+ private final Context mContext;
+ private final String mCurrentCountryIso;
+
+ public ContactInfoHelper(Context context, String currentCountryIso) {
+ mContext = context;
+ mCurrentCountryIso = currentCountryIso;
+ }
+
+ /**
+ * Returns the contact information for the given number.
+ * <p>
+ * If the number does not match any contact, returns a contact info containing only the number
+ * and the formatted number.
+ * <p>
+ * If an error occurs during the lookup, it returns null.
+ *
+ * @param number the number to look up
+ * @param countryIso the country associated with this number
+ */
+ public ContactInfo lookupNumber(String number, String countryIso) {
+ final ContactInfo info;
+
+ // Determine the contact info.
+ if (PhoneNumberUtils.isUriNumber(number)) {
+ // This "number" is really a SIP address.
+ ContactInfo sipInfo = queryContactInfoForSipAddress(number);
+ if (sipInfo == null || sipInfo == ContactInfo.EMPTY) {
+ // Check whether the "username" part of the SIP address is
+ // actually the phone number of a contact.
+ String username = PhoneNumberUtils.getUsernameFromUriNumber(number);
+ if (PhoneNumberUtils.isGlobalPhoneNumber(username)) {
+ sipInfo = queryContactInfoForPhoneNumber(username, countryIso);
+ }
+ }
+ info = sipInfo;
+ } else {
+ // Look for a contact that has the given phone number.
+ ContactInfo phoneInfo = queryContactInfoForPhoneNumber(number, countryIso);
+
+ if (phoneInfo == null || phoneInfo == ContactInfo.EMPTY) {
+ // Check whether the phone number has been saved as an "Internet call" number.
+ phoneInfo = queryContactInfoForSipAddress(number);
+ }
+ info = phoneInfo;
+ }
+
+ final ContactInfo updatedInfo;
+ if (info == null) {
+ // The lookup failed.
+ updatedInfo = null;
+ } else {
+ // If we did not find a matching contact, generate an empty contact info for the number.
+ if (info == ContactInfo.EMPTY) {
+ // Did not find a matching contact.
+ updatedInfo = new ContactInfo();
+ updatedInfo.number = number;
+ updatedInfo.formattedNumber = formatPhoneNumber(number, null, countryIso);
+ } else {
+ updatedInfo = info;
+ }
+ }
+ return updatedInfo;
+ }
+
+ /**
+ * Looks up a contact using the given URI.
+ * <p>
+ * It returns null if an error occurs, {@link ContactInfo#EMPTY} if no matching contact is
+ * found, or the {@link ContactInfo} for the given contact.
+ * <p>
+ * The {@link ContactInfo#formattedNumber} field is always set to {@code null} in the returned
+ * value.
+ */
+ private ContactInfo lookupContactFromUri(Uri uri) {
+ final ContactInfo info;
+ Cursor phonesCursor =
+ mContext.getContentResolver().query(
+ uri, PhoneQuery._PROJECTION, null, null, null);
+
+ if (phonesCursor != null) {
+ try {
+ if (phonesCursor.moveToFirst()) {
+ info = new ContactInfo();
+ long contactId = phonesCursor.getLong(PhoneQuery.PERSON_ID);
+ String lookupKey = phonesCursor.getString(PhoneQuery.LOOKUP_KEY);
+ info.lookupUri = Contacts.getLookupUri(contactId, lookupKey);
+ info.name = phonesCursor.getString(PhoneQuery.NAME);
+ info.type = phonesCursor.getInt(PhoneQuery.PHONE_TYPE);
+ info.label = phonesCursor.getString(PhoneQuery.LABEL);
+ info.number = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER);
+ info.normalizedNumber = phonesCursor.getString(PhoneQuery.NORMALIZED_NUMBER);
+ info.photoId = phonesCursor.getLong(PhoneQuery.PHOTO_ID);
+ info.photoUri =
+ UriUtils.parseUriOrNull(phonesCursor.getString(PhoneQuery.PHOTO_URI));
+ info.formattedNumber = null;
+ } else {
+ info = ContactInfo.EMPTY;
+ }
+ } finally {
+ phonesCursor.close();
+ }
+ } else {
+ // Failed to fetch the data, ignore this request.
+ info = null;
+ }
+ return info;
+ }
+
+ /**
+ * Determines the contact information for the given SIP address.
+ * <p>
+ * It returns the contact info if found.
+ * <p>
+ * If no contact corresponds to the given SIP address, returns {@link ContactInfo#EMPTY}.
+ * <p>
+ * If the lookup fails for some other reason, it returns null.
+ */
+ private ContactInfo queryContactInfoForSipAddress(String sipAddress) {
+ final ContactInfo info;
+
+ // "contactNumber" is a SIP address, so use the PhoneLookup table with the SIP parameter.
+ Uri.Builder uriBuilder = PhoneLookup.CONTENT_FILTER_URI.buildUpon();
+ uriBuilder.appendPath(Uri.encode(sipAddress));
+ uriBuilder.appendQueryParameter(PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, "1");
+ return lookupContactFromUri(uriBuilder.build());
+ }
+
+ /**
+ * Determines the contact information for the given phone number.
+ * <p>
+ * It returns the contact info if found.
+ * <p>
+ * If no contact corresponds to the given phone number, returns {@link ContactInfo#EMPTY}.
+ * <p>
+ * If the lookup fails for some other reason, it returns null.
+ */
+ private ContactInfo queryContactInfoForPhoneNumber(String number, String countryIso) {
+ String contactNumber = number;
+ if (!TextUtils.isEmpty(countryIso)) {
+ // Normalize the number: this is needed because the PhoneLookup query below does not
+ // accept a country code as an input.
+ String numberE164 = PhoneNumberUtils.formatNumberToE164(number, countryIso);
+ if (!TextUtils.isEmpty(numberE164)) {
+ // Only use it if the number could be formatted to E164.
+ contactNumber = numberE164;
+ }
+ }
+
+ // The "contactNumber" is a regular phone number, so use the PhoneLookup table.
+ Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(contactNumber));
+ ContactInfo info = lookupContactFromUri(uri);
+ if (info != null && info != ContactInfo.EMPTY) {
+ info.formattedNumber = formatPhoneNumber(number, null, countryIso);
+ }
+ return info;
+ }
+
+ /**
+ * Format the given phone number
+ *
+ * @param number the number to be formatted.
+ * @param normalizedNumber the normalized number of the given number.
+ * @param countryIso the ISO 3166-1 two letters country code, the country's
+ * convention will be used to format the number if the normalized
+ * phone is null.
+ *
+ * @return the formatted number, or the given number if it was formatted.
+ */
+ private String formatPhoneNumber(String number, String normalizedNumber,
+ String countryIso) {
+ if (TextUtils.isEmpty(number)) {
+ return "";
+ }
+ // If "number" is really a SIP address, don't try to do any formatting at all.
+ if (PhoneNumberUtils.isUriNumber(number)) {
+ return number;
+ }
+ if (TextUtils.isEmpty(countryIso)) {
+ countryIso = mCurrentCountryIso;
+ }
+ return PhoneNumberUtils.formatNumber(number, normalizedNumber, countryIso);
+ }
+}
diff --git a/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java b/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java
new file mode 100644
index 000000000..0f6fe3b08
--- /dev/null
+++ b/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.PhoneLookup;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.common.io.MoreCloseables;
+import com.android.dialer.CallDetailActivity;
+import com.android.contacts.R;
+import com.google.common.collect.Maps;
+
+import java.util.Map;
+
+/**
+ * Implementation of {@link VoicemailNotifier} that shows a notification in the
+ * status bar.
+ */
+public class DefaultVoicemailNotifier implements VoicemailNotifier {
+ public static final String TAG = "DefaultVoicemailNotifier";
+
+ /** The tag used to identify notifications from this class. */
+ private static final String NOTIFICATION_TAG = "DefaultVoicemailNotifier";
+ /** The identifier of the notification of new voicemails. */
+ private static final int NOTIFICATION_ID = 1;
+
+ /** The singleton instance of {@link DefaultVoicemailNotifier}. */
+ private static DefaultVoicemailNotifier sInstance;
+
+ private final Context mContext;
+ private final NotificationManager mNotificationManager;
+ private final NewCallsQuery mNewCallsQuery;
+ private final NameLookupQuery mNameLookupQuery;
+ private final PhoneNumberHelper mPhoneNumberHelper;
+
+ /** Returns the singleton instance of the {@link DefaultVoicemailNotifier}. */
+ public static synchronized DefaultVoicemailNotifier getInstance(Context context) {
+ if (sInstance == null) {
+ NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ ContentResolver contentResolver = context.getContentResolver();
+ sInstance = new DefaultVoicemailNotifier(context, notificationManager,
+ createNewCallsQuery(contentResolver),
+ createNameLookupQuery(contentResolver),
+ createPhoneNumberHelper(context));
+ }
+ return sInstance;
+ }
+
+ private DefaultVoicemailNotifier(Context context,
+ NotificationManager notificationManager, NewCallsQuery newCallsQuery,
+ NameLookupQuery nameLookupQuery, PhoneNumberHelper phoneNumberHelper) {
+ mContext = context;
+ mNotificationManager = notificationManager;
+ mNewCallsQuery = newCallsQuery;
+ mNameLookupQuery = nameLookupQuery;
+ mPhoneNumberHelper = phoneNumberHelper;
+ }
+
+ /** Updates the notification and notifies of the call with the given URI. */
+ @Override
+ public void updateNotification(Uri newCallUri) {
+ // Lookup the list of new voicemails to include in the notification.
+ // TODO: Move this into a service, to avoid holding the receiver up.
+ final NewCall[] newCalls = mNewCallsQuery.query();
+
+ if (newCalls == null) {
+ // Query failed, just return.
+ return;
+ }
+
+ if (newCalls.length == 0) {
+ // No voicemails to notify about: clear the notification.
+ clearNotification();
+ return;
+ }
+
+ Resources resources = mContext.getResources();
+
+ // This represents a list of names to include in the notification.
+ String callers = null;
+
+ // Maps each number into a name: if a number is in the map, it has already left a more
+ // recent voicemail.
+ final Map<String, String> names = Maps.newHashMap();
+
+ // Determine the call corresponding to the new voicemail we have to notify about.
+ NewCall callToNotify = null;
+
+ // Iterate over the new voicemails to determine all the information above.
+ for (NewCall newCall : newCalls) {
+ // Check if we already know the name associated with this number.
+ String name = names.get(newCall.number);
+ if (name == null) {
+ // Look it up in the database.
+ name = mNameLookupQuery.query(newCall.number);
+ // If we cannot lookup the contact, use the number instead.
+ if (name == null) {
+ name = mPhoneNumberHelper.getDisplayNumber(newCall.number, "").toString();
+ if (TextUtils.isEmpty(name)) {
+ name = newCall.number;
+ }
+ }
+ names.put(newCall.number, name);
+ // This is a new caller. Add it to the back of the list of callers.
+ if (TextUtils.isEmpty(callers)) {
+ callers = name;
+ } else {
+ callers = resources.getString(
+ R.string.notification_voicemail_callers_list, callers, name);
+ }
+ }
+ // Check if this is the new call we need to notify about.
+ if (newCallUri != null && newCallUri.equals(newCall.voicemailUri)) {
+ callToNotify = newCall;
+ }
+ }
+
+ if (newCallUri != null && callToNotify == null) {
+ Log.e(TAG, "The new call could not be found in the call log: " + newCallUri);
+ }
+
+ // Determine the title of the notification and the icon for it.
+ final String title = resources.getQuantityString(
+ R.plurals.notification_voicemail_title, newCalls.length, newCalls.length);
+ // TODO: Use the photo of contact if all calls are from the same person.
+ final int icon = android.R.drawable.stat_notify_voicemail;
+
+ Notification.Builder notificationBuilder = new Notification.Builder(mContext)
+ .setSmallIcon(icon)
+ .setContentTitle(title)
+ .setContentText(callers)
+ .setDefaults(callToNotify != null ? Notification.DEFAULT_ALL : 0)
+ .setDeleteIntent(createMarkNewVoicemailsAsOldIntent())
+ .setAutoCancel(true);
+
+ // Determine the intent to fire when the notification is clicked on.
+ final Intent contentIntent;
+ if (newCalls.length == 1) {
+ // Open the voicemail directly.
+ contentIntent = new Intent(mContext, CallDetailActivity.class);
+ contentIntent.setData(newCalls[0].callsUri);
+ contentIntent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
+ newCalls[0].voicemailUri);
+ Intent playIntent = new Intent(mContext, CallDetailActivity.class);
+ playIntent.setData(newCalls[0].callsUri);
+ playIntent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
+ newCalls[0].voicemailUri);
+ playIntent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, true);
+ playIntent.putExtra(CallDetailActivity.EXTRA_FROM_NOTIFICATION, true);
+ notificationBuilder.addAction(R.drawable.ic_play_holo_dark,
+ resources.getString(R.string.notification_action_voicemail_play),
+ PendingIntent.getActivity(mContext, 0, playIntent, 0));
+ } else {
+ // Open the call log.
+ contentIntent = new Intent(Intent.ACTION_VIEW, Calls.CONTENT_URI);
+ }
+ notificationBuilder.setContentIntent(
+ PendingIntent.getActivity(mContext, 0, contentIntent, 0));
+
+ // The text to show in the ticker, describing the new event.
+ if (callToNotify != null) {
+ notificationBuilder.setTicker(resources.getString(
+ R.string.notification_new_voicemail_ticker, names.get(callToNotify.number)));
+ }
+
+ mNotificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notificationBuilder.build());
+ }
+
+ /** Creates a pending intent that marks all new voicemails as old. */
+ private PendingIntent createMarkNewVoicemailsAsOldIntent() {
+ Intent intent = new Intent(mContext, CallLogNotificationsService.class);
+ intent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_VOICEMAILS_AS_OLD);
+ return PendingIntent.getService(mContext, 0, intent, 0);
+ }
+
+ @Override
+ public void clearNotification() {
+ mNotificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID);
+ }
+
+ /** Information about a new voicemail. */
+ private static final class NewCall {
+ public final Uri callsUri;
+ public final Uri voicemailUri;
+ public final String number;
+
+ public NewCall(Uri callsUri, Uri voicemailUri, String number) {
+ this.callsUri = callsUri;
+ this.voicemailUri = voicemailUri;
+ this.number = number;
+ }
+ }
+
+ /** Allows determining the new calls for which a notification should be generated. */
+ public interface NewCallsQuery {
+ /**
+ * Returns the new calls for which a notification should be generated.
+ */
+ public NewCall[] query();
+ }
+
+ /** Create a new instance of {@link NewCallsQuery}. */
+ public static NewCallsQuery createNewCallsQuery(ContentResolver contentResolver) {
+ return new DefaultNewCallsQuery(contentResolver);
+ }
+
+ /**
+ * Default implementation of {@link NewCallsQuery} that looks up the list of new calls to
+ * notify about in the call log.
+ */
+ private static final class DefaultNewCallsQuery implements NewCallsQuery {
+ private static final String[] PROJECTION = {
+ Calls._ID, Calls.NUMBER, Calls.VOICEMAIL_URI
+ };
+ private static final int ID_COLUMN_INDEX = 0;
+ private static final int NUMBER_COLUMN_INDEX = 1;
+ private static final int VOICEMAIL_URI_COLUMN_INDEX = 2;
+
+ private final ContentResolver mContentResolver;
+
+ private DefaultNewCallsQuery(ContentResolver contentResolver) {
+ mContentResolver = contentResolver;
+ }
+
+ @Override
+ public NewCall[] query() {
+ final String selection = String.format("%s = 1 AND %s = ?", Calls.NEW, Calls.TYPE);
+ final String[] selectionArgs = new String[]{ Integer.toString(Calls.VOICEMAIL_TYPE) };
+ Cursor cursor = null;
+ try {
+ cursor = mContentResolver.query(Calls.CONTENT_URI_WITH_VOICEMAIL, PROJECTION,
+ selection, selectionArgs, Calls.DEFAULT_SORT_ORDER);
+ if (cursor == null) {
+ return null;
+ }
+ NewCall[] newCalls = new NewCall[cursor.getCount()];
+ while (cursor.moveToNext()) {
+ newCalls[cursor.getPosition()] = createNewCallsFromCursor(cursor);
+ }
+ return newCalls;
+ } finally {
+ MoreCloseables.closeQuietly(cursor);
+ }
+ }
+
+ /** Returns an instance of {@link NewCall} created by using the values of the cursor. */
+ private NewCall createNewCallsFromCursor(Cursor cursor) {
+ String voicemailUriString = cursor.getString(VOICEMAIL_URI_COLUMN_INDEX);
+ Uri callsUri = ContentUris.withAppendedId(
+ Calls.CONTENT_URI_WITH_VOICEMAIL, cursor.getLong(ID_COLUMN_INDEX));
+ Uri voicemailUri = voicemailUriString == null ? null : Uri.parse(voicemailUriString);
+ return new NewCall(callsUri, voicemailUri, cursor.getString(NUMBER_COLUMN_INDEX));
+ }
+ }
+
+ /** Allows determining the name associated with a given phone number. */
+ public interface NameLookupQuery {
+ /**
+ * Returns the name associated with the given number in the contacts database, or null if
+ * the number does not correspond to any of the contacts.
+ * <p>
+ * If there are multiple contacts with the same phone number, it will return the name of one
+ * of the matching contacts.
+ */
+ public String query(String number);
+ }
+
+ /** Create a new instance of {@link NameLookupQuery}. */
+ public static NameLookupQuery createNameLookupQuery(ContentResolver contentResolver) {
+ return new DefaultNameLookupQuery(contentResolver);
+ }
+
+ /**
+ * Default implementation of {@link NameLookupQuery} that looks up the name of a contact in the
+ * contacts database.
+ */
+ private static final class DefaultNameLookupQuery implements NameLookupQuery {
+ private static final String[] PROJECTION = { PhoneLookup.DISPLAY_NAME };
+ private static final int DISPLAY_NAME_COLUMN_INDEX = 0;
+
+ private final ContentResolver mContentResolver;
+
+ private DefaultNameLookupQuery(ContentResolver contentResolver) {
+ mContentResolver = contentResolver;
+ }
+
+ @Override
+ public String query(String number) {
+ Cursor cursor = null;
+ try {
+ cursor = mContentResolver.query(
+ Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)),
+ PROJECTION, null, null, null);
+ if (cursor == null || !cursor.moveToFirst()) return null;
+ return cursor.getString(DISPLAY_NAME_COLUMN_INDEX);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Create a new PhoneNumberHelper.
+ * <p>
+ * This will cause some Disk I/O, at least the first time it is created, so it should not be
+ * called from the main thread.
+ */
+ public static PhoneNumberHelper createPhoneNumberHelper(Context context) {
+ return new PhoneNumberHelper(context.getResources());
+ }
+}
diff --git a/src/com/android/dialer/calllog/ExtendedCursor.java b/src/com/android/dialer/calllog/ExtendedCursor.java
new file mode 100644
index 000000000..3e55aabe8
--- /dev/null
+++ b/src/com/android/dialer/calllog/ExtendedCursor.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.database.AbstractCursor;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.DataSetObserver;
+
+import com.android.common.io.MoreCloseables;
+
+/**
+ * Wraps a cursor to add an additional column with the same value for all rows.
+ * <p>
+ * The number of rows in the cursor and the set of columns is determined by the cursor being
+ * wrapped.
+ */
+public class ExtendedCursor extends AbstractCursor {
+ /** The cursor to wrap. */
+ private final Cursor mCursor;
+ /** The name of the additional column. */
+ private final String mColumnName;
+ /** The value to be assigned to the additional column. */
+ private final Object mValue;
+
+ /**
+ * Creates a new cursor which extends the given cursor by adding a column with a constant value.
+ *
+ * @param cursor the cursor to extend
+ * @param columnName the name of the additional column
+ * @param value the value to be assigned to the additional column
+ */
+ public ExtendedCursor(Cursor cursor, String columnName, Object value) {
+ mCursor = cursor;
+ mColumnName = columnName;
+ mValue = value;
+ }
+
+ @Override
+ public int getCount() {
+ return mCursor.getCount();
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ String[] columnNames = mCursor.getColumnNames();
+ int length = columnNames.length;
+ String[] extendedColumnNames = new String[length + 1];
+ System.arraycopy(columnNames, 0, extendedColumnNames, 0, length);
+ extendedColumnNames[length] = mColumnName;
+ return extendedColumnNames;
+ }
+
+ @Override
+ public String getString(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return (String) mValue;
+ }
+ return mCursor.getString(column);
+ }
+
+ @Override
+ public short getShort(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return (Short) mValue;
+ }
+ return mCursor.getShort(column);
+ }
+
+ @Override
+ public int getInt(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return (Integer) mValue;
+ }
+ return mCursor.getInt(column);
+ }
+
+ @Override
+ public long getLong(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return (Long) mValue;
+ }
+ return mCursor.getLong(column);
+ }
+
+ @Override
+ public float getFloat(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return (Float) mValue;
+ }
+ return mCursor.getFloat(column);
+ }
+
+ @Override
+ public double getDouble(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return (Double) mValue;
+ }
+ return mCursor.getDouble(column);
+ }
+
+ @Override
+ public boolean isNull(int column) {
+ if (column == mCursor.getColumnCount()) {
+ return mValue == null;
+ }
+ return mCursor.isNull(column);
+ }
+
+ @Override
+ public boolean onMove(int oldPosition, int newPosition) {
+ return mCursor.moveToPosition(newPosition);
+ }
+
+ @Override
+ public void close() {
+ MoreCloseables.closeQuietly(mCursor);
+ super.close();
+ }
+
+ @Override
+ public void registerContentObserver(ContentObserver observer) {
+ mCursor.registerContentObserver(observer);
+ }
+
+ @Override
+ public void unregisterContentObserver(ContentObserver observer) {
+ mCursor.unregisterContentObserver(observer);
+ }
+
+ @Override
+ public void registerDataSetObserver(DataSetObserver observer) {
+ mCursor.registerDataSetObserver(observer);
+ }
+
+ @Override
+ public void unregisterDataSetObserver(DataSetObserver observer) {
+ mCursor.unregisterDataSetObserver(observer);
+ }
+}
diff --git a/src/com/android/dialer/calllog/IntentProvider.java b/src/com/android/dialer/calllog/IntentProvider.java
new file mode 100644
index 000000000..f43dc5104
--- /dev/null
+++ b/src/com/android/dialer/calllog/IntentProvider.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.CallLog.Calls;
+
+import com.android.dialer.CallDetailActivity;
+import com.android.contacts.ContactsUtils;
+
+/**
+ * Used to create an intent to attach to an action in the call log.
+ * <p>
+ * The intent is constructed lazily with the given information.
+ */
+public abstract class IntentProvider {
+ public abstract Intent getIntent(Context context);
+
+ public static IntentProvider getReturnCallIntentProvider(final String number) {
+ return new IntentProvider() {
+ @Override
+ public Intent getIntent(Context context) {
+ return ContactsUtils.getCallIntent(number);
+ }
+ };
+ }
+
+ public static IntentProvider getPlayVoicemailIntentProvider(final long rowId,
+ final String voicemailUri) {
+ return new IntentProvider() {
+ @Override
+ public Intent getIntent(Context context) {
+ Intent intent = new Intent(context, CallDetailActivity.class);
+ intent.setData(ContentUris.withAppendedId(
+ Calls.CONTENT_URI_WITH_VOICEMAIL, rowId));
+ if (voicemailUri != null) {
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
+ Uri.parse(voicemailUri));
+ }
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, true);
+ return intent;
+ }
+ };
+ }
+
+ public static IntentProvider getCallDetailIntentProvider(
+ final CallLogAdapter adapter, final int position, final long id, final int groupSize) {
+ return new IntentProvider() {
+ @Override
+ public Intent getIntent(Context context) {
+ Cursor cursor = adapter.getCursor();
+ cursor.moveToPosition(position);
+ if (CallLogQuery.isSectionHeader(cursor)) {
+ // Do nothing when a header is clicked.
+ return null;
+ }
+ Intent intent = new Intent(context, CallDetailActivity.class);
+ // Check if the first item is a voicemail.
+ String voicemailUri = cursor.getString(CallLogQuery.VOICEMAIL_URI);
+ if (voicemailUri != null) {
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
+ Uri.parse(voicemailUri));
+ }
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, false);
+
+ if (groupSize > 1) {
+ // We want to restore the position in the cursor at the end.
+ long[] ids = new long[groupSize];
+ // Copy the ids of the rows in the group.
+ for (int index = 0; index < groupSize; ++index) {
+ ids[index] = cursor.getLong(CallLogQuery.ID);
+ cursor.moveToNext();
+ }
+ intent.putExtra(CallDetailActivity.EXTRA_CALL_LOG_IDS, ids);
+ } else {
+ // If there is a single item, use the direct URI for it.
+ intent.setData(ContentUris.withAppendedId(
+ Calls.CONTENT_URI_WITH_VOICEMAIL, id));
+ }
+ return intent;
+ }
+ };
+ }
+}
diff --git a/src/com/android/dialer/calllog/PhoneNumberHelper.java b/src/com/android/dialer/calllog/PhoneNumberHelper.java
new file mode 100644
index 000000000..70505eebc
--- /dev/null
+++ b/src/com/android/dialer/calllog/PhoneNumberHelper.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.res.Resources;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+
+import com.android.contacts.R;
+import com.android.internal.telephony.CallerInfo;
+
+/**
+ * Helper for formatting and managing phone numbers.
+ */
+public class PhoneNumberHelper {
+ private final Resources mResources;
+
+ public PhoneNumberHelper(Resources resources) {
+ mResources = resources;
+ }
+
+ /** Returns true if it is possible to place a call to the given number. */
+ public boolean canPlaceCallsTo(CharSequence number) {
+ return !(TextUtils.isEmpty(number)
+ || number.equals(CallerInfo.UNKNOWN_NUMBER)
+ || number.equals(CallerInfo.PRIVATE_NUMBER)
+ || number.equals(CallerInfo.PAYPHONE_NUMBER));
+ }
+
+ /** Returns true if it is possible to send an SMS to the given number. */
+ public boolean canSendSmsTo(CharSequence number) {
+ return canPlaceCallsTo(number) && !isVoicemailNumber(number) && !isSipNumber(number);
+ }
+
+ /**
+ * Returns the string to display for the given phone number.
+ *
+ * @param number the number to display
+ * @param formattedNumber the formatted number if available, may be null
+ */
+ public CharSequence getDisplayNumber(CharSequence number, CharSequence formattedNumber) {
+ if (TextUtils.isEmpty(number)) {
+ return "";
+ }
+ if (number.equals(CallerInfo.UNKNOWN_NUMBER)) {
+ return mResources.getString(R.string.unknown);
+ }
+ if (number.equals(CallerInfo.PRIVATE_NUMBER)) {
+ return mResources.getString(R.string.private_num);
+ }
+ if (number.equals(CallerInfo.PAYPHONE_NUMBER)) {
+ return mResources.getString(R.string.payphone);
+ }
+ if (isVoicemailNumber(number)) {
+ return mResources.getString(R.string.voicemail);
+ }
+ if (TextUtils.isEmpty(formattedNumber)) {
+ return number;
+ } else {
+ return formattedNumber;
+ }
+ }
+
+ /**
+ * Returns true if the given number is the number of the configured voicemail.
+ * To be able to mock-out this, it is not a static method.
+ */
+ public boolean isVoicemailNumber(CharSequence number) {
+ return PhoneNumberUtils.isVoiceMailNumber(number.toString());
+ }
+
+ /**
+ * Returns true if the given number is a SIP address.
+ * To be able to mock-out this, it is not a static method.
+ */
+ public boolean isSipNumber(CharSequence number) {
+ return PhoneNumberUtils.isUriNumber(number.toString());
+ }
+}
diff --git a/src/com/android/dialer/calllog/PhoneQuery.java b/src/com/android/dialer/calllog/PhoneQuery.java
new file mode 100644
index 000000000..719052204
--- /dev/null
+++ b/src/com/android/dialer/calllog/PhoneQuery.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.provider.ContactsContract.PhoneLookup;
+
+/**
+ * The query to look up the {@link ContactInfo} for a given number in the Call Log.
+ */
+final class PhoneQuery {
+ public static final String[] _PROJECTION = new String[] {
+ PhoneLookup._ID,
+ PhoneLookup.DISPLAY_NAME,
+ PhoneLookup.TYPE,
+ PhoneLookup.LABEL,
+ PhoneLookup.NUMBER,
+ PhoneLookup.NORMALIZED_NUMBER,
+ PhoneLookup.PHOTO_ID,
+ PhoneLookup.LOOKUP_KEY,
+ PhoneLookup.PHOTO_URI};
+
+ public static final int PERSON_ID = 0;
+ public static final int NAME = 1;
+ public static final int PHONE_TYPE = 2;
+ public static final int LABEL = 3;
+ public static final int MATCHED_NUMBER = 4;
+ public static final int NORMALIZED_NUMBER = 5;
+ public static final int PHOTO_ID = 6;
+ public static final int LOOKUP_KEY = 7;
+ public static final int PHOTO_URI = 8;
+}
diff --git a/src/com/android/dialer/calllog/VoicemailNotifier.java b/src/com/android/dialer/calllog/VoicemailNotifier.java
new file mode 100644
index 000000000..d433cf7f6
--- /dev/null
+++ b/src/com/android/dialer/calllog/VoicemailNotifier.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.net.Uri;
+
+/**
+ * Handles notifications for voicemails.
+ */
+public interface VoicemailNotifier {
+ /**
+ * Updates the notification and clears it if there are no new voicemails.
+ * <p>
+ * If the given URI corresponds to a new voicemail, also notifies about it.
+ * <p>
+ * It is not safe to call this method from the main thread.
+ *
+ * @param newCallUri URI of the new call, may be null
+ */
+ public void updateNotification(Uri newCallUri);
+
+ /** Clears the new voicemail notification. */
+ public void clearNotification();
+}
diff --git a/src/com/android/dialer/dialpad/DialpadFragment.java b/src/com/android/dialer/dialpad/DialpadFragment.java
new file mode 100644
index 000000000..a4f3599b5
--- /dev/null
+++ b/src/com/android/dialer/dialpad/DialpadFragment.java
@@ -0,0 +1,1629 @@
+/*
+ * Copyright (C) 2011 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.dialpad;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.AudioManager;
+import android.media.ToneGenerator;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.os.SystemProperties;
+import android.provider.Contacts.Intents.Insert;
+import android.provider.Contacts.People;
+import android.provider.Contacts.Phones;
+import android.provider.Contacts.PhonesColumns;
+import android.provider.Settings;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.PhoneStateListener;
+import android.telephony.TelephonyManager;
+import android.text.Editable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.method.DialerKeyListener;
+import android.text.style.RelativeSizeSpan;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.BaseAdapter;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.PopupMenu;
+import android.widget.TextView;
+
+import com.android.contacts.ContactsUtils;
+import com.android.contacts.R;
+import com.android.contacts.SpecialCharSequenceMgr;
+import com.android.dialer.DialtactsActivity;
+import com.android.contacts.util.Constants;
+import com.android.contacts.util.PhoneNumberFormatter;
+import com.android.contacts.util.StopWatch;
+import com.android.internal.telephony.ITelephony;
+import com.android.phone.CallLogAsync;
+import com.android.phone.HapticFeedback;
+
+/**
+ * Fragment that displays a twelve-key phone dialpad.
+ */
+public class DialpadFragment extends Fragment
+ implements View.OnClickListener,
+ View.OnLongClickListener, View.OnKeyListener,
+ AdapterView.OnItemClickListener, TextWatcher,
+ PopupMenu.OnMenuItemClickListener,
+ DialpadImageButton.OnPressedListener {
+ private static final String TAG = DialpadFragment.class.getSimpleName();
+
+ private static final boolean DEBUG = DialtactsActivity.DEBUG;
+
+ private static final String EMPTY_NUMBER = "";
+
+ /** The length of DTMF tones in milliseconds */
+ private static final int TONE_LENGTH_MS = 150;
+ private static final int TONE_LENGTH_INFINITE = -1;
+
+ /** The DTMF tone volume relative to other sounds in the stream */
+ private static final int TONE_RELATIVE_VOLUME = 80;
+
+ /** Stream type used to play the DTMF tones off call, and mapped to the volume control keys */
+ private static final int DIAL_TONE_STREAM_TYPE = AudioManager.STREAM_DTMF;
+
+ /**
+ * View (usually FrameLayout) containing mDigits field. This can be null, in which mDigits
+ * isn't enclosed by the container.
+ */
+ private View mDigitsContainer;
+ private EditText mDigits;
+
+ /** Remembers if we need to clear digits field when the screen is completely gone. */
+ private boolean mClearDigitsOnStop;
+
+ private View mDelete;
+ private ToneGenerator mToneGenerator;
+ private final Object mToneGeneratorLock = new Object();
+ private View mDialpad;
+ /**
+ * Remembers the number of dialpad buttons which are pressed at this moment.
+ * If it becomes 0, meaning no buttons are pressed, we'll call
+ * {@link ToneGenerator#stopTone()}; the method shouldn't be called unless the last key is
+ * released.
+ */
+ private int mDialpadPressCount;
+
+ private View mDialButtonContainer;
+ private View mDialButton;
+ private ListView mDialpadChooser;
+ private DialpadChooserAdapter mDialpadChooserAdapter;
+
+ /**
+ * Regular expression prohibiting manual phone call. Can be empty, which means "no rule".
+ */
+ private String mProhibitedPhoneNumberRegexp;
+
+
+ // Last number dialed, retrieved asynchronously from the call DB
+ // in onCreate. This number is displayed when the user hits the
+ // send key and cleared in onPause.
+ private final CallLogAsync mCallLog = new CallLogAsync();
+ private String mLastNumberDialed = EMPTY_NUMBER;
+
+ // determines if we want to playback local DTMF tones.
+ private boolean mDTMFToneEnabled;
+
+ // Vibration (haptic feedback) for dialer key presses.
+ private final HapticFeedback mHaptic = new HapticFeedback();
+
+ /** Identifier for the "Add Call" intent extra. */
+ private static final String ADD_CALL_MODE_KEY = "add_call_mode";
+
+ /**
+ * Identifier for intent extra for sending an empty Flash message for
+ * CDMA networks. This message is used by the network to simulate a
+ * press/depress of the "hookswitch" of a landline phone. Aka "empty flash".
+ *
+ * TODO: Using an intent extra to tell the phone to send this flash is a
+ * temporary measure. To be replaced with an ITelephony call in the future.
+ * TODO: Keep in sync with the string defined in OutgoingCallBroadcaster.java
+ * in Phone app until this is replaced with the ITelephony API.
+ */
+ private static final String EXTRA_SEND_EMPTY_FLASH
+ = "com.android.phone.extra.SEND_EMPTY_FLASH";
+
+ private String mCurrentCountryIso;
+
+ private final PhoneStateListener mPhoneStateListener = new PhoneStateListener() {
+ /**
+ * Listen for phone state changes so that we can take down the
+ * "dialpad chooser" if the phone becomes idle while the
+ * chooser UI is visible.
+ */
+ @Override
+ public void onCallStateChanged(int state, String incomingNumber) {
+ // Log.i(TAG, "PhoneStateListener.onCallStateChanged: "
+ // + state + ", '" + incomingNumber + "'");
+ if ((state == TelephonyManager.CALL_STATE_IDLE) && dialpadChooserVisible()) {
+ // Log.i(TAG, "Call ended with dialpad chooser visible! Taking it down...");
+ // Note there's a race condition in the UI here: the
+ // dialpad chooser could conceivably disappear (on its
+ // own) at the exact moment the user was trying to select
+ // one of the choices, which would be confusing. (But at
+ // least that's better than leaving the dialpad chooser
+ // onscreen, but useless...)
+ showDialpadChooser(false);
+ }
+ }
+ };
+
+ private boolean mWasEmptyBeforeTextChange;
+
+ /**
+ * This field is set to true while processing an incoming DIAL intent, in order to make sure
+ * that SpecialCharSequenceMgr actions can be triggered by user input but *not* by a
+ * tel: URI passed by some other app. It will be set to false when all digits are cleared.
+ */
+ private boolean mDigitsFilledByIntent;
+
+ private static final String PREF_DIGITS_FILLED_BY_INTENT = "pref_digits_filled_by_intent";
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ mWasEmptyBeforeTextChange = TextUtils.isEmpty(s);
+ }
+
+ @Override
+ public void onTextChanged(CharSequence input, int start, int before, int changeCount) {
+ if (mWasEmptyBeforeTextChange != TextUtils.isEmpty(input)) {
+ final Activity activity = getActivity();
+ if (activity != null) {
+ activity.invalidateOptionsMenu();
+ }
+ }
+
+ // DTMF Tones do not need to be played here any longer -
+ // the DTMF dialer handles that functionality now.
+ }
+
+ @Override
+ public void afterTextChanged(Editable input) {
+ // When DTMF dialpad buttons are being pressed, we delay SpecialCharSequencMgr sequence,
+ // since some of SpecialCharSequenceMgr's behavior is too abrupt for the "touch-down"
+ // behavior.
+ if (!mDigitsFilledByIntent &&
+ SpecialCharSequenceMgr.handleChars(getActivity(), input.toString(), mDigits)) {
+ // A special sequence was entered, clear the digits
+ mDigits.getText().clear();
+ }
+
+ if (isDigitsEmpty()) {
+ mDigitsFilledByIntent = false;
+ mDigits.setCursorVisible(false);
+ }
+
+ updateDialAndDeleteButtonEnabledState();
+ }
+
+ @Override
+ public void onCreate(Bundle state) {
+ super.onCreate(state);
+
+ mCurrentCountryIso = ContactsUtils.getCurrentCountryIso(getActivity());
+
+ try {
+ mHaptic.init(getActivity(),
+ getResources().getBoolean(R.bool.config_enable_dialer_key_vibration));
+ } catch (Resources.NotFoundException nfe) {
+ Log.e(TAG, "Vibrate control bool missing.", nfe);
+ }
+
+ setHasOptionsMenu(true);
+
+ mProhibitedPhoneNumberRegexp = getResources().getString(
+ R.string.config_prohibited_phone_number_regexp);
+
+ if (state != null) {
+ mDigitsFilledByIntent = state.getBoolean(PREF_DIGITS_FILLED_BY_INTENT);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
+ View fragmentView = inflater.inflate(R.layout.dialpad_fragment, container, false);
+
+ // Load up the resources for the text field.
+ Resources r = getResources();
+
+ mDigitsContainer = fragmentView.findViewById(R.id.digits_container);
+ mDigits = (EditText) fragmentView.findViewById(R.id.digits);
+ mDigits.setKeyListener(DialerKeyListener.getInstance());
+ mDigits.setOnClickListener(this);
+ mDigits.setOnKeyListener(this);
+ mDigits.setOnLongClickListener(this);
+ mDigits.addTextChangedListener(this);
+
+ PhoneNumberFormatter.setPhoneNumberFormattingTextWatcher(getActivity(), mDigits);
+
+ // Check for the presence of the keypad
+ View oneButton = fragmentView.findViewById(R.id.one);
+ if (oneButton != null) {
+ setupKeypad(fragmentView);
+ }
+
+ DisplayMetrics dm = getResources().getDisplayMetrics();
+ int minCellSize = (int) (56 * dm.density); // 56dip == minimum size of menu buttons
+ int cellCount = dm.widthPixels / minCellSize;
+ int fakeMenuItemWidth = dm.widthPixels / cellCount;
+ mDialButtonContainer = fragmentView.findViewById(R.id.dialButtonContainer);
+ // If in portrait, add padding to the dial button since we need space for the
+ // search and menu/overflow buttons.
+ if (mDialButtonContainer != null && !ContactsUtils.isLandscape(this.getActivity())) {
+ mDialButtonContainer.setPadding(
+ fakeMenuItemWidth, mDialButtonContainer.getPaddingTop(),
+ fakeMenuItemWidth, mDialButtonContainer.getPaddingBottom());
+ }
+ mDialButton = fragmentView.findViewById(R.id.dialButton);
+ if (r.getBoolean(R.bool.config_show_onscreen_dial_button)) {
+ mDialButton.setOnClickListener(this);
+ mDialButton.setOnLongClickListener(this);
+ } else {
+ mDialButton.setVisibility(View.GONE); // It's VISIBLE by default
+ mDialButton = null;
+ }
+
+ mDelete = fragmentView.findViewById(R.id.deleteButton);
+ if (mDelete != null) {
+ mDelete.setOnClickListener(this);
+ mDelete.setOnLongClickListener(this);
+ }
+
+ mDialpad = fragmentView.findViewById(R.id.dialpad); // This is null in landscape mode.
+
+ // In landscape we put the keyboard in phone mode.
+ if (null == mDialpad) {
+ mDigits.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
+ } else {
+ mDigits.setCursorVisible(false);
+ }
+
+ // Set up the "dialpad chooser" UI; see showDialpadChooser().
+ mDialpadChooser = (ListView) fragmentView.findViewById(R.id.dialpadChooser);
+ mDialpadChooser.setOnItemClickListener(this);
+
+ configureScreenFromIntent(getActivity().getIntent());
+
+ return fragmentView;
+ }
+
+ private boolean isLayoutReady() {
+ return mDigits != null;
+ }
+
+ public EditText getDigitsWidget() {
+ return mDigits;
+ }
+
+ /**
+ * @return true when {@link #mDigits} is actually filled by the Intent.
+ */
+ private boolean fillDigitsIfNecessary(Intent intent) {
+ final String action = intent.getAction();
+ if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) {
+ Uri uri = intent.getData();
+ if (uri != null) {
+ if (Constants.SCHEME_TEL.equals(uri.getScheme())) {
+ // Put the requested number into the input area
+ String data = uri.getSchemeSpecificPart();
+ // Remember it is filled via Intent.
+ mDigitsFilledByIntent = true;
+ setFormattedDigits(data, null);
+ return true;
+ } else {
+ String type = intent.getType();
+ if (People.CONTENT_ITEM_TYPE.equals(type)
+ || Phones.CONTENT_ITEM_TYPE.equals(type)) {
+ // Query the phone number
+ Cursor c = getActivity().getContentResolver().query(intent.getData(),
+ new String[] {PhonesColumns.NUMBER, PhonesColumns.NUMBER_KEY},
+ null, null, null);
+ if (c != null) {
+ try {
+ if (c.moveToFirst()) {
+ // Remember it is filled via Intent.
+ mDigitsFilledByIntent = true;
+ // Put the number into the input area
+ setFormattedDigits(c.getString(0), c.getString(1));
+ return true;
+ }
+ } finally {
+ c.close();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @see #showDialpadChooser(boolean)
+ */
+ private static boolean needToShowDialpadChooser(Intent intent, boolean isAddCallMode) {
+ final String action = intent.getAction();
+
+ boolean needToShowDialpadChooser = false;
+
+ if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) {
+ Uri uri = intent.getData();
+ if (uri == null) {
+ // ACTION_DIAL or ACTION_VIEW with no data.
+ // This behaves basically like ACTION_MAIN: If there's
+ // already an active call, bring up an intermediate UI to
+ // make the user confirm what they really want to do.
+ // Be sure *not* to show the dialpad chooser if this is an
+ // explicit "Add call" action, though.
+ if (!isAddCallMode && phoneIsInUse()) {
+ needToShowDialpadChooser = true;
+ }
+ }
+ } else if (Intent.ACTION_MAIN.equals(action)) {
+ // The MAIN action means we're bringing up a blank dialer
+ // (e.g. by selecting the Home shortcut, or tabbing over from
+ // Contacts or Call log.)
+ //
+ // At this point, IF there's already an active call, there's a
+ // good chance that the user got here accidentally (but really
+ // wanted the in-call dialpad instead). So we bring up an
+ // intermediate UI to make the user confirm what they really
+ // want to do.
+ if (phoneIsInUse()) {
+ // Log.i(TAG, "resolveIntent(): phone is in use; showing dialpad chooser!");
+ needToShowDialpadChooser = true;
+ }
+ }
+
+ return needToShowDialpadChooser;
+ }
+
+ private static boolean isAddCallMode(Intent intent) {
+ final String action = intent.getAction();
+ if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) {
+ // see if we are "adding a call" from the InCallScreen; false by default.
+ return intent.getBooleanExtra(ADD_CALL_MODE_KEY, false);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Checks the given Intent and changes dialpad's UI state. For example, if the Intent requires
+ * the screen to enter "Add Call" mode, this method will show correct UI for the mode.
+ */
+ public void configureScreenFromIntent(Intent intent) {
+ if (!isLayoutReady()) {
+ // This happens typically when parent's Activity#onNewIntent() is called while
+ // Fragment#onCreateView() isn't called yet, and thus we cannot configure Views at
+ // this point. onViewCreate() should call this method after preparing layouts, so
+ // just ignore this call now.
+ Log.i(TAG,
+ "Screen configuration is requested before onCreateView() is called. Ignored");
+ return;
+ }
+
+ boolean needToShowDialpadChooser = false;
+
+ final boolean isAddCallMode = isAddCallMode(intent);
+ if (!isAddCallMode) {
+ final boolean digitsFilled = fillDigitsIfNecessary(intent);
+ if (!digitsFilled) {
+ needToShowDialpadChooser = needToShowDialpadChooser(intent, isAddCallMode);
+ }
+ }
+ showDialpadChooser(needToShowDialpadChooser);
+ }
+
+ /**
+ * Sets formatted digits to digits field.
+ */
+ private void setFormattedDigits(String data, String normalizedNumber) {
+ // strip the non-dialable numbers out of the data string.
+ String dialString = PhoneNumberUtils.extractNetworkPortion(data);
+ dialString =
+ PhoneNumberUtils.formatNumber(dialString, normalizedNumber, mCurrentCountryIso);
+ if (!TextUtils.isEmpty(dialString)) {
+ Editable digits = mDigits.getText();
+ digits.replace(0, digits.length(), dialString);
+ // for some reason this isn't getting called in the digits.replace call above..
+ // but in any case, this will make sure the background drawable looks right
+ afterTextChanged(digits);
+ }
+ }
+
+ private void setupKeypad(View fragmentView) {
+ int[] buttonIds = new int[] { R.id.one, R.id.two, R.id.three, R.id.four, R.id.five,
+ R.id.six, R.id.seven, R.id.eight, R.id.nine, R.id.zero, R.id.star, R.id.pound};
+ for (int id : buttonIds) {
+ ((DialpadImageButton) fragmentView.findViewById(id)).setOnPressedListener(this);
+ }
+
+ // Long-pressing one button will initiate Voicemail.
+ fragmentView.findViewById(R.id.one).setOnLongClickListener(this);
+
+ // Long-pressing zero button will enter '+' instead.
+ fragmentView.findViewById(R.id.zero).setOnLongClickListener(this);
+
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ final StopWatch stopWatch = StopWatch.start("Dialpad.onResume");
+
+ // Query the last dialed number. Do it first because hitting
+ // the DB is 'slow'. This call is asynchronous.
+ queryLastOutgoingCall();
+
+ stopWatch.lap("qloc");
+
+ // retrieve the DTMF tone play back setting.
+ mDTMFToneEnabled = Settings.System.getInt(getActivity().getContentResolver(),
+ Settings.System.DTMF_TONE_WHEN_DIALING, 1) == 1;
+
+ stopWatch.lap("dtwd");
+
+ // Retrieve the haptic feedback setting.
+ mHaptic.checkSystemSetting();
+
+ stopWatch.lap("hptc");
+
+ // if the mToneGenerator creation fails, just continue without it. It is
+ // a local audio signal, and is not as important as the dtmf tone itself.
+ synchronized (mToneGeneratorLock) {
+ if (mToneGenerator == null) {
+ try {
+ mToneGenerator = new ToneGenerator(DIAL_TONE_STREAM_TYPE, TONE_RELATIVE_VOLUME);
+ } catch (RuntimeException e) {
+ Log.w(TAG, "Exception caught while creating local tone generator: " + e);
+ mToneGenerator = null;
+ }
+ }
+ }
+ stopWatch.lap("tg");
+ // Prevent unnecessary confusion. Reset the press count anyway.
+ mDialpadPressCount = 0;
+
+ Activity parent = getActivity();
+ if (parent instanceof DialtactsActivity) {
+ // See if we were invoked with a DIAL intent. If we were, fill in the appropriate
+ // digits in the dialer field.
+ fillDigitsIfNecessary(parent.getIntent());
+ }
+
+ stopWatch.lap("fdin");
+
+ // While we're in the foreground, listen for phone state changes,
+ // purely so that we can take down the "dialpad chooser" if the
+ // phone becomes idle while the chooser UI is visible.
+ TelephonyManager telephonyManager =
+ (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE);
+ telephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE);
+
+ stopWatch.lap("tm");
+
+ // Potentially show hint text in the mDigits field when the user
+ // hasn't typed any digits yet. (If there's already an active call,
+ // this hint text will remind the user that he's about to add a new
+ // call.)
+ //
+ // TODO: consider adding better UI for the case where *both* lines
+ // are currently in use. (Right now we let the user try to add
+ // another call, but that call is guaranteed to fail. Perhaps the
+ // entire dialer UI should be disabled instead.)
+ if (phoneIsInUse()) {
+ final SpannableString hint = new SpannableString(
+ getActivity().getString(R.string.dialerDialpadHintText));
+ hint.setSpan(new RelativeSizeSpan(0.8f), 0, hint.length(), 0);
+ mDigits.setHint(hint);
+ } else {
+ // Common case; no hint necessary.
+ mDigits.setHint(null);
+
+ // Also, a sanity-check: the "dialpad chooser" UI should NEVER
+ // be visible if the phone is idle!
+ showDialpadChooser(false);
+ }
+
+ stopWatch.lap("hnt");
+
+ updateDialAndDeleteButtonEnabledState();
+
+ stopWatch.lap("bes");
+
+ stopWatch.stopAndLog(TAG, 50);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ // Stop listening for phone state changes.
+ TelephonyManager telephonyManager =
+ (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE);
+ telephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE);
+
+ // Make sure we don't leave this activity with a tone still playing.
+ stopTone();
+ // Just in case reset the counter too.
+ mDialpadPressCount = 0;
+
+ synchronized (mToneGeneratorLock) {
+ if (mToneGenerator != null) {
+ mToneGenerator.release();
+ mToneGenerator = null;
+ }
+ }
+ // TODO: I wonder if we should not check if the AsyncTask that
+ // lookup the last dialed number has completed.
+ mLastNumberDialed = EMPTY_NUMBER; // Since we are going to query again, free stale number.
+
+ SpecialCharSequenceMgr.cleanup();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (mClearDigitsOnStop) {
+ mClearDigitsOnStop = false;
+ mDigits.getText().clear();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(PREF_DIGITS_FILLED_BY_INTENT, mDigitsFilledByIntent);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ // Landscape dialer uses the real actionbar menu, whereas portrait uses a fake one
+ // that is created using constructPopupMenu()
+ if (ContactsUtils.isLandscape(this.getActivity()) ||
+ ViewConfiguration.get(getActivity()).hasPermanentMenuKey() &&
+ isLayoutReady() && mDialpadChooser != null) {
+ inflater.inflate(R.menu.dialpad_options, menu);
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ // Hardware menu key should be available and Views should already be ready.
+ if (ContactsUtils.isLandscape(this.getActivity()) ||
+ ViewConfiguration.get(getActivity()).hasPermanentMenuKey() &&
+ isLayoutReady() && mDialpadChooser != null) {
+ setupMenuItems(menu);
+ }
+ }
+
+ private void setupMenuItems(Menu menu) {
+ final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings_dialpad);
+ final MenuItem addToContactMenuItem = menu.findItem(R.id.menu_add_contacts);
+ final MenuItem twoSecPauseMenuItem = menu.findItem(R.id.menu_2s_pause);
+ final MenuItem waitMenuItem = menu.findItem(R.id.menu_add_wait);
+
+ // Check if all the menu items are inflated correctly. As a shortcut, we assume all menu
+ // items are ready if the first item is non-null.
+ if (callSettingsMenuItem == null) {
+ return;
+ }
+
+ final Activity activity = getActivity();
+ if (activity != null && ViewConfiguration.get(activity).hasPermanentMenuKey()) {
+ // Call settings should be available via its parent Activity.
+ callSettingsMenuItem.setVisible(false);
+ } else {
+ callSettingsMenuItem.setVisible(true);
+ callSettingsMenuItem.setIntent(DialtactsActivity.getCallSettingsIntent());
+ }
+
+ // We show "add to contacts", "2sec pause", and "add wait" menus only when the user is
+ // seeing usual dialpads and has typed at least one digit.
+ // We never show a menu if the "choose dialpad" UI is up.
+ if (dialpadChooserVisible() || isDigitsEmpty()) {
+ addToContactMenuItem.setVisible(false);
+ twoSecPauseMenuItem.setVisible(false);
+ waitMenuItem.setVisible(false);
+ } else {
+ final CharSequence digits = mDigits.getText();
+
+ // Put the current digits string into an intent
+ addToContactMenuItem.setIntent(getAddToContactIntent(digits));
+ addToContactMenuItem.setVisible(true);
+
+ // Check out whether to show Pause & Wait option menu items
+ int selectionStart;
+ int selectionEnd;
+ String strDigits = digits.toString();
+
+ selectionStart = mDigits.getSelectionStart();
+ selectionEnd = mDigits.getSelectionEnd();
+
+ if (selectionStart != -1) {
+ if (selectionStart > selectionEnd) {
+ // swap it as we want start to be less then end
+ int tmp = selectionStart;
+ selectionStart = selectionEnd;
+ selectionEnd = tmp;
+ }
+
+ if (selectionStart != 0) {
+ // Pause can be visible if cursor is not in the begining
+ twoSecPauseMenuItem.setVisible(true);
+
+ // For Wait to be visible set of condition to meet
+ waitMenuItem.setVisible(showWait(selectionStart, selectionEnd, strDigits));
+ } else {
+ // cursor in the beginning both pause and wait to be invisible
+ twoSecPauseMenuItem.setVisible(false);
+ waitMenuItem.setVisible(false);
+ }
+ } else {
+ twoSecPauseMenuItem.setVisible(true);
+
+ // cursor is not selected so assume new digit is added to the end
+ int strLength = strDigits.length();
+ waitMenuItem.setVisible(showWait(strLength, strLength, strDigits));
+ }
+ }
+ }
+
+ private static Intent getAddToContactIntent(CharSequence digits) {
+ final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
+ intent.putExtra(Insert.PHONE, digits);
+ intent.setType(People.CONTENT_ITEM_TYPE);
+ return intent;
+ }
+
+ private void keyPressed(int keyCode) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_1:
+ playTone(ToneGenerator.TONE_DTMF_1, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_2:
+ playTone(ToneGenerator.TONE_DTMF_2, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_3:
+ playTone(ToneGenerator.TONE_DTMF_3, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_4:
+ playTone(ToneGenerator.TONE_DTMF_4, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_5:
+ playTone(ToneGenerator.TONE_DTMF_5, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_6:
+ playTone(ToneGenerator.TONE_DTMF_6, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_7:
+ playTone(ToneGenerator.TONE_DTMF_7, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_8:
+ playTone(ToneGenerator.TONE_DTMF_8, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_9:
+ playTone(ToneGenerator.TONE_DTMF_9, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_0:
+ playTone(ToneGenerator.TONE_DTMF_0, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_POUND:
+ playTone(ToneGenerator.TONE_DTMF_P, TONE_LENGTH_INFINITE);
+ break;
+ case KeyEvent.KEYCODE_STAR:
+ playTone(ToneGenerator.TONE_DTMF_S, TONE_LENGTH_INFINITE);
+ break;
+ default:
+ break;
+ }
+
+ mHaptic.vibrate();
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode);
+ mDigits.onKeyDown(keyCode, event);
+
+ // If the cursor is at the end of the text we hide it.
+ final int length = mDigits.length();
+ if (length == mDigits.getSelectionStart() && length == mDigits.getSelectionEnd()) {
+ mDigits.setCursorVisible(false);
+ }
+ }
+
+ @Override
+ public boolean onKey(View view, int keyCode, KeyEvent event) {
+ switch (view.getId()) {
+ case R.id.digits:
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ dialButtonPressed();
+ return true;
+ }
+ break;
+ }
+ return false;
+ }
+
+ /**
+ * When a key is pressed, we start playing DTMF tone, do vibration, and enter the digit
+ * immediately. When a key is released, we stop the tone. Note that the "key press" event will
+ * be delivered by the system with certain amount of delay, it won't be synced with user's
+ * actual "touch-down" behavior.
+ */
+ @Override
+ public void onPressed(View view, boolean pressed) {
+ if (DEBUG) Log.d(TAG, "onPressed(). view: " + view + ", pressed: " + pressed);
+ if (pressed) {
+ switch (view.getId()) {
+ case R.id.one: {
+ keyPressed(KeyEvent.KEYCODE_1);
+ break;
+ }
+ case R.id.two: {
+ keyPressed(KeyEvent.KEYCODE_2);
+ break;
+ }
+ case R.id.three: {
+ keyPressed(KeyEvent.KEYCODE_3);
+ break;
+ }
+ case R.id.four: {
+ keyPressed(KeyEvent.KEYCODE_4);
+ break;
+ }
+ case R.id.five: {
+ keyPressed(KeyEvent.KEYCODE_5);
+ break;
+ }
+ case R.id.six: {
+ keyPressed(KeyEvent.KEYCODE_6);
+ break;
+ }
+ case R.id.seven: {
+ keyPressed(KeyEvent.KEYCODE_7);
+ break;
+ }
+ case R.id.eight: {
+ keyPressed(KeyEvent.KEYCODE_8);
+ break;
+ }
+ case R.id.nine: {
+ keyPressed(KeyEvent.KEYCODE_9);
+ break;
+ }
+ case R.id.zero: {
+ keyPressed(KeyEvent.KEYCODE_0);
+ break;
+ }
+ case R.id.pound: {
+ keyPressed(KeyEvent.KEYCODE_POUND);
+ break;
+ }
+ case R.id.star: {
+ keyPressed(KeyEvent.KEYCODE_STAR);
+ break;
+ }
+ default: {
+ Log.wtf(TAG, "Unexpected onTouch(ACTION_DOWN) event from: " + view);
+ break;
+ }
+ }
+ mDialpadPressCount++;
+ } else {
+ view.jumpDrawablesToCurrentState();
+ mDialpadPressCount--;
+ if (mDialpadPressCount < 0) {
+ // e.g.
+ // - when the user action is detected as horizontal swipe, at which only
+ // "up" event is thrown.
+ // - when the user long-press '0' button, at which dialpad will decrease this count
+ // while it still gets press-up event here.
+ if (DEBUG) Log.d(TAG, "mKeyPressCount become negative.");
+ stopTone();
+ mDialpadPressCount = 0;
+ } else if (mDialpadPressCount == 0) {
+ stopTone();
+ }
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ switch (view.getId()) {
+ case R.id.deleteButton: {
+ keyPressed(KeyEvent.KEYCODE_DEL);
+ return;
+ }
+ case R.id.dialButton: {
+ mHaptic.vibrate(); // Vibrate here too, just like we do for the regular keys
+ dialButtonPressed();
+ return;
+ }
+ case R.id.digits: {
+ if (!isDigitsEmpty()) {
+ mDigits.setCursorVisible(true);
+ }
+ return;
+ }
+ default: {
+ Log.wtf(TAG, "Unexpected onClick() event from: " + view);
+ return;
+ }
+ }
+ }
+
+ public PopupMenu constructPopupMenu(View anchorView) {
+ final Context context = getActivity();
+ if (context == null) {
+ return null;
+ }
+ final PopupMenu popupMenu = new PopupMenu(context, anchorView);
+ final Menu menu = popupMenu.getMenu();
+ popupMenu.inflate(R.menu.dialpad_options);
+ popupMenu.setOnMenuItemClickListener(this);
+ setupMenuItems(menu);
+ return popupMenu;
+ }
+
+ @Override
+ public boolean onLongClick(View view) {
+ final Editable digits = mDigits.getText();
+ final int id = view.getId();
+ switch (id) {
+ case R.id.deleteButton: {
+ digits.clear();
+ // TODO: The framework forgets to clear the pressed
+ // status of disabled button. Until this is fixed,
+ // clear manually the pressed status. b/2133127
+ mDelete.setPressed(false);
+ return true;
+ }
+ case R.id.one: {
+ // '1' may be already entered since we rely on onTouch() event for numeric buttons.
+ // Just for safety we also check if the digits field is empty or not.
+ if (isDigitsEmpty() || TextUtils.equals(mDigits.getText(), "1")) {
+ // We'll try to initiate voicemail and thus we want to remove irrelevant string.
+ removePreviousDigitIfPossible();
+
+ if (isVoicemailAvailable()) {
+ callVoicemail();
+ } else if (getActivity() != null) {
+ // Voicemail is unavailable maybe because Airplane mode is turned on.
+ // Check the current status and show the most appropriate error message.
+ final boolean isAirplaneModeOn =
+ Settings.System.getInt(getActivity().getContentResolver(),
+ Settings.System.AIRPLANE_MODE_ON, 0) != 0;
+ if (isAirplaneModeOn) {
+ DialogFragment dialogFragment = ErrorDialogFragment.newInstance(
+ R.string.dialog_voicemail_airplane_mode_message);
+ dialogFragment.show(getFragmentManager(),
+ "voicemail_request_during_airplane_mode");
+ } else {
+ DialogFragment dialogFragment = ErrorDialogFragment.newInstance(
+ R.string.dialog_voicemail_not_ready_message);
+ dialogFragment.show(getFragmentManager(), "voicemail_not_ready");
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+ case R.id.zero: {
+ // Remove tentative input ('0') done by onTouch().
+ removePreviousDigitIfPossible();
+ keyPressed(KeyEvent.KEYCODE_PLUS);
+
+ // Stop tone immediately and decrease the press count, so that possible subsequent
+ // dial button presses won't honor the 0 click any more.
+ // Note: this *will* make mDialpadPressCount negative when the 0 key is released,
+ // which should be handled appropriately.
+ stopTone();
+ if (mDialpadPressCount > 0) mDialpadPressCount--;
+
+ return true;
+ }
+ case R.id.digits: {
+ // Right now EditText does not show the "paste" option when cursor is not visible.
+ // To show that, make the cursor visible, and return false, letting the EditText
+ // show the option by itself.
+ mDigits.setCursorVisible(true);
+ return false;
+ }
+ case R.id.dialButton: {
+ if (isDigitsEmpty()) {
+ handleDialButtonClickWithEmptyDigits();
+ // This event should be consumed so that onClick() won't do the exactly same
+ // thing.
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Remove the digit just before the current position. This can be used if we want to replace
+ * the previous digit or cancel previously entered character.
+ */
+ private void removePreviousDigitIfPossible() {
+ final Editable editable = mDigits.getText();
+ final int currentPosition = mDigits.getSelectionStart();
+ if (currentPosition > 0) {
+ mDigits.setSelection(currentPosition);
+ mDigits.getText().delete(currentPosition - 1, currentPosition);
+ }
+ }
+
+ public void callVoicemail() {
+ startActivity(ContactsUtils.getVoicemailIntent());
+ mClearDigitsOnStop = true;
+ getActivity().finish();
+ }
+
+ public static class ErrorDialogFragment extends DialogFragment {
+ private int mTitleResId;
+ private int mMessageResId;
+
+ private static final String ARG_TITLE_RES_ID = "argTitleResId";
+ private static final String ARG_MESSAGE_RES_ID = "argMessageResId";
+
+ public static ErrorDialogFragment newInstance(int messageResId) {
+ return newInstance(0, messageResId);
+ }
+
+ public static ErrorDialogFragment newInstance(int titleResId, int messageResId) {
+ final ErrorDialogFragment fragment = new ErrorDialogFragment();
+ final Bundle args = new Bundle();
+ args.putInt(ARG_TITLE_RES_ID, titleResId);
+ args.putInt(ARG_MESSAGE_RES_ID, messageResId);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mTitleResId = getArguments().getInt(ARG_TITLE_RES_ID);
+ mMessageResId = getArguments().getInt(ARG_MESSAGE_RES_ID);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ if (mTitleResId != 0) {
+ builder.setTitle(mTitleResId);
+ }
+ if (mMessageResId != 0) {
+ builder.setMessage(mMessageResId);
+ }
+ builder.setPositiveButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dismiss();
+ }
+ });
+ return builder.create();
+ }
+ }
+
+ /**
+ * In most cases, when the dial button is pressed, there is a
+ * number in digits area. Pack it in the intent, start the
+ * outgoing call broadcast as a separate task and finish this
+ * activity.
+ *
+ * When there is no digit and the phone is CDMA and off hook,
+ * we're sending a blank flash for CDMA. CDMA networks use Flash
+ * messages when special processing needs to be done, mainly for
+ * 3-way or call waiting scenarios. Presumably, here we're in a
+ * special 3-way scenario where the network needs a blank flash
+ * before being able to add the new participant. (This is not the
+ * case with all 3-way calls, just certain CDMA infrastructures.)
+ *
+ * Otherwise, there is no digit, display the last dialed
+ * number. Don't finish since the user may want to edit it. The
+ * user needs to press the dial button again, to dial it (general
+ * case described above).
+ */
+ public void dialButtonPressed() {
+ if (isDigitsEmpty()) { // No number entered.
+ handleDialButtonClickWithEmptyDigits();
+ } else {
+ final String number = mDigits.getText().toString();
+
+ // "persist.radio.otaspdial" is a temporary hack needed for one carrier's automated
+ // test equipment.
+ // TODO: clean it up.
+ if (number != null
+ && !TextUtils.isEmpty(mProhibitedPhoneNumberRegexp)
+ && number.matches(mProhibitedPhoneNumberRegexp)
+ && (SystemProperties.getInt("persist.radio.otaspdial", 0) != 1)) {
+ Log.i(TAG, "The phone number is prohibited explicitly by a rule.");
+ if (getActivity() != null) {
+ DialogFragment dialogFragment = ErrorDialogFragment.newInstance(
+ R.string.dialog_phone_call_prohibited_message);
+ dialogFragment.show(getFragmentManager(), "phone_prohibited_dialog");
+ }
+
+ // Clear the digits just in case.
+ mDigits.getText().clear();
+ } else {
+ final Intent intent = ContactsUtils.getCallIntent(number,
+ (getActivity() instanceof DialtactsActivity ?
+ ((DialtactsActivity)getActivity()).getCallOrigin() : null));
+ startActivity(intent);
+ mClearDigitsOnStop = true;
+ getActivity().finish();
+ }
+ }
+ }
+
+ private void handleDialButtonClickWithEmptyDigits() {
+ if (phoneIsCdma() && phoneIsOffhook()) {
+ // This is really CDMA specific. On GSM is it possible
+ // to be off hook and wanted to add a 3rd party using
+ // the redial feature.
+ startActivity(newFlashIntent());
+ } else {
+ if (!TextUtils.isEmpty(mLastNumberDialed)) {
+ // Recall the last number dialed.
+ mDigits.setText(mLastNumberDialed);
+
+ // ...and move the cursor to the end of the digits string,
+ // so you'll be able to delete digits using the Delete
+ // button (just as if you had typed the number manually.)
+ //
+ // Note we use mDigits.getText().length() here, not
+ // mLastNumberDialed.length(), since the EditText widget now
+ // contains a *formatted* version of mLastNumberDialed (due to
+ // mTextWatcher) and its length may have changed.
+ mDigits.setSelection(mDigits.getText().length());
+ } else {
+ // There's no "last number dialed" or the
+ // background query is still running. There's
+ // nothing useful for the Dial button to do in
+ // this case. Note: with a soft dial button, this
+ // can never happens since the dial button is
+ // disabled under these conditons.
+ playTone(ToneGenerator.TONE_PROP_NACK);
+ }
+ }
+ }
+
+ /**
+ * Plays the specified tone for TONE_LENGTH_MS milliseconds.
+ */
+ private void playTone(int tone) {
+ playTone(tone, TONE_LENGTH_MS);
+ }
+
+ /**
+ * Play the specified tone for the specified milliseconds
+ *
+ * The tone is played locally, using the audio stream for phone calls.
+ * Tones are played only if the "Audible touch tones" user preference
+ * is checked, and are NOT played if the device is in silent mode.
+ *
+ * The tone length can be -1, meaning "keep playing the tone." If the caller does so, it should
+ * call stopTone() afterward.
+ *
+ * @param tone a tone code from {@link ToneGenerator}
+ * @param durationMs tone length.
+ */
+ private void playTone(int tone, int durationMs) {
+ // if local tone playback is disabled, just return.
+ if (!mDTMFToneEnabled) {
+ return;
+ }
+
+ // Also do nothing if the phone is in silent mode.
+ // We need to re-check the ringer mode for *every* playTone()
+ // call, rather than keeping a local flag that's updated in
+ // onResume(), since it's possible to toggle silent mode without
+ // leaving the current activity (via the ENDCALL-longpress menu.)
+ AudioManager audioManager =
+ (AudioManager) getActivity().getSystemService(Context.AUDIO_SERVICE);
+ int ringerMode = audioManager.getRingerMode();
+ if ((ringerMode == AudioManager.RINGER_MODE_SILENT)
+ || (ringerMode == AudioManager.RINGER_MODE_VIBRATE)) {
+ return;
+ }
+
+ synchronized (mToneGeneratorLock) {
+ if (mToneGenerator == null) {
+ Log.w(TAG, "playTone: mToneGenerator == null, tone: " + tone);
+ return;
+ }
+
+ // Start the new tone (will stop any playing tone)
+ mToneGenerator.startTone(tone, durationMs);
+ }
+ }
+
+ /**
+ * Stop the tone if it is played.
+ */
+ private void stopTone() {
+ // if local tone playback is disabled, just return.
+ if (!mDTMFToneEnabled) {
+ return;
+ }
+ synchronized (mToneGeneratorLock) {
+ if (mToneGenerator == null) {
+ Log.w(TAG, "stopTone: mToneGenerator == null");
+ return;
+ }
+ mToneGenerator.stopTone();
+ }
+ }
+
+ /**
+ * Brings up the "dialpad chooser" UI in place of the usual Dialer
+ * elements (the textfield/button and the dialpad underneath).
+ *
+ * We show this UI if the user brings up the Dialer while a call is
+ * already in progress, since there's a good chance we got here
+ * accidentally (and the user really wanted the in-call dialpad instead).
+ * So in this situation we display an intermediate UI that lets the user
+ * explicitly choose between the in-call dialpad ("Use touch tone
+ * keypad") and the regular Dialer ("Add call"). (Or, the option "Return
+ * to call in progress" just goes back to the in-call UI with no dialpad
+ * at all.)
+ *
+ * @param enabled If true, show the "dialpad chooser" instead
+ * of the regular Dialer UI
+ */
+ private void showDialpadChooser(boolean enabled) {
+ // Check if onCreateView() is already called by checking one of View objects.
+ if (!isLayoutReady()) {
+ return;
+ }
+
+ if (enabled) {
+ // Log.i(TAG, "Showing dialpad chooser!");
+ if (mDigitsContainer != null) {
+ mDigitsContainer.setVisibility(View.GONE);
+ } else {
+ // mDigits is not enclosed by the container. Make the digits field itself gone.
+ mDigits.setVisibility(View.GONE);
+ }
+ if (mDialpad != null) mDialpad.setVisibility(View.GONE);
+ if (mDialButtonContainer != null) mDialButtonContainer.setVisibility(View.GONE);
+
+ mDialpadChooser.setVisibility(View.VISIBLE);
+
+ // Instantiate the DialpadChooserAdapter and hook it up to the
+ // ListView. We do this only once.
+ if (mDialpadChooserAdapter == null) {
+ mDialpadChooserAdapter = new DialpadChooserAdapter(getActivity());
+ }
+ mDialpadChooser.setAdapter(mDialpadChooserAdapter);
+ } else {
+ // Log.i(TAG, "Displaying normal Dialer UI.");
+ if (mDigitsContainer != null) {
+ mDigitsContainer.setVisibility(View.VISIBLE);
+ } else {
+ mDigits.setVisibility(View.VISIBLE);
+ }
+ if (mDialpad != null) mDialpad.setVisibility(View.VISIBLE);
+ if (mDialButtonContainer != null) mDialButtonContainer.setVisibility(View.VISIBLE);
+ mDialpadChooser.setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * @return true if we're currently showing the "dialpad chooser" UI.
+ */
+ private boolean dialpadChooserVisible() {
+ return mDialpadChooser.getVisibility() == View.VISIBLE;
+ }
+
+ /**
+ * Simple list adapter, binding to an icon + text label
+ * for each item in the "dialpad chooser" list.
+ */
+ private static class DialpadChooserAdapter extends BaseAdapter {
+ private LayoutInflater mInflater;
+
+ // Simple struct for a single "choice" item.
+ static class ChoiceItem {
+ String text;
+ Bitmap icon;
+ int id;
+
+ public ChoiceItem(String s, Bitmap b, int i) {
+ text = s;
+ icon = b;
+ id = i;
+ }
+ }
+
+ // IDs for the possible "choices":
+ static final int DIALPAD_CHOICE_USE_DTMF_DIALPAD = 101;
+ static final int DIALPAD_CHOICE_RETURN_TO_CALL = 102;
+ static final int DIALPAD_CHOICE_ADD_NEW_CALL = 103;
+
+ private static final int NUM_ITEMS = 3;
+ private ChoiceItem mChoiceItems[] = new ChoiceItem[NUM_ITEMS];
+
+ public DialpadChooserAdapter(Context context) {
+ // Cache the LayoutInflate to avoid asking for a new one each time.
+ mInflater = LayoutInflater.from(context);
+
+ // Initialize the possible choices.
+ // TODO: could this be specified entirely in XML?
+
+ // - "Use touch tone keypad"
+ mChoiceItems[0] = new ChoiceItem(
+ context.getString(R.string.dialer_useDtmfDialpad),
+ BitmapFactory.decodeResource(context.getResources(),
+ R.drawable.ic_dialer_fork_tt_keypad),
+ DIALPAD_CHOICE_USE_DTMF_DIALPAD);
+
+ // - "Return to call in progress"
+ mChoiceItems[1] = new ChoiceItem(
+ context.getString(R.string.dialer_returnToInCallScreen),
+ BitmapFactory.decodeResource(context.getResources(),
+ R.drawable.ic_dialer_fork_current_call),
+ DIALPAD_CHOICE_RETURN_TO_CALL);
+
+ // - "Add call"
+ mChoiceItems[2] = new ChoiceItem(
+ context.getString(R.string.dialer_addAnotherCall),
+ BitmapFactory.decodeResource(context.getResources(),
+ R.drawable.ic_dialer_fork_add_call),
+ DIALPAD_CHOICE_ADD_NEW_CALL);
+ }
+
+ @Override
+ public int getCount() {
+ return NUM_ITEMS;
+ }
+
+ /**
+ * Return the ChoiceItem for a given position.
+ */
+ @Override
+ public Object getItem(int position) {
+ return mChoiceItems[position];
+ }
+
+ /**
+ * Return a unique ID for each possible choice.
+ */
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ /**
+ * Make a view for each row.
+ */
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ // When convertView is non-null, we can reuse it (there's no need
+ // to reinflate it.)
+ if (convertView == null) {
+ convertView = mInflater.inflate(R.layout.dialpad_chooser_list_item, null);
+ }
+
+ TextView text = (TextView) convertView.findViewById(R.id.text);
+ text.setText(mChoiceItems[position].text);
+
+ ImageView icon = (ImageView) convertView.findViewById(R.id.icon);
+ icon.setImageBitmap(mChoiceItems[position].icon);
+
+ return convertView;
+ }
+ }
+
+ /**
+ * Handle clicks from the dialpad chooser.
+ */
+ @Override
+ public void onItemClick(AdapterView<?> parent, View v, int position, long id) {
+ DialpadChooserAdapter.ChoiceItem item =
+ (DialpadChooserAdapter.ChoiceItem) parent.getItemAtPosition(position);
+ int itemId = item.id;
+ switch (itemId) {
+ case DialpadChooserAdapter.DIALPAD_CHOICE_USE_DTMF_DIALPAD:
+ // Log.i(TAG, "DIALPAD_CHOICE_USE_DTMF_DIALPAD");
+ // Fire off an intent to go back to the in-call UI
+ // with the dialpad visible.
+ returnToInCallScreen(true);
+ break;
+
+ case DialpadChooserAdapter.DIALPAD_CHOICE_RETURN_TO_CALL:
+ // Log.i(TAG, "DIALPAD_CHOICE_RETURN_TO_CALL");
+ // Fire off an intent to go back to the in-call UI
+ // (with the dialpad hidden).
+ returnToInCallScreen(false);
+ break;
+
+ case DialpadChooserAdapter.DIALPAD_CHOICE_ADD_NEW_CALL:
+ // Log.i(TAG, "DIALPAD_CHOICE_ADD_NEW_CALL");
+ // Ok, guess the user really did want to be here (in the
+ // regular Dialer) after all. Bring back the normal Dialer UI.
+ showDialpadChooser(false);
+ break;
+
+ default:
+ Log.w(TAG, "onItemClick: unexpected itemId: " + itemId);
+ break;
+ }
+ }
+
+ /**
+ * Returns to the in-call UI (where there's presumably a call in
+ * progress) in response to the user selecting "use touch tone keypad"
+ * or "return to call" from the dialpad chooser.
+ */
+ private void returnToInCallScreen(boolean showDialpad) {
+ try {
+ ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone"));
+ if (phone != null) phone.showCallScreenWithDialpad(showDialpad);
+ } catch (RemoteException e) {
+ Log.w(TAG, "phone.showCallScreenWithDialpad() failed", e);
+ }
+
+ // Finally, finish() ourselves so that we don't stay on the
+ // activity stack.
+ // Note that we do this whether or not the showCallScreenWithDialpad()
+ // call above had any effect or not! (That call is a no-op if the
+ // phone is idle, which can happen if the current call ends while
+ // the dialpad chooser is up. In this case we can't show the
+ // InCallScreen, and there's no point staying here in the Dialer,
+ // so we just take the user back where he came from...)
+ getActivity().finish();
+ }
+
+ /**
+ * @return true if the phone is "in use", meaning that at least one line
+ * is active (ie. off hook or ringing or dialing).
+ */
+ public static boolean phoneIsInUse() {
+ boolean phoneInUse = false;
+ try {
+ ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone"));
+ if (phone != null) phoneInUse = !phone.isIdle();
+ } catch (RemoteException e) {
+ Log.w(TAG, "phone.isIdle() failed", e);
+ }
+ return phoneInUse;
+ }
+
+ /**
+ * @return true if the phone is a CDMA phone type
+ */
+ private boolean phoneIsCdma() {
+ boolean isCdma = false;
+ try {
+ ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone"));
+ if (phone != null) {
+ isCdma = (phone.getActivePhoneType() == TelephonyManager.PHONE_TYPE_CDMA);
+ }
+ } catch (RemoteException e) {
+ Log.w(TAG, "phone.getActivePhoneType() failed", e);
+ }
+ return isCdma;
+ }
+
+ /**
+ * @return true if the phone state is OFFHOOK
+ */
+ private boolean phoneIsOffhook() {
+ boolean phoneOffhook = false;
+ try {
+ ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone"));
+ if (phone != null) phoneOffhook = phone.isOffhook();
+ } catch (RemoteException e) {
+ Log.w(TAG, "phone.isOffhook() failed", e);
+ }
+ return phoneOffhook;
+ }
+
+ /**
+ * Returns true whenever any one of the options from the menu is selected.
+ * Code changes to support dialpad options
+ */
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_2s_pause:
+ updateDialString(",");
+ return true;
+ case R.id.menu_add_wait:
+ updateDialString(";");
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ return onOptionsItemSelected(item);
+ }
+
+ /**
+ * Updates the dial string (mDigits) after inserting a Pause character (,)
+ * or Wait character (;).
+ */
+ private void updateDialString(String newDigits) {
+ int selectionStart;
+ int selectionEnd;
+
+ // SpannableStringBuilder editable_text = new SpannableStringBuilder(mDigits.getText());
+ int anchor = mDigits.getSelectionStart();
+ int point = mDigits.getSelectionEnd();
+
+ selectionStart = Math.min(anchor, point);
+ selectionEnd = Math.max(anchor, point);
+
+ Editable digits = mDigits.getText();
+ if (selectionStart != -1) {
+ if (selectionStart == selectionEnd) {
+ // then there is no selection. So insert the pause at this
+ // position and update the mDigits.
+ digits.replace(selectionStart, selectionStart, newDigits);
+ } else {
+ digits.replace(selectionStart, selectionEnd, newDigits);
+ // Unselect: back to a regular cursor, just pass the character inserted.
+ mDigits.setSelection(selectionStart + 1);
+ }
+ } else {
+ int len = mDigits.length();
+ digits.replace(len, len, newDigits);
+ }
+ }
+
+ /**
+ * Update the enabledness of the "Dial" and "Backspace" buttons if applicable.
+ */
+ private void updateDialAndDeleteButtonEnabledState() {
+ final boolean digitsNotEmpty = !isDigitsEmpty();
+
+ if (mDialButton != null) {
+ // On CDMA phones, if we're already on a call, we *always*
+ // enable the Dial button (since you can press it without
+ // entering any digits to send an empty flash.)
+ if (phoneIsCdma() && phoneIsOffhook()) {
+ mDialButton.setEnabled(true);
+ } else {
+ // Common case: GSM, or CDMA but not on a call.
+ // Enable the Dial button if some digits have
+ // been entered, or if there is a last dialed number
+ // that could be redialed.
+ mDialButton.setEnabled(digitsNotEmpty ||
+ !TextUtils.isEmpty(mLastNumberDialed));
+ }
+ }
+ mDelete.setEnabled(digitsNotEmpty);
+ }
+
+ /**
+ * Check if voicemail is enabled/accessible.
+ *
+ * @return true if voicemail is enabled and accessibly. Note that this can be false
+ * "temporarily" after the app boot.
+ * @see TelephonyManager#getVoiceMailNumber()
+ */
+ private boolean isVoicemailAvailable() {
+ try {
+ return (TelephonyManager.getDefault().getVoiceMailNumber() != null);
+ } catch (SecurityException se) {
+ // Possibly no READ_PHONE_STATE privilege.
+ Log.w(TAG, "SecurityException is thrown. Maybe privilege isn't sufficient.");
+ }
+ return false;
+ }
+
+ /**
+ * This function return true if Wait menu item can be shown
+ * otherwise returns false. Assumes the passed string is non-empty
+ * and the 0th index check is not required.
+ */
+ private static boolean showWait(int start, int end, String digits) {
+ if (start == end) {
+ // visible false in this case
+ if (start > digits.length()) return false;
+
+ // preceding char is ';', so visible should be false
+ if (digits.charAt(start - 1) == ';') return false;
+
+ // next char is ';', so visible should be false
+ if ((digits.length() > start) && (digits.charAt(start) == ';')) return false;
+ } else {
+ // visible false in this case
+ if (start > digits.length() || end > digits.length()) return false;
+
+ // In this case we need to just check for ';' preceding to start
+ // or next to end
+ if (digits.charAt(start - 1) == ';') return false;
+ }
+ return true;
+ }
+
+ /**
+ * @return true if the widget with the phone number digits is empty.
+ */
+ private boolean isDigitsEmpty() {
+ return mDigits.length() == 0;
+ }
+
+ /**
+ * Starts the asyn query to get the last dialed/outgoing
+ * number. When the background query finishes, mLastNumberDialed
+ * is set to the last dialed number or an empty string if none
+ * exists yet.
+ */
+ private void queryLastOutgoingCall() {
+ mLastNumberDialed = EMPTY_NUMBER;
+ CallLogAsync.GetLastOutgoingCallArgs lastCallArgs =
+ new CallLogAsync.GetLastOutgoingCallArgs(
+ getActivity(),
+ new CallLogAsync.OnLastOutgoingCallComplete() {
+ @Override
+ public void lastOutgoingCall(String number) {
+ // TODO: Filter out emergency numbers if
+ // the carrier does not want redial for
+ // these.
+ mLastNumberDialed = number;
+ updateDialAndDeleteButtonEnabledState();
+ }
+ });
+ mCallLog.getLastOutgoingCall(lastCallArgs);
+ }
+
+ private Intent newFlashIntent() {
+ final Intent intent = ContactsUtils.getCallIntent(EMPTY_NUMBER);
+ intent.putExtra(EXTRA_SEND_EMPTY_FLASH, true);
+ return intent;
+ }
+}
diff --git a/src/com/android/dialer/dialpad/DialpadImageButton.java b/src/com/android/dialer/dialpad/DialpadImageButton.java
new file mode 100644
index 000000000..d5f825b72
--- /dev/null
+++ b/src/com/android/dialer/dialpad/DialpadImageButton.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2012 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.dialpad;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageButton;
+
+/**
+ * Custom {@link ImageButton} for dialpad buttons.
+ *
+ * During horizontal swipe, we want to exit "fading out" animation offered by its background
+ * just after starting the swipe.This class overrides {@link #onTouchEvent(MotionEvent)} to achieve
+ * the behavior.
+ */
+public class DialpadImageButton extends ImageButton {
+ public interface OnPressedListener {
+ public void onPressed(View view, boolean pressed);
+ }
+
+ private OnPressedListener mOnPressedListener;
+
+ public void setOnPressedListener(OnPressedListener onPressedListener) {
+ mOnPressedListener = onPressedListener;
+ }
+
+ public DialpadImageButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public DialpadImageButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void setPressed(boolean pressed) {
+ super.setPressed(pressed);
+ if (mOnPressedListener != null) {
+ mOnPressedListener.onPressed(this, pressed);
+ }
+ }
+}
diff --git a/src/com/android/dialer/dialpad/DigitsEditText.java b/src/com/android/dialer/dialpad/DigitsEditText.java
new file mode 100644
index 000000000..6ad4df955
--- /dev/null
+++ b/src/com/android/dialer/dialpad/DigitsEditText.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2011 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.dialpad;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+
+/**
+ * EditText which suppresses IME show up.
+ */
+public class DigitsEditText extends EditText {
+ public DigitsEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ setShowSoftInputOnFocus(false);
+ }
+
+ @Override
+ protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
+ super.onFocusChanged(focused, direction, previouslyFocusedRect);
+ final InputMethodManager imm = ((InputMethodManager) getContext()
+ .getSystemService(Context.INPUT_METHOD_SERVICE));
+ if (imm != null && imm.isActive(this)) {
+ imm.hideSoftInputFromWindow(getApplicationWindowToken(), 0);
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ final boolean ret = super.onTouchEvent(event);
+ // Must be done after super.onTouchEvent()
+ final InputMethodManager imm = ((InputMethodManager) getContext()
+ .getSystemService(Context.INPUT_METHOD_SERVICE));
+ if (imm != null && imm.isActive(this)) {
+ imm.hideSoftInputFromWindow(getApplicationWindowToken(), 0);
+ }
+ return ret;
+ }
+
+ @Override
+ public void sendAccessibilityEventUnchecked(AccessibilityEvent event) {
+ if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) {
+ // Since we're replacing the text every time we add or remove a
+ // character, only read the difference. (issue 5337550)
+ final int added = event.getAddedCount();
+ final int removed = event.getRemovedCount();
+ final int length = event.getBeforeText().length();
+ if (added > removed) {
+ event.setRemovedCount(0);
+ event.setAddedCount(1);
+ event.setFromIndex(length);
+ } else if (removed > added) {
+ event.setRemovedCount(1);
+ event.setAddedCount(0);
+ event.setFromIndex(length - 1);
+ } else {
+ return;
+ }
+ } else if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
+ // The parent EditText class lets tts read "edit box" when this View has a focus, which
+ // confuses users on app launch (issue 5275935).
+ return;
+ }
+ super.sendAccessibilityEventUnchecked(event);
+ }
+}
diff --git a/src/com/android/dialer/util/ExpirableCache.java b/src/com/android/dialer/util/ExpirableCache.java
new file mode 100644
index 000000000..2b4e4393b
--- /dev/null
+++ b/src/com/android/dialer/util/ExpirableCache.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright (C) 2011 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.util;
+
+import android.util.LruCache;
+
+import com.android.contacts.test.NeededForTesting;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.concurrent.Immutable;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * An LRU cache in which all items can be marked as expired at a given time and it is possible to
+ * query whether a particular cached value is expired or not.
+ * <p>
+ * A typical use case for this is caching of values which are expensive to compute but which are
+ * still useful when out of date.
+ * <p>
+ * Consider a cache for contact information:
+ * <pre>{@code
+ * private ExpirableCache<String, Contact> mContactCache;}</pre>
+ * which stores the contact information for a given phone number.
+ * <p>
+ * When we need to store contact information for a given phone number, we can look up the info in
+ * the cache:
+ * <pre>{@code
+ * CachedValue<Contact> cachedContact = mContactCache.getCachedValue(phoneNumber);
+ * }</pre>
+ * We might also want to fetch the contact information again if the item is expired.
+ * <pre>
+ * if (cachedContact.isExpired()) {
+ * fetchContactForNumber(phoneNumber,
+ * new FetchListener() {
+ * &#64;Override
+ * public void onFetched(Contact contact) {
+ * mContactCache.put(phoneNumber, contact);
+ * }
+ * });
+ * }</pre>
+ * and insert it back into the cache when the fetch completes.
+ * <p>
+ * At a certain point we want to expire the content of the cache because we know the content may
+ * no longer be up-to-date, for instance, when resuming the activity this is shown into:
+ * <pre>
+ * &#64;Override
+ * protected onResume() {
+ * // We were paused for some time, the cached value might no longer be up to date.
+ * mContactCache.expireAll();
+ * super.onResume();
+ * }
+ * </pre>
+ * The values will be still available from the cache, but they will be expired.
+ * <p>
+ * If interested only in the value itself, not whether it is expired or not, one should use the
+ * {@link #getPossiblyExpired(Object)} method. If interested only in non-expired values, one should
+ * use the {@link #get(Object)} method instead.
+ * <p>
+ * This class wraps around an {@link LruCache} instance: it follows the {@link LruCache} behavior
+ * for evicting items when the cache is full. It is possible to supply your own subclass of LruCache
+ * by using the {@link #create(LruCache)} method, which can define a custom expiration policy.
+ * Since the underlying cache maps keys to cached values it can determine which items are expired
+ * and which are not, allowing for an implementation that evicts expired items before non expired
+ * ones.
+ * <p>
+ * This class is thread-safe.
+ *
+ * @param <K> the type of the keys
+ * @param <V> the type of the values
+ */
+@ThreadSafe
+public class ExpirableCache<K, V> {
+ /**
+ * A cached value stored inside the cache.
+ * <p>
+ * It provides access to the value stored in the cache but also allows to check whether the
+ * value is expired.
+ *
+ * @param <V> the type of value stored in the cache
+ */
+ public interface CachedValue<V> {
+ /** Returns the value stored in the cache for a given key. */
+ public V getValue();
+
+ /**
+ * Checks whether the value, while still being present in the cache, is expired.
+ *
+ * @return true if the value is expired
+ */
+ public boolean isExpired();
+ }
+
+ /**
+ * Cached values storing the generation at which they were added.
+ */
+ @Immutable
+ private static class GenerationalCachedValue<V> implements ExpirableCache.CachedValue<V> {
+ /** The value stored in the cache. */
+ public final V mValue;
+ /** The generation at which the value was added to the cache. */
+ private final int mGeneration;
+ /** The atomic integer storing the current generation of the cache it belongs to. */
+ private final AtomicInteger mCacheGeneration;
+
+ /**
+ * @param cacheGeneration the atomic integer storing the generation of the cache in which
+ * this value will be stored
+ */
+ public GenerationalCachedValue(V value, AtomicInteger cacheGeneration) {
+ mValue = value;
+ mCacheGeneration = cacheGeneration;
+ // Snapshot the current generation.
+ mGeneration = mCacheGeneration.get();
+ }
+
+ @Override
+ public V getValue() {
+ return mValue;
+ }
+
+ @Override
+ public boolean isExpired() {
+ return mGeneration != mCacheGeneration.get();
+ }
+ }
+
+ /** The underlying cache used to stored the cached values. */
+ private LruCache<K, CachedValue<V>> mCache;
+
+ /**
+ * The current generation of items added to the cache.
+ * <p>
+ * Items in the cache can belong to a previous generation, but in that case they would be
+ * expired.
+ *
+ * @see ExpirableCache.CachedValue#isExpired()
+ */
+ private final AtomicInteger mGeneration;
+
+ private ExpirableCache(LruCache<K, CachedValue<V>> cache) {
+ mCache = cache;
+ mGeneration = new AtomicInteger(0);
+ }
+
+ /**
+ * Returns the cached value for the given key, or null if no value exists.
+ * <p>
+ * The cached value gives access both to the value associated with the key and whether it is
+ * expired or not.
+ * <p>
+ * If not interested in whether the value is expired, use {@link #getPossiblyExpired(Object)}
+ * instead.
+ * <p>
+ * If only wants values that are not expired, use {@link #get(Object)} instead.
+ *
+ * @param key the key to look up
+ */
+ public CachedValue<V> getCachedValue(K key) {
+ return mCache.get(key);
+ }
+
+ /**
+ * Returns the value for the given key, or null if no value exists.
+ * <p>
+ * When using this method, it is not possible to determine whether the value is expired or not.
+ * Use {@link #getCachedValue(Object)} to achieve that instead. However, if using
+ * {@link #getCachedValue(Object)} to determine if an item is expired, one should use the item
+ * within the {@link CachedValue} and not call {@link #getPossiblyExpired(Object)} to get the
+ * value afterwards, since that is not guaranteed to return the same value or that the newly
+ * returned value is in the same state.
+ *
+ * @param key the key to look up
+ */
+ public V getPossiblyExpired(K key) {
+ CachedValue<V> cachedValue = getCachedValue(key);
+ return cachedValue == null ? null : cachedValue.getValue();
+ }
+
+ /**
+ * Returns the value for the given key only if it is not expired, or null if no value exists or
+ * is expired.
+ * <p>
+ * This method will return null if either there is no value associated with this key or if the
+ * associated value is expired.
+ *
+ * @param key the key to look up
+ */
+ @NeededForTesting
+ public V get(K key) {
+ CachedValue<V> cachedValue = getCachedValue(key);
+ return cachedValue == null || cachedValue.isExpired() ? null : cachedValue.getValue();
+ }
+
+ /**
+ * Puts an item in the cache.
+ * <p>
+ * Newly added item will not be expired until {@link #expireAll()} is next called.
+ *
+ * @param key the key to look up
+ * @param value the value to associate with the key
+ */
+ public void put(K key, V value) {
+ mCache.put(key, newCachedValue(value));
+ }
+
+ /**
+ * Mark all items currently in the cache as expired.
+ * <p>
+ * Newly added items after this call will be marked as not expired.
+ * <p>
+ * Expiring the items in the cache does not imply they will be evicted.
+ */
+ public void expireAll() {
+ mGeneration.incrementAndGet();
+ }
+
+ /**
+ * Creates a new {@link CachedValue} instance to be stored in this cache.
+ * <p>
+ * Implementation of {@link LruCache#create(K)} can use this method to create a new entry.
+ */
+ public CachedValue<V> newCachedValue(V value) {
+ return new GenerationalCachedValue<V>(value, mGeneration);
+ }
+
+ /**
+ * Creates a new {@link ExpirableCache} that wraps the given {@link LruCache}.
+ * <p>
+ * The created cache takes ownership of the cache passed in as an argument.
+ *
+ * @param <K> the type of the keys
+ * @param <V> the type of the values
+ * @param cache the cache to store the value in
+ * @return the newly created expirable cache
+ * @throws IllegalArgumentException if the cache is not empty
+ */
+ public static <K, V> ExpirableCache<K, V> create(LruCache<K, CachedValue<V>> cache) {
+ return new ExpirableCache<K, V>(cache);
+ }
+
+ /**
+ * Creates a new {@link ExpirableCache} with the given maximum size.
+ *
+ * @param <K> the type of the keys
+ * @param <V> the type of the values
+ * @return the newly created expirable cache
+ */
+ public static <K, V> ExpirableCache<K, V> create(int maxSize) {
+ return create(new LruCache<K, CachedValue<V>>(maxSize));
+ }
+}
diff --git a/src/com/android/dialer/voicemail/VoicemailPlaybackFragment.java b/src/com/android/dialer/voicemail/VoicemailPlaybackFragment.java
new file mode 100644
index 000000000..473d40bc6
--- /dev/null
+++ b/src/com/android/dialer/voicemail/VoicemailPlaybackFragment.java
@@ -0,0 +1,474 @@
+/*
+ * Copyright (C) 2011 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.voicemail;
+
+import static com.android.dialer.CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK;
+import static com.android.dialer.CallDetailActivity.EXTRA_VOICEMAIL_URI;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.PowerManager;
+import android.provider.VoicemailContract;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import com.android.common.io.MoreCloseables;
+import com.android.contacts.ProximitySensorAware;
+import com.android.contacts.R;
+import com.android.contacts.util.AsyncTaskExecutors;
+import com.android.ex.variablespeed.MediaPlayerProxy;
+import com.android.ex.variablespeed.VariableSpeed;
+import com.google.common.base.Preconditions;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.NotThreadSafe;
+
+/**
+ * Displays and plays back a single voicemail.
+ * <p>
+ * When the Activity containing this Fragment is created, voicemail playback
+ * will begin immediately. The Activity is expected to be started via an intent
+ * containing a suitable voicemail uri to playback.
+ * <p>
+ * This class is not thread-safe, it is thread-confined. All calls to all public
+ * methods on this class are expected to come from the main ui thread.
+ */
+@NotThreadSafe
+public class VoicemailPlaybackFragment extends Fragment {
+ private static final String TAG = "VoicemailPlayback";
+ private static final int NUMBER_OF_THREADS_IN_POOL = 2;
+ private static final String[] HAS_CONTENT_PROJECTION = new String[] {
+ VoicemailContract.Voicemails.HAS_CONTENT,
+ };
+
+ private VoicemailPlaybackPresenter mPresenter;
+ private ScheduledExecutorService mScheduledExecutorService;
+ private View mPlaybackLayout;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ mPlaybackLayout = inflater.inflate(R.layout.playback_layout, null);
+ return mPlaybackLayout;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mScheduledExecutorService = createScheduledExecutorService();
+ Bundle arguments = getArguments();
+ Preconditions.checkNotNull(arguments, "fragment must be started with arguments");
+ Uri voicemailUri = arguments.getParcelable(EXTRA_VOICEMAIL_URI);
+ Preconditions.checkNotNull(voicemailUri, "fragment must contain EXTRA_VOICEMAIL_URI");
+ boolean startPlayback = arguments.getBoolean(EXTRA_VOICEMAIL_START_PLAYBACK, false);
+ PowerManager powerManager =
+ (PowerManager) getActivity().getSystemService(Context.POWER_SERVICE);
+ PowerManager.WakeLock wakeLock =
+ powerManager.newWakeLock(
+ PowerManager.SCREEN_DIM_WAKE_LOCK, getClass().getSimpleName());
+ mPresenter = new VoicemailPlaybackPresenter(createPlaybackViewImpl(),
+ createMediaPlayer(mScheduledExecutorService), voicemailUri,
+ mScheduledExecutorService, startPlayback,
+ AsyncTaskExecutors.createAsyncTaskExecutor(), wakeLock);
+ mPresenter.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ mPresenter.onSaveInstanceState(outState);
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onDestroy() {
+ mPresenter.onDestroy();
+ mScheduledExecutorService.shutdown();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onPause() {
+ mPresenter.onPause();
+ super.onPause();
+ }
+
+ private PlaybackViewImpl createPlaybackViewImpl() {
+ return new PlaybackViewImpl(new ActivityReference(), getActivity().getApplicationContext(),
+ mPlaybackLayout);
+ }
+
+ private MediaPlayerProxy createMediaPlayer(ExecutorService executorService) {
+ return VariableSpeed.createVariableSpeed(executorService);
+ }
+
+ private ScheduledExecutorService createScheduledExecutorService() {
+ return Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL);
+ }
+
+ /**
+ * Formats a number of milliseconds as something that looks like {@code 00:05}.
+ * <p>
+ * We always use four digits, two for minutes two for seconds. In the very unlikely event
+ * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
+ */
+ private static String formatAsMinutesAndSeconds(int millis) {
+ int seconds = millis / 1000;
+ int minutes = seconds / 60;
+ seconds -= minutes * 60;
+ if (minutes > 99) {
+ minutes = 99;
+ }
+ return String.format("%02d:%02d", minutes, seconds);
+ }
+
+ /**
+ * An object that can provide us with an Activity.
+ * <p>
+ * Fragments suffer the drawback that the Activity they belong to may sometimes be null. This
+ * can happen if the Fragment is detached, for example. In that situation a call to
+ * {@link Fragment#getString(int)} will throw and {@link IllegalStateException}. Also, calling
+ * {@link Fragment#getActivity()} is dangerous - it may sometimes return null. And thus blindly
+ * calling a method on the result of getActivity() is dangerous too.
+ * <p>
+ * To work around this, I have made the {@link PlaybackViewImpl} class static, so that it does
+ * not have access to any Fragment methods directly. Instead it uses an application Context for
+ * things like accessing strings, accessing system services. It only uses the Activity when it
+ * absolutely needs it - and does so through this class. This makes it easy to see where we have
+ * to check for null properly.
+ */
+ private final class ActivityReference {
+ /** Gets this Fragment's Activity: <b>may be null</b>. */
+ public final Activity get() {
+ return getActivity();
+ }
+ }
+
+ /** Methods required by the PlaybackView for the VoicemailPlaybackPresenter. */
+ private static final class PlaybackViewImpl implements VoicemailPlaybackPresenter.PlaybackView {
+ private final ActivityReference mActivityReference;
+ private final Context mApplicationContext;
+ private final SeekBar mPlaybackSeek;
+ private final ImageButton mStartStopButton;
+ private final ImageButton mPlaybackSpeakerphone;
+ private final ImageButton mRateDecreaseButton;
+ private final ImageButton mRateIncreaseButton;
+ private final TextViewWithMessagesController mTextController;
+
+ public PlaybackViewImpl(ActivityReference activityReference, Context applicationContext,
+ View playbackLayout) {
+ Preconditions.checkNotNull(activityReference);
+ Preconditions.checkNotNull(applicationContext);
+ Preconditions.checkNotNull(playbackLayout);
+ mActivityReference = activityReference;
+ mApplicationContext = applicationContext;
+ mPlaybackSeek = (SeekBar) playbackLayout.findViewById(R.id.playback_seek);
+ mStartStopButton = (ImageButton) playbackLayout.findViewById(
+ R.id.playback_start_stop);
+ mPlaybackSpeakerphone = (ImageButton) playbackLayout.findViewById(
+ R.id.playback_speakerphone);
+ mRateDecreaseButton = (ImageButton) playbackLayout.findViewById(
+ R.id.rate_decrease_button);
+ mRateIncreaseButton = (ImageButton) playbackLayout.findViewById(
+ R.id.rate_increase_button);
+ mTextController = new TextViewWithMessagesController(
+ (TextView) playbackLayout.findViewById(R.id.playback_position_text),
+ (TextView) playbackLayout.findViewById(R.id.playback_speed_text));
+ }
+
+ @Override
+ public void finish() {
+ Activity activity = mActivityReference.get();
+ if (activity != null) {
+ activity.finish();
+ }
+ }
+
+ @Override
+ public void runOnUiThread(Runnable runnable) {
+ Activity activity = mActivityReference.get();
+ if (activity != null) {
+ activity.runOnUiThread(runnable);
+ }
+ }
+
+ @Override
+ public Context getDataSourceContext() {
+ return mApplicationContext;
+ }
+
+ @Override
+ public void setRateDecreaseButtonListener(View.OnClickListener listener) {
+ mRateDecreaseButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setRateIncreaseButtonListener(View.OnClickListener listener) {
+ mRateIncreaseButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setStartStopListener(View.OnClickListener listener) {
+ mStartStopButton.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setSpeakerphoneListener(View.OnClickListener listener) {
+ mPlaybackSpeakerphone.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setRateDisplay(float rate, int stringResourceId) {
+ mTextController.setTemporaryText(
+ mApplicationContext.getString(stringResourceId), 1, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener) {
+ mPlaybackSeek.setOnSeekBarChangeListener(listener);
+ }
+
+ @Override
+ public void playbackStarted() {
+ mStartStopButton.setImageResource(R.drawable.ic_hold_pause_holo_dark);
+ }
+
+ @Override
+ public void playbackStopped() {
+ mStartStopButton.setImageResource(R.drawable.ic_play);
+ }
+
+ @Override
+ public void enableProximitySensor() {
+ // Only change the state if the activity is still around.
+ Activity activity = mActivityReference.get();
+ if (activity != null && activity instanceof ProximitySensorAware) {
+ ((ProximitySensorAware) activity).enableProximitySensor();
+ }
+ }
+
+ @Override
+ public void disableProximitySensor() {
+ // Only change the state if the activity is still around.
+ Activity activity = mActivityReference.get();
+ if (activity != null && activity instanceof ProximitySensorAware) {
+ ((ProximitySensorAware) activity).disableProximitySensor(true);
+ }
+ }
+
+ @Override
+ public void registerContentObserver(Uri uri, ContentObserver observer) {
+ mApplicationContext.getContentResolver().registerContentObserver(uri, false, observer);
+ }
+
+ @Override
+ public void unregisterContentObserver(ContentObserver observer) {
+ mApplicationContext.getContentResolver().unregisterContentObserver(observer);
+ }
+
+ @Override
+ public void setClipPosition(int clipPositionInMillis, int clipLengthInMillis) {
+ int seekBarPosition = Math.max(0, clipPositionInMillis);
+ int seekBarMax = Math.max(seekBarPosition, clipLengthInMillis);
+ if (mPlaybackSeek.getMax() != seekBarMax) {
+ mPlaybackSeek.setMax(seekBarMax);
+ }
+ mPlaybackSeek.setProgress(seekBarPosition);
+ mTextController.setPermanentText(
+ formatAsMinutesAndSeconds(seekBarMax - seekBarPosition));
+ }
+
+ private String getString(int resId) {
+ return mApplicationContext.getString(resId);
+ }
+
+ @Override
+ public void setIsBuffering() {
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_buffering));
+ }
+
+ @Override
+ public void setIsFetchingContent() {
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_fetching_content));
+ }
+
+ @Override
+ public void setFetchContentTimeout() {
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_fetching_timout));
+ }
+
+ @Override
+ public int getDesiredClipPosition() {
+ return mPlaybackSeek.getProgress();
+ }
+
+ @Override
+ public void disableUiElements() {
+ mRateIncreaseButton.setEnabled(false);
+ mRateDecreaseButton.setEnabled(false);
+ mStartStopButton.setEnabled(false);
+ mPlaybackSpeakerphone.setEnabled(false);
+ mPlaybackSeek.setProgress(0);
+ mPlaybackSeek.setEnabled(false);
+ }
+
+ @Override
+ public void playbackError(Exception e) {
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_playback_error));
+ Log.e(TAG, "Could not play voicemail", e);
+ }
+
+ @Override
+ public void enableUiElements() {
+ mRateIncreaseButton.setEnabled(true);
+ mRateDecreaseButton.setEnabled(true);
+ mStartStopButton.setEnabled(true);
+ mPlaybackSpeakerphone.setEnabled(true);
+ mPlaybackSeek.setEnabled(true);
+ }
+
+ @Override
+ public void sendFetchVoicemailRequest(Uri voicemailUri) {
+ Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, voicemailUri);
+ mApplicationContext.sendBroadcast(intent);
+ }
+
+ @Override
+ public boolean queryHasContent(Uri voicemailUri) {
+ ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ Cursor cursor = contentResolver.query(
+ voicemailUri, HAS_CONTENT_PROJECTION, null, null, null);
+ try {
+ if (cursor != null && cursor.moveToNext()) {
+ return cursor.getInt(cursor.getColumnIndexOrThrow(
+ VoicemailContract.Voicemails.HAS_CONTENT)) == 1;
+ }
+ } finally {
+ MoreCloseables.closeQuietly(cursor);
+ }
+ return false;
+ }
+
+ private AudioManager getAudioManager() {
+ return (AudioManager) mApplicationContext.getSystemService(Context.AUDIO_SERVICE);
+ }
+
+ @Override
+ public boolean isSpeakerPhoneOn() {
+ return getAudioManager().isSpeakerphoneOn();
+ }
+
+ @Override
+ public void setSpeakerPhoneOn(boolean on) {
+ getAudioManager().setSpeakerphoneOn(on);
+ if (on) {
+ mPlaybackSpeakerphone.setImageResource(R.drawable.ic_speakerphone_on);
+ } else {
+ mPlaybackSpeakerphone.setImageResource(R.drawable.ic_speakerphone_off);
+ }
+ }
+
+ @Override
+ public void setVolumeControlStream(int streamType) {
+ Activity activity = mActivityReference.get();
+ if (activity != null) {
+ activity.setVolumeControlStream(streamType);
+ }
+ }
+ }
+
+ /**
+ * Controls a TextView with dynamically changing text.
+ * <p>
+ * There are two methods here of interest,
+ * {@link TextViewWithMessagesController#setPermanentText(String)} and
+ * {@link TextViewWithMessagesController#setTemporaryText(String, long, TimeUnit)}. The
+ * former is used to set the text on the text view immediately, and is used in our case for
+ * the countdown of duration remaining during voicemail playback. The second is used to
+ * temporarily replace this countdown with a message, in our case faster voicemail speed or
+ * slower voicemail speed, before returning to the countdown display.
+ * <p>
+ * All the methods on this class must be called from the ui thread.
+ */
+ private static final class TextViewWithMessagesController {
+ private static final float VISIBLE = 1;
+ private static final float INVISIBLE = 0;
+ private static final long SHORT_ANIMATION_MS = 200;
+ private static final long LONG_ANIMATION_MS = 400;
+ private final Object mLock = new Object();
+ private final TextView mPermanentTextView;
+ private final TextView mTemporaryTextView;
+ @GuardedBy("mLock") private Runnable mRunnable;
+
+ public TextViewWithMessagesController(TextView permanentTextView,
+ TextView temporaryTextView) {
+ mPermanentTextView = permanentTextView;
+ mTemporaryTextView = temporaryTextView;
+ }
+
+ public void setPermanentText(String text) {
+ mPermanentTextView.setText(text);
+ }
+
+ public void setTemporaryText(String text, long duration, TimeUnit units) {
+ synchronized (mLock) {
+ mTemporaryTextView.setText(text);
+ mTemporaryTextView.animate().alpha(VISIBLE).setDuration(SHORT_ANIMATION_MS);
+ mPermanentTextView.animate().alpha(INVISIBLE).setDuration(SHORT_ANIMATION_MS);
+ mRunnable = new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mLock) {
+ // We check for (mRunnable == this) becuase if not true, then another
+ // setTemporaryText call has taken place in the meantime, and this
+ // one is now defunct and needs to take no action.
+ if (mRunnable == this) {
+ mRunnable = null;
+ mTemporaryTextView.animate()
+ .alpha(INVISIBLE).setDuration(LONG_ANIMATION_MS);
+ mPermanentTextView.animate()
+ .alpha(VISIBLE).setDuration(LONG_ANIMATION_MS);
+ }
+ }
+ }
+ };
+ mTemporaryTextView.postDelayed(mRunnable, units.toMillis(duration));
+ }
+ }
+ }
+}
diff --git a/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java b/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java
new file mode 100644
index 000000000..93b60de1d
--- /dev/null
+++ b/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java
@@ -0,0 +1,630 @@
+/*
+ * Copyright (C) 2011 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.voicemail;
+
+import static android.util.MathUtils.constrain;
+
+import android.content.Context;
+import android.database.ContentObserver;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.PowerManager;
+import android.view.View;
+import android.widget.SeekBar;
+
+import com.android.contacts.R;
+import com.android.contacts.util.AsyncTaskExecutor;
+import com.android.ex.variablespeed.MediaPlayerProxy;
+import com.android.ex.variablespeed.SingleThreadedMediaPlayerProxy;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.NotThreadSafe;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * Contains the controlling logic for a voicemail playback ui.
+ * <p>
+ * Specifically right now this class is used to control the
+ * {@link com.android.dialer.voicemail.VoicemailPlaybackFragment}.
+ * <p>
+ * This class is not thread safe. The thread policy for this class is
+ * thread-confinement, all calls into this class from outside must be done from
+ * the main ui thread.
+ */
+@NotThreadSafe
+@VisibleForTesting
+public class VoicemailPlaybackPresenter {
+ /** The stream used to playback voicemail. */
+ private static final int PLAYBACK_STREAM = AudioManager.STREAM_VOICE_CALL;
+
+ /** Contract describing the behaviour we need from the ui we are controlling. */
+ public interface PlaybackView {
+ Context getDataSourceContext();
+ void runOnUiThread(Runnable runnable);
+ void setStartStopListener(View.OnClickListener listener);
+ void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener);
+ void setSpeakerphoneListener(View.OnClickListener listener);
+ void setIsBuffering();
+ void setClipPosition(int clipPositionInMillis, int clipLengthInMillis);
+ int getDesiredClipPosition();
+ void playbackStarted();
+ void playbackStopped();
+ void playbackError(Exception e);
+ boolean isSpeakerPhoneOn();
+ void setSpeakerPhoneOn(boolean on);
+ void finish();
+ void setRateDisplay(float rate, int stringResourceId);
+ void setRateIncreaseButtonListener(View.OnClickListener listener);
+ void setRateDecreaseButtonListener(View.OnClickListener listener);
+ void setIsFetchingContent();
+ void disableUiElements();
+ void enableUiElements();
+ void sendFetchVoicemailRequest(Uri voicemailUri);
+ boolean queryHasContent(Uri voicemailUri);
+ void setFetchContentTimeout();
+ void registerContentObserver(Uri uri, ContentObserver observer);
+ void unregisterContentObserver(ContentObserver observer);
+ void enableProximitySensor();
+ void disableProximitySensor();
+ void setVolumeControlStream(int streamType);
+ }
+
+ /** The enumeration of {@link AsyncTask} objects we use in this class. */
+ public enum Tasks {
+ CHECK_FOR_CONTENT,
+ CHECK_CONTENT_AFTER_CHANGE,
+ PREPARE_MEDIA_PLAYER,
+ RESET_PREPARE_START_MEDIA_PLAYER,
+ }
+
+ /** Update rate for the slider, 30fps. */
+ private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
+ /** Time our ui will wait for content to be fetched before reporting not available. */
+ private static final long FETCH_CONTENT_TIMEOUT_MS = 20000;
+ /**
+ * If present in the saved instance bundle, we should not resume playback on
+ * create.
+ */
+ private static final String PAUSED_STATE_KEY = VoicemailPlaybackPresenter.class.getName()
+ + ".PAUSED_STATE_KEY";
+ /**
+ * If present in the saved instance bundle, indicates where to set the
+ * playback slider.
+ */
+ private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName()
+ + ".CLIP_POSITION_KEY";
+
+ /** The preset variable-speed rates. Each is greater than the previous by 25%. */
+ private static final float[] PRESET_RATES = new float[] {
+ 0.64f, 0.8f, 1.0f, 1.25f, 1.5625f
+ };
+ /** The string resource ids corresponding to the names given to the above preset rates. */
+ private static final int[] PRESET_NAMES = new int[] {
+ R.string.voicemail_speed_slowest,
+ R.string.voicemail_speed_slower,
+ R.string.voicemail_speed_normal,
+ R.string.voicemail_speed_faster,
+ R.string.voicemail_speed_fastest,
+ };
+
+ /**
+ * Pointer into the {@link VoicemailPlaybackPresenter#PRESET_RATES} array.
+ * <p>
+ * This doesn't need to be synchronized, it's used only by the {@link RateChangeListener}
+ * which in turn is only executed on the ui thread. This can't be encapsulated inside the
+ * rate change listener since multiple rate change listeners must share the same value.
+ */
+ private int mRateIndex = 2;
+
+ /**
+ * The most recently calculated duration.
+ * <p>
+ * We cache this in a field since we don't want to keep requesting it from the player, as
+ * this can easily lead to throwing {@link IllegalStateException} (any time the player is
+ * released, it's illegal to ask for the duration).
+ */
+ private final AtomicInteger mDuration = new AtomicInteger(0);
+
+ private final PlaybackView mView;
+ private final MediaPlayerProxy mPlayer;
+ private final PositionUpdater mPositionUpdater;
+
+ /** Voicemail uri to play. */
+ private final Uri mVoicemailUri;
+ /** Start playing in onCreate iff this is true. */
+ private final boolean mStartPlayingImmediately;
+ /** Used to run async tasks that need to interact with the ui. */
+ private final AsyncTaskExecutor mAsyncTaskExecutor;
+
+ /**
+ * Used to handle the result of a successful or time-out fetch result.
+ * <p>
+ * This variable is thread-contained, accessed only on the ui thread.
+ */
+ private FetchResultHandler mFetchResultHandler;
+ private PowerManager.WakeLock mWakeLock;
+ private AsyncTask<Void, ?, ?> mPrepareTask;
+
+ public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player,
+ Uri voicemailUri, ScheduledExecutorService executorService,
+ boolean startPlayingImmediately, AsyncTaskExecutor asyncTaskExecutor,
+ PowerManager.WakeLock wakeLock) {
+ mView = view;
+ mPlayer = player;
+ mVoicemailUri = voicemailUri;
+ mStartPlayingImmediately = startPlayingImmediately;
+ mAsyncTaskExecutor = asyncTaskExecutor;
+ mPositionUpdater = new PositionUpdater(executorService, SLIDER_UPDATE_PERIOD_MILLIS);
+ mWakeLock = wakeLock;
+ }
+
+ public void onCreate(Bundle bundle) {
+ mView.setVolumeControlStream(PLAYBACK_STREAM);
+ checkThatWeHaveContent();
+ }
+
+ /**
+ * Checks to see if we have content available for this voicemail.
+ * <p>
+ * This method will be called once, after the fragment has been created, before we know if the
+ * voicemail we've been asked to play has any content available.
+ * <p>
+ * This method will notify the user through the ui that we are fetching the content, then check
+ * to see if the content field in the db is set. If set, we proceed to
+ * {@link #postSuccessfullyFetchedContent()} method. If not set, we will make a request to fetch
+ * the content asynchronously via {@link #makeRequestForContent()}.
+ */
+ private void checkThatWeHaveContent() {
+ mView.setIsFetchingContent();
+ mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() {
+ @Override
+ public Boolean doInBackground(Void... params) {
+ return mView.queryHasContent(mVoicemailUri);
+ }
+
+ @Override
+ public void onPostExecute(Boolean hasContent) {
+ if (hasContent) {
+ postSuccessfullyFetchedContent();
+ } else {
+ makeRequestForContent();
+ }
+ }
+ });
+ }
+
+ /**
+ * Makes a broadcast request to ask that a voicemail source fetch this content.
+ * <p>
+ * This method <b>must be called on the ui thread</b>.
+ * <p>
+ * This method will be called when we realise that we don't have content for this voicemail. It
+ * will trigger a broadcast to request that the content be downloaded. It will add a listener to
+ * the content resolver so that it will be notified when the has_content field changes. It will
+ * also set a timer. If the has_content field changes to true within the allowed time, we will
+ * proceed to {@link #postSuccessfullyFetchedContent()}. If the has_content field does not
+ * become true within the allowed time, we will update the ui to reflect the fact that content
+ * was not available.
+ */
+ private void makeRequestForContent() {
+ Handler handler = new Handler();
+ Preconditions.checkState(mFetchResultHandler == null, "mFetchResultHandler should be null");
+ mFetchResultHandler = new FetchResultHandler(handler);
+ mView.registerContentObserver(mVoicemailUri, mFetchResultHandler);
+ handler.postDelayed(mFetchResultHandler.getTimeoutRunnable(), FETCH_CONTENT_TIMEOUT_MS);
+ mView.sendFetchVoicemailRequest(mVoicemailUri);
+ }
+
+ @ThreadSafe
+ private class FetchResultHandler extends ContentObserver implements Runnable {
+ private AtomicBoolean mResultStillPending = new AtomicBoolean(true);
+ private final Handler mHandler;
+
+ public FetchResultHandler(Handler handler) {
+ super(handler);
+ mHandler = handler;
+ }
+
+ public Runnable getTimeoutRunnable() {
+ return this;
+ }
+
+ @Override
+ public void run() {
+ if (mResultStillPending.getAndSet(false)) {
+ mView.unregisterContentObserver(FetchResultHandler.this);
+ mView.setFetchContentTimeout();
+ }
+ }
+
+ public void destroy() {
+ if (mResultStillPending.getAndSet(false)) {
+ mView.unregisterContentObserver(FetchResultHandler.this);
+ mHandler.removeCallbacks(this);
+ }
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE,
+ new AsyncTask<Void, Void, Boolean>() {
+ @Override
+ public Boolean doInBackground(Void... params) {
+ return mView.queryHasContent(mVoicemailUri);
+ }
+
+ @Override
+ public void onPostExecute(Boolean hasContent) {
+ if (hasContent) {
+ if (mResultStillPending.getAndSet(false)) {
+ mView.unregisterContentObserver(FetchResultHandler.this);
+ postSuccessfullyFetchedContent();
+ }
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Prepares the voicemail content for playback.
+ * <p>
+ * This method will be called once we know that our voicemail has content (according to the
+ * content provider). This method will try to prepare the data source through the media player.
+ * If preparing the media player works, we will call through to
+ * {@link #postSuccessfulPrepareActions()}. If preparing the media player fails (perhaps the
+ * file the content provider points to is actually missing, perhaps it is of an unknown file
+ * format that we can't play, who knows) then we will show an error on the ui.
+ */
+ private void postSuccessfullyFetchedContent() {
+ mView.setIsBuffering();
+ mAsyncTaskExecutor.submit(Tasks.PREPARE_MEDIA_PLAYER,
+ new AsyncTask<Void, Void, Exception>() {
+ @Override
+ public Exception doInBackground(Void... params) {
+ try {
+ mPlayer.reset();
+ mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
+ mPlayer.setAudioStreamType(PLAYBACK_STREAM);
+ mPlayer.prepare();
+ return null;
+ } catch (Exception e) {
+ return e;
+ }
+ }
+
+ @Override
+ public void onPostExecute(Exception exception) {
+ if (exception == null) {
+ postSuccessfulPrepareActions();
+ } else {
+ mView.playbackError(exception);
+ }
+ }
+ });
+ }
+
+ /**
+ * Enables the ui, and optionally starts playback immediately.
+ * <p>
+ * This will be called once we have successfully prepared the media player, and will optionally
+ * playback immediately.
+ */
+ private void postSuccessfulPrepareActions() {
+ mView.enableUiElements();
+ mView.setPositionSeekListener(new PlaybackPositionListener());
+ mView.setStartStopListener(new StartStopButtonListener());
+ mView.setSpeakerphoneListener(new SpeakerphoneListener());
+ mPlayer.setOnErrorListener(new MediaPlayerErrorListener());
+ mPlayer.setOnCompletionListener(new MediaPlayerCompletionListener());
+ mView.setSpeakerPhoneOn(mView.isSpeakerPhoneOn());
+ mView.setRateDecreaseButtonListener(createRateDecreaseListener());
+ mView.setRateIncreaseButtonListener(createRateIncreaseListener());
+ mView.setClipPosition(0, mPlayer.getDuration());
+ mView.playbackStopped();
+ // Always disable on stop.
+ mView.disableProximitySensor();
+ if (mStartPlayingImmediately) {
+ resetPrepareStartPlaying(0);
+ }
+ // TODO: Now I'm ignoring the bundle, when previously I was checking for contains against
+ // the PAUSED_STATE_KEY, and CLIP_POSITION_KEY.
+ }
+
+ public void onSaveInstanceState(Bundle outState) {
+ outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition());
+ if (!mPlayer.isPlaying()) {
+ outState.putBoolean(PAUSED_STATE_KEY, true);
+ }
+ }
+
+ public void onDestroy() {
+ mPlayer.release();
+ if (mFetchResultHandler != null) {
+ mFetchResultHandler.destroy();
+ mFetchResultHandler = null;
+ }
+ mPositionUpdater.stopUpdating();
+ if (mWakeLock.isHeld()) {
+ mWakeLock.release();
+ }
+ }
+
+ private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener {
+ @Override
+ public boolean onError(MediaPlayer mp, int what, int extra) {
+ mView.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ handleError(new IllegalStateException("MediaPlayer error listener invoked"));
+ }
+ });
+ return true;
+ }
+ }
+
+ private class MediaPlayerCompletionListener implements MediaPlayer.OnCompletionListener {
+ @Override
+ public void onCompletion(final MediaPlayer mp) {
+ mView.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ handleCompletion(mp);
+ }
+ });
+ }
+ }
+
+ public View.OnClickListener createRateDecreaseListener() {
+ return new RateChangeListener(false);
+ }
+
+ public View.OnClickListener createRateIncreaseListener() {
+ return new RateChangeListener(true);
+ }
+
+ /**
+ * Listens to clicks on the rate increase and decrease buttons.
+ * <p>
+ * This class is not thread-safe, but all interactions with it will happen on the ui thread.
+ */
+ private class RateChangeListener implements View.OnClickListener {
+ private final boolean mIncrease;
+
+ public RateChangeListener(boolean increase) {
+ mIncrease = increase;
+ }
+
+ @Override
+ public void onClick(View v) {
+ // Adjust the current rate, then clamp it to the allowed values.
+ mRateIndex = constrain(mRateIndex + (mIncrease ? 1 : -1), 0, PRESET_RATES.length - 1);
+ // Whether or not we have actually changed the index, call changeRate().
+ // This will ensure that we show the "fastest" or "slowest" text on the ui to indicate
+ // to the user that it doesn't get any faster or slower.
+ changeRate(PRESET_RATES[mRateIndex], PRESET_NAMES[mRateIndex]);
+ }
+ }
+
+ private void resetPrepareStartPlaying(final int clipPositionInMillis) {
+ if (mPrepareTask != null) {
+ mPrepareTask.cancel(false);
+ }
+ mPrepareTask = mAsyncTaskExecutor.submit(Tasks.RESET_PREPARE_START_MEDIA_PLAYER,
+ new AsyncTask<Void, Void, Exception>() {
+ @Override
+ public Exception doInBackground(Void... params) {
+ try {
+ mPlayer.reset();
+ mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
+ mPlayer.setAudioStreamType(PLAYBACK_STREAM);
+ mPlayer.prepare();
+ return null;
+ } catch (Exception e) {
+ return e;
+ }
+ }
+
+ @Override
+ public void onPostExecute(Exception exception) {
+ mPrepareTask = null;
+ if (exception == null) {
+ mDuration.set(mPlayer.getDuration());
+ int startPosition =
+ constrain(clipPositionInMillis, 0, mDuration.get());
+ mView.setClipPosition(startPosition, mDuration.get());
+ mPlayer.seekTo(startPosition);
+ mPlayer.start();
+ mView.playbackStarted();
+ if (!mWakeLock.isHeld()) {
+ mWakeLock.acquire();
+ }
+ // Only enable if we are not currently using the speaker phone.
+ if (!mView.isSpeakerPhoneOn()) {
+ mView.enableProximitySensor();
+ }
+ mPositionUpdater.startUpdating(startPosition, mDuration.get());
+ } else {
+ handleError(exception);
+ }
+ }
+ });
+ }
+
+ private void handleError(Exception e) {
+ mView.playbackError(e);
+ mPositionUpdater.stopUpdating();
+ mPlayer.release();
+ }
+
+ public void handleCompletion(MediaPlayer mediaPlayer) {
+ stopPlaybackAtPosition(0, mDuration.get());
+ }
+
+ private void stopPlaybackAtPosition(int clipPosition, int duration) {
+ mPositionUpdater.stopUpdating();
+ mView.playbackStopped();
+ if (mWakeLock.isHeld()) {
+ mWakeLock.release();
+ }
+ // Always disable on stop.
+ mView.disableProximitySensor();
+ mView.setClipPosition(clipPosition, duration);
+ if (mPlayer.isPlaying()) {
+ mPlayer.pause();
+ }
+ }
+
+ private class PlaybackPositionListener implements SeekBar.OnSeekBarChangeListener {
+ private boolean mShouldResumePlaybackAfterSeeking;
+
+ @Override
+ public void onStartTrackingTouch(SeekBar arg0) {
+ if (mPlayer.isPlaying()) {
+ mShouldResumePlaybackAfterSeeking = true;
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
+ } else {
+ mShouldResumePlaybackAfterSeeking = false;
+ }
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar arg0) {
+ if (mPlayer.isPlaying()) {
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
+ }
+ if (mShouldResumePlaybackAfterSeeking) {
+ resetPrepareStartPlaying(mView.getDesiredClipPosition());
+ }
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ mView.setClipPosition(seekBar.getProgress(), seekBar.getMax());
+ }
+ }
+
+ private void changeRate(float rate, int stringResourceId) {
+ ((SingleThreadedMediaPlayerProxy) mPlayer).setVariableSpeed(rate);
+ mView.setRateDisplay(rate, stringResourceId);
+ }
+
+ private class SpeakerphoneListener implements View.OnClickListener {
+ @Override
+ public void onClick(View v) {
+ boolean previousState = mView.isSpeakerPhoneOn();
+ mView.setSpeakerPhoneOn(!previousState);
+ if (mPlayer.isPlaying() && previousState) {
+ // If we are currently playing and we are disabling the speaker phone, enable the
+ // sensor.
+ mView.enableProximitySensor();
+ } else {
+ // If we are not currently playing, disable the sensor.
+ mView.disableProximitySensor();
+ }
+ }
+ }
+
+ private class StartStopButtonListener implements View.OnClickListener {
+ @Override
+ public void onClick(View arg0) {
+ if (mPlayer.isPlaying()) {
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
+ } else {
+ resetPrepareStartPlaying(mView.getDesiredClipPosition());
+ }
+ }
+ }
+
+ /**
+ * Controls the animation of the playback slider.
+ */
+ @ThreadSafe
+ private final class PositionUpdater implements Runnable {
+ private final ScheduledExecutorService mExecutorService;
+ private final int mPeriodMillis;
+ private final Object mLock = new Object();
+ @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture;
+ private final Runnable mSetClipPostitionRunnable = new Runnable() {
+ @Override
+ public void run() {
+ int currentPosition = 0;
+ synchronized (mLock) {
+ if (mScheduledFuture == null) {
+ // This task has been canceled. Just stop now.
+ return;
+ }
+ currentPosition = mPlayer.getCurrentPosition();
+ }
+ mView.setClipPosition(currentPosition, mDuration.get());
+ }
+ };
+
+ public PositionUpdater(ScheduledExecutorService executorService, int periodMillis) {
+ mExecutorService = executorService;
+ mPeriodMillis = periodMillis;
+ }
+
+ @Override
+ public void run() {
+ mView.runOnUiThread(mSetClipPostitionRunnable);
+ }
+
+ public void startUpdating(int beginPosition, int endPosition) {
+ synchronized (mLock) {
+ if (mScheduledFuture != null) {
+ mScheduledFuture.cancel(false);
+ }
+ mScheduledFuture = mExecutorService.scheduleAtFixedRate(this, 0, mPeriodMillis,
+ TimeUnit.MILLISECONDS);
+ }
+ }
+
+ public void stopUpdating() {
+ synchronized (mLock) {
+ if (mScheduledFuture != null) {
+ mScheduledFuture.cancel(false);
+ mScheduledFuture = null;
+ }
+ }
+ }
+ }
+
+ public void onPause() {
+ if (mPlayer.isPlaying()) {
+ stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
+ }
+ if (mPrepareTask != null) {
+ mPrepareTask.cancel(false);
+ }
+ if (mWakeLock.isHeld()) {
+ mWakeLock.release();
+ }
+ }
+}
diff --git a/src/com/android/dialer/voicemail/VoicemailStatusHelper.java b/src/com/android/dialer/voicemail/VoicemailStatusHelper.java
new file mode 100644
index 000000000..545691ec9
--- /dev/null
+++ b/src/com/android/dialer/voicemail/VoicemailStatusHelper.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2011 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.voicemail;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.VoicemailContract.Status;
+
+import java.util.List;
+
+/**
+ * Interface used by the call log UI to determine what user message, if any, related to voicemail
+ * source status needs to be shown. The messages are returned in the order of importance.
+ * <p>
+ * The implementation of this interface interacts with the voicemail content provider to fetch
+ * statuses of all the registered voicemail sources and determines if any status message needs to
+ * be shown. The user of this interface must observe/listen to provider changes and invoke
+ * this class to check if any message needs to be shown.
+ */
+public interface VoicemailStatusHelper {
+ public class StatusMessage {
+ /** Package of the source on behalf of which this message has to be shown.*/
+ public final String sourcePackage;
+ /**
+ * The string resource id of the status message that should be shown in the call log
+ * page. Set to -1, if this message is not to be shown in call log.
+ */
+ public final int callLogMessageId;
+ /**
+ * The string resource id of the status message that should be shown in the call details
+ * page. Set to -1, if this message is not to be shown in call details page.
+ */
+ public final int callDetailsMessageId;
+ /** The string resource id of the action message that should be shown. */
+ public final int actionMessageId;
+ /** URI for the corrective action, where applicable. Null if no action URI is available. */
+ public final Uri actionUri;
+ public StatusMessage(String sourcePackage, int callLogMessageId, int callDetailsMessageId,
+ int actionMessageId, Uri actionUri) {
+ this.sourcePackage = sourcePackage;
+ this.callLogMessageId = callLogMessageId;
+ this.callDetailsMessageId = callDetailsMessageId;
+ this.actionMessageId = actionMessageId;
+ this.actionUri = actionUri;
+ }
+
+ /** Whether this message should be shown in the call log page. */
+ public boolean showInCallLog() {
+ return callLogMessageId != -1;
+ }
+
+ /** Whether this message should be shown in the call details page. */
+ public boolean showInCallDetails() {
+ return callDetailsMessageId != -1;
+ }
+ }
+
+ /**
+ * Returns a list of messages, in the order or priority that should be shown to the user. An
+ * empty list is returned if no message needs to be shown.
+ * @param cursor The cursor pointing to the query on {@link Status#CONTENT_URI}. The projection
+ * to be used is defined by the implementation class of this interface.
+ */
+ public List<StatusMessage> getStatusMessages(Cursor cursor);
+
+ /**
+ * Returns the number of active voicemail sources installed.
+ * <p>
+ * The number of sources is counted by querying the voicemail status table.
+ */
+ public int getNumberActivityVoicemailSources(Cursor cursor);
+}
diff --git a/src/com/android/dialer/voicemail/VoicemailStatusHelperImpl.java b/src/com/android/dialer/voicemail/VoicemailStatusHelperImpl.java
new file mode 100644
index 000000000..3a08e2bff
--- /dev/null
+++ b/src/com/android/dialer/voicemail/VoicemailStatusHelperImpl.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2011 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.voicemail;
+
+import static android.provider.VoicemailContract.Status.CONFIGURATION_STATE_CAN_BE_CONFIGURED;
+import static android.provider.VoicemailContract.Status.CONFIGURATION_STATE_OK;
+import static android.provider.VoicemailContract.Status.DATA_CHANNEL_STATE_NO_CONNECTION;
+import static android.provider.VoicemailContract.Status.DATA_CHANNEL_STATE_OK;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_MESSAGE_WAITING;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_NO_CONNECTION;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_OK;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.VoicemailContract.Status;
+
+import com.android.contacts.R;
+import com.android.contacts.util.UriUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/** Implementation of {@link VoicemailStatusHelper}. */
+public class VoicemailStatusHelperImpl implements VoicemailStatusHelper {
+ private static final int SOURCE_PACKAGE_INDEX = 0;
+ private static final int CONFIGURATION_STATE_INDEX = 1;
+ private static final int DATA_CHANNEL_STATE_INDEX = 2;
+ private static final int NOTIFICATION_CHANNEL_STATE_INDEX = 3;
+ private static final int SETTINGS_URI_INDEX = 4;
+ private static final int VOICEMAIL_ACCESS_URI_INDEX = 5;
+ private static final int NUM_COLUMNS = 6;
+ /** Projection on the voicemail_status table used by this class. */
+ public static final String[] PROJECTION = new String[NUM_COLUMNS];
+ static {
+ PROJECTION[SOURCE_PACKAGE_INDEX] = Status.SOURCE_PACKAGE;
+ PROJECTION[CONFIGURATION_STATE_INDEX] = Status.CONFIGURATION_STATE;
+ PROJECTION[DATA_CHANNEL_STATE_INDEX] = Status.DATA_CHANNEL_STATE;
+ PROJECTION[NOTIFICATION_CHANNEL_STATE_INDEX] = Status.NOTIFICATION_CHANNEL_STATE;
+ PROJECTION[SETTINGS_URI_INDEX] = Status.SETTINGS_URI;
+ PROJECTION[VOICEMAIL_ACCESS_URI_INDEX] = Status.VOICEMAIL_ACCESS_URI;
+ }
+
+ /** Possible user actions. */
+ public static enum Action {
+ NONE(-1),
+ CALL_VOICEMAIL(R.string.voicemail_status_action_call_server),
+ CONFIGURE_VOICEMAIL(R.string.voicemail_status_action_configure);
+
+ private final int mMessageId;
+ private Action(int messageId) {
+ mMessageId = messageId;
+ }
+
+ public int getMessageId() {
+ return mMessageId;
+ }
+ }
+
+ /**
+ * Overall state of the source status. Each state is associated with the corresponding display
+ * string and the corrective action. The states are also assigned a relative priority which is
+ * used to order the messages from different sources.
+ */
+ private static enum OverallState {
+ // TODO: Add separate string for call details and call log pages for the states that needs
+ // to be shown in both.
+ /** Both notification and data channel are not working. */
+ NO_CONNECTION(0, Action.CALL_VOICEMAIL, R.string.voicemail_status_voicemail_not_available,
+ R.string.voicemail_status_audio_not_available),
+ /** Notifications working, but data channel is not working. Audio cannot be downloaded. */
+ NO_DATA(1, Action.CALL_VOICEMAIL, R.string.voicemail_status_voicemail_not_available,
+ R.string.voicemail_status_audio_not_available),
+ /** Messages are known to be waiting but data channel is not working. */
+ MESSAGE_WAITING(2, Action.CALL_VOICEMAIL, R.string.voicemail_status_messages_waiting,
+ R.string.voicemail_status_audio_not_available),
+ /** Notification channel not working, but data channel is. */
+ NO_NOTIFICATIONS(3, Action.CALL_VOICEMAIL,
+ R.string.voicemail_status_voicemail_not_available),
+ /** Invite user to set up voicemail. */
+ INVITE_FOR_CONFIGURATION(4, Action.CONFIGURE_VOICEMAIL,
+ R.string.voicemail_status_configure_voicemail),
+ /**
+ * No detailed notifications, but data channel is working.
+ * This is normal mode of operation for certain sources. No action needed.
+ */
+ NO_DETAILED_NOTIFICATION(5, Action.NONE, -1),
+ /** Visual voicemail not yet set up. No local action needed. */
+ NOT_CONFIGURED(6, Action.NONE, -1),
+ /** Everything is OK. */
+ OK(7, Action.NONE, -1),
+ /** If one or more state value set by the source is not valid. */
+ INVALID(8, Action.NONE, -1);
+
+ private final int mPriority;
+ private final Action mAction;
+ private final int mCallLogMessageId;
+ private final int mCallDetailsMessageId;
+
+ private OverallState(int priority, Action action, int callLogMessageId) {
+ this(priority, action, callLogMessageId, -1);
+ }
+
+ private OverallState(int priority, Action action, int callLogMessageId,
+ int callDetailsMessageId) {
+ mPriority = priority;
+ mAction = action;
+ mCallLogMessageId = callLogMessageId;
+ mCallDetailsMessageId = callDetailsMessageId;
+ }
+
+ public Action getAction() {
+ return mAction;
+ }
+
+ public int getPriority() {
+ return mPriority;
+ }
+
+ public int getCallLogMessageId() {
+ return mCallLogMessageId;
+ }
+
+ public int getCallDetailsMessageId() {
+ return mCallDetailsMessageId;
+ }
+ }
+
+ /** A wrapper on {@link StatusMessage} which additionally stores the priority of the message. */
+ private static class MessageStatusWithPriority {
+ private final StatusMessage mMessage;
+ private final int mPriority;
+
+ public MessageStatusWithPriority(StatusMessage message, int priority) {
+ mMessage = message;
+ mPriority = priority;
+ }
+ }
+
+ @Override
+ public List<StatusMessage> getStatusMessages(Cursor cursor) {
+ List<MessageStatusWithPriority> messages =
+ new ArrayList<VoicemailStatusHelperImpl.MessageStatusWithPriority>();
+ cursor.moveToPosition(-1);
+ while(cursor.moveToNext()) {
+ MessageStatusWithPriority message = getMessageForStatusEntry(cursor);
+ if (message != null) {
+ messages.add(message);
+ }
+ }
+ // Finally reorder the messages by their priority.
+ return reorderMessages(messages);
+ }
+
+ @Override
+ public int getNumberActivityVoicemailSources(Cursor cursor) {
+ int count = 0;
+ cursor.moveToPosition(-1);
+ while(cursor.moveToNext()) {
+ if (isVoicemailSourceActive(cursor)) {
+ ++count;
+ }
+ }
+ return count;
+ }
+
+ /** Returns whether the source status in the cursor corresponds to an active source. */
+ private boolean isVoicemailSourceActive(Cursor cursor) {
+ return cursor.getString(SOURCE_PACKAGE_INDEX) != null
+ && cursor.getInt(CONFIGURATION_STATE_INDEX) == Status.CONFIGURATION_STATE_OK;
+ }
+
+ private List<StatusMessage> reorderMessages(List<MessageStatusWithPriority> messageWrappers) {
+ Collections.sort(messageWrappers, new Comparator<MessageStatusWithPriority>() {
+ @Override
+ public int compare(MessageStatusWithPriority msg1, MessageStatusWithPriority msg2) {
+ return msg1.mPriority - msg2.mPriority;
+ }
+ });
+ List<StatusMessage> reorderMessages = new ArrayList<VoicemailStatusHelper.StatusMessage>();
+ // Copy the ordered message objects into the final list.
+ for (MessageStatusWithPriority messageWrapper : messageWrappers) {
+ reorderMessages.add(messageWrapper.mMessage);
+ }
+ return reorderMessages;
+ }
+
+ /**
+ * Returns the message for the status entry pointed to by the cursor.
+ */
+ private MessageStatusWithPriority getMessageForStatusEntry(Cursor cursor) {
+ final String sourcePackage = cursor.getString(SOURCE_PACKAGE_INDEX);
+ if (sourcePackage == null) {
+ return null;
+ }
+ final OverallState overallState = getOverallState(cursor.getInt(CONFIGURATION_STATE_INDEX),
+ cursor.getInt(DATA_CHANNEL_STATE_INDEX),
+ cursor.getInt(NOTIFICATION_CHANNEL_STATE_INDEX));
+ final Action action = overallState.getAction();
+
+ // No source package or no action, means no message shown.
+ if (action == Action.NONE) {
+ return null;
+ }
+
+ Uri actionUri = null;
+ if (action == Action.CALL_VOICEMAIL) {
+ actionUri = UriUtils.parseUriOrNull(cursor.getString(VOICEMAIL_ACCESS_URI_INDEX));
+ // Even if actionUri is null, it is still be useful to show the notification.
+ } else if (action == Action.CONFIGURE_VOICEMAIL) {
+ actionUri = UriUtils.parseUriOrNull(cursor.getString(SETTINGS_URI_INDEX));
+ // If there is no settings URI, there is no point in showing the notification.
+ if (actionUri == null) {
+ return null;
+ }
+ }
+ return new MessageStatusWithPriority(
+ new StatusMessage(sourcePackage, overallState.getCallLogMessageId(),
+ overallState.getCallDetailsMessageId(), action.getMessageId(),
+ actionUri),
+ overallState.getPriority());
+ }
+
+ private OverallState getOverallState(int configurationState, int dataChannelState,
+ int notificationChannelState) {
+ if (configurationState == CONFIGURATION_STATE_OK) {
+ // Voicemail is configured. Let's see how is the data channel.
+ if (dataChannelState == DATA_CHANNEL_STATE_OK) {
+ // Data channel is fine. What about notification channel?
+ if (notificationChannelState == NOTIFICATION_CHANNEL_STATE_OK) {
+ return OverallState.OK;
+ } else if (notificationChannelState == NOTIFICATION_CHANNEL_STATE_MESSAGE_WAITING) {
+ return OverallState.NO_DETAILED_NOTIFICATION;
+ } else if (notificationChannelState == NOTIFICATION_CHANNEL_STATE_NO_CONNECTION) {
+ return OverallState.NO_NOTIFICATIONS;
+ }
+ } else if (dataChannelState == DATA_CHANNEL_STATE_NO_CONNECTION) {
+ // Data channel is not working. What about notification channel?
+ if (notificationChannelState == NOTIFICATION_CHANNEL_STATE_OK) {
+ return OverallState.NO_DATA;
+ } else if (notificationChannelState == NOTIFICATION_CHANNEL_STATE_MESSAGE_WAITING) {
+ return OverallState.MESSAGE_WAITING;
+ } else if (notificationChannelState == NOTIFICATION_CHANNEL_STATE_NO_CONNECTION) {
+ return OverallState.NO_CONNECTION;
+ }
+ }
+ } else if (configurationState == CONFIGURATION_STATE_CAN_BE_CONFIGURED) {
+ // Voicemail not configured. data/notification channel states are irrelevant.
+ return OverallState.INVITE_FOR_CONFIGURATION;
+ } else if (configurationState == Status.CONFIGURATION_STATE_NOT_CONFIGURED) {
+ // Voicemail not configured. data/notification channel states are irrelevant.
+ return OverallState.NOT_CONFIGURED;
+ }
+ // Will reach here only if the source has set an invalid value.
+ return OverallState.INVALID;
+ }
+}
diff --git a/tests/Android.mk b/tests/Android.mk
new file mode 100644
index 000000000..d440f6a6d
--- /dev/null
+++ b/tests/Android.mk
@@ -0,0 +1,19 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+# We only want this apk build for tests.
+LOCAL_MODULE_TAGS := tests
+LOCAL_CERTIFICATE := shared
+
+LOCAL_JAVA_LIBRARIES := android.test.runner
+
+# Include all test java files.
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_STATIC_JAVA_LIBRARIES += com.android.contacts.common.test
+
+LOCAL_PACKAGE_NAME := DialerTests
+
+LOCAL_INSTRUMENTATION_FOR := Dialer
+
+include $(BUILD_PACKAGE)
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
new file mode 100644
index 000000000..3a714e39a
--- /dev/null
+++ b/tests/AndroidManifest.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2012 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
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.dialer.tests">
+
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
+ <uses-permission android:name="android.permission.WRITE_CONTACTS" />
+ <uses-permission android:name="android.permission.READ_CALL_LOG" />
+ <uses-permission android:name="android.permission.WRITE_CALL_LOG" />
+ <uses-permission android:name="android.permission.GET_ACCOUNTS" />
+
+ <uses-permission android:name="android.permission.USE_CREDENTIALS" />
+ <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
+ <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
+ <uses-permission android:name="android.permission.READ_SYNC_STATS" />
+ <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
+ <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
+
+ <uses-permission android:name="android.permission.READ_PROFILE" />
+ <uses-permission android:name="android.permission.READ_SOCIAL_STREAM" />
+
+ <application>
+ <uses-library android:name="android.test.runner" />
+ <meta-data android:name="com.android.dialer.iconset" android:resource="@xml/iconset" />
+
+ <activity android:name=".calllog.FillCallLogTestActivity"
+ android:label="Call log filter test"
+ >
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ </application>
+
+ <instrumentation android:name="android.test.InstrumentationTestRunner"
+ android:targetPackage="com.android.dialer"
+ android:label="Dialer app tests">
+ </instrumentation>
+
+ <instrumentation android:name="com.android.dialer.DialerLaunchPerformance"
+ android:targetPackage="com.android.dialer"
+ android:label="Dialer launch performance">
+ </instrumentation>
+
+</manifest>
diff --git a/tests/proguard.flags b/tests/proguard.flags
new file mode 100644
index 000000000..39784b11b
--- /dev/null
+++ b/tests/proguard.flags
@@ -0,0 +1,20 @@
+-keep class com.android.contacts.model.Sources {
+ public <init>(...);
+}
+
+# Xml files containing onClick (menus and layouts) require that proguard not
+# remove their handlers.
+-keepclassmembers class * extends android.app.Activity {
+ public void *(android.view.View);
+ public void *(android.view.MenuItem);
+}
+
+# Any class or method annotated with NeededForTesting or NeededForReflection.
+-keep @com.android.contacts.test.NeededForTesting class *
+-keep @com.android.contacts.test.NeededForReflection class *
+-keepclassmembers class * {
+@com.android.contacts.test.NeededForTesting *;
+@com.android.contacts.test.NeededForReflection *;
+}
+
+-verbose
diff --git a/tests/res/drawable/default_icon.png b/tests/res/drawable/default_icon.png
new file mode 100644
index 000000000..cea0eb3b7
--- /dev/null
+++ b/tests/res/drawable/default_icon.png
Binary files differ
diff --git a/tests/res/drawable/phone_icon.png b/tests/res/drawable/phone_icon.png
new file mode 100644
index 000000000..4e613ecce
--- /dev/null
+++ b/tests/res/drawable/phone_icon.png
Binary files differ
diff --git a/tests/res/layout/fill_call_log_test.xml b/tests/res/layout/fill_call_log_test.xml
new file mode 100644
index 000000000..704b9c66e
--- /dev/null
+++ b/tests/res/layout/fill_call_log_test.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2012 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
+ -->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:gravity="center_horizontal"
+>
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/numberOfCallLogEntries"
+ />
+ <EditText
+ android:id="@+id/number"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="number"
+ android:text="10"
+ />
+ <CheckBox
+ android:id="@+id/use_random_numbers"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/useRandomNumbers"
+ />
+ <Button
+ android:id="@+id/add"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/addToCallLogButton"
+ />
+ <ProgressBar
+ android:id="@+id/progress"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:indeterminate="false"
+ android:visibility="gone"
+ />
+</LinearLayout>
diff --git a/tests/res/values/donottranslate_strings.xml b/tests/res/values/donottranslate_strings.xml
new file mode 100644
index 000000000..ceba5ea96
--- /dev/null
+++ b/tests/res/values/donottranslate_strings.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2012 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
+ -->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+ <string-array name="allIntents">
+ <!-- List modes -->
+ <!-- Various ways to start Contacts -->
+ <item>DIAL</item>
+ <item>DIAL phone (deprecated)</item>
+ <item>DIAL person (deprecated)</item>
+ <item>DIAL voicemail</item>
+ <item>CALL BUTTON</item>
+ <item>DIAL tel</item>
+ <item>VIEW tel</item>
+ <item>VIEW calls (call-log after a phone call)</item>
+ <item>VIEW calls item</item>
+ <item>CallDetailActivity (legacy)</item>
+ <item>CallLogActivity (legacy)</item>
+ </string-array>
+
+ <string name="addToCallLogButton">Add</string>
+ <string name="useRandomNumbers">Use random numbers</string>
+ <string name="numberOfCallLogEntries">Number of call log entries to add:</string>
+ <string name="addedLogEntriesToast">Added %1$d call log entries.</string>
+ <string name="noLogEntriesToast">No entries in the call log yet. Need at least one record for the template. Or use random numbers.</string>
+
+</resources>
diff --git a/tests/res/xml/iconset.xml b/tests/res/xml/iconset.xml
new file mode 100644
index 000000000..ec3894566
--- /dev/null
+++ b/tests/res/xml/iconset.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2012 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
+ -->
+
+<icon-set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <icon-default android:icon="@drawable/default_icon" />
+ <icon android:mimeType="vnd.android.cursor.item/phone"
+ android:icon="@drawable/phone_icon" />
+
+</icon-set>
diff --git a/tests/src/com/android/dialer/CallDetailActivityTest.java b/tests/src/com/android/dialer/CallDetailActivityTest.java
new file mode 100644
index 000000000..43204652a
--- /dev/null
+++ b/tests/src/com/android/dialer/CallDetailActivityTest.java
@@ -0,0 +1,336 @@
+/*
+ * Copyright (C) 2011 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;
+
+import static com.android.dialer.CallDetailActivity.Tasks.UPDATE_PHONE_CALL_DETAILS;
+import static com.android.dialer.voicemail.VoicemailPlaybackPresenter.Tasks.CHECK_FOR_CONTENT;
+import static com.android.dialer.voicemail.VoicemailPlaybackPresenter.Tasks.PREPARE_MEDIA_PLAYER;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.res.AssetManager;
+import android.net.Uri;
+import android.provider.CallLog;
+import android.provider.VoicemailContract;
+import android.test.ActivityInstrumentationTestCase2;
+import android.test.suitebuilder.annotation.LargeTest;
+import android.test.suitebuilder.annotation.Suppress;
+import android.view.Menu;
+import android.widget.TextView;
+
+import com.android.contacts.util.AsyncTaskExecutors;
+import com.android.dialer.util.FakeAsyncTaskExecutor;
+import com.android.contacts.common.test.IntegrationTestUtils;
+import com.android.dialer.util.LocaleTestUtils;
+import com.android.internal.view.menu.ContextMenuBuilder;
+import com.google.common.io.Closeables;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Unit tests for the {@link CallDetailActivity}.
+ */
+@LargeTest
+public class CallDetailActivityTest extends ActivityInstrumentationTestCase2<CallDetailActivity> {
+ private static final String TEST_ASSET_NAME = "quick_test_recording.mp3";
+ private static final String MIME_TYPE = "audio/mp3";
+ private static final String CONTACT_NUMBER = "+1412555555";
+ private static final String VOICEMAIL_FILE_LOCATION = "/sdcard/sadlfj893w4j23o9sfu.mp3";
+
+ private Uri mCallLogUri;
+ private Uri mVoicemailUri;
+ private IntegrationTestUtils mTestUtils;
+ private LocaleTestUtils mLocaleTestUtils;
+ private FakeAsyncTaskExecutor mFakeAsyncTaskExecutor;
+ private CallDetailActivity mActivityUnderTest;
+
+ public CallDetailActivityTest() {
+ super(CallDetailActivity.class);
+ }
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mFakeAsyncTaskExecutor = new FakeAsyncTaskExecutor(getInstrumentation());
+ AsyncTaskExecutors.setFactoryForTest(mFakeAsyncTaskExecutor.getFactory());
+ // I don't like the default of focus-mode for tests, the green focus border makes the
+ // screenshots look weak.
+ setActivityInitialTouchMode(true);
+ mTestUtils = new IntegrationTestUtils(getInstrumentation());
+ // Some of the tests rely on the text that appears on screen - safest to force a
+ // specific locale.
+ mLocaleTestUtils = new LocaleTestUtils(getInstrumentation().getTargetContext());
+ mLocaleTestUtils.setLocale(Locale.US);
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mLocaleTestUtils.restoreLocale();
+ mLocaleTestUtils = null;
+ cleanUpUri();
+ mTestUtils = null;
+ AsyncTaskExecutors.setFactoryForTest(null);
+ super.tearDown();
+ }
+
+ public void testInitialActivityStartsWithFetchingVoicemail() throws Throwable {
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ // When the activity first starts, we will show "Fetching voicemail" on the screen.
+ // The duration should not be visible.
+ assertHasOneTextViewContaining("Fetching voicemail");
+ assertZeroTextViewsContaining("00:00");
+ }
+
+ public void testWhenCheckForContentCompletes_UiShowsBuffering() throws Throwable {
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ // There is a background check that is testing to see if we have the content available.
+ // Once that task completes, we shouldn't be showing the fetching message, we should
+ // be showing "Buffering".
+ mFakeAsyncTaskExecutor.runTask(CHECK_FOR_CONTENT);
+ assertHasOneTextViewContaining("Buffering");
+ assertZeroTextViewsContaining("Fetching voicemail");
+ }
+
+ public void testInvalidVoicemailShowsErrorMessage() throws Throwable {
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ mFakeAsyncTaskExecutor.runTask(CHECK_FOR_CONTENT);
+ // There should be exactly one background task ready to prepare the media player.
+ // Preparing the media player will have thrown an IOException since the file doesn't exist.
+ // This should have put a failed to play message on screen, buffering is gone.
+ mFakeAsyncTaskExecutor.runTask(PREPARE_MEDIA_PLAYER);
+ assertHasOneTextViewContaining("Couldn't play voicemail");
+ assertZeroTextViewsContaining("Buffering");
+ }
+
+ public void testOnResumeDoesNotCreateManyFragments() throws Throwable {
+ // There was a bug where every time the activity was resumed, a new fragment was created.
+ // Before the fix, this was failing reproducibly with at least 3 "Buffering" views.
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ mFakeAsyncTaskExecutor.runTask(CHECK_FOR_CONTENT);
+ getInstrumentation().runOnMainSync(new Runnable() {
+ @Override
+ public void run() {
+ getInstrumentation().callActivityOnPause(mActivityUnderTest);
+ getInstrumentation().callActivityOnResume(mActivityUnderTest);
+ getInstrumentation().callActivityOnPause(mActivityUnderTest);
+ getInstrumentation().callActivityOnResume(mActivityUnderTest);
+ }
+ });
+ assertHasOneTextViewContaining("Buffering");
+ }
+
+ /**
+ * Test for bug where increase rate button with invalid voicemail causes a crash.
+ * <p>
+ * The repro steps for this crash were to open a voicemail that does not have an attachment,
+ * then click the play button (which just reported an error), then after that try to adjust the
+ * rate. See http://b/5047879.
+ */
+ public void testClickIncreaseRateButtonWithInvalidVoicemailDoesNotCrash() throws Throwable {
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ mTestUtils.clickButton(mActivityUnderTest, R.id.playback_start_stop);
+ mTestUtils.clickButton(mActivityUnderTest, R.id.rate_increase_button);
+ }
+
+ /** Test for bug where missing Extras on intent used to start Activity causes NPE. */
+ public void testCallLogUriWithMissingExtrasShouldNotCauseNPE() throws Throwable {
+ setActivityIntentForTestCallEntry();
+ startActivityUnderTest();
+ }
+
+ /**
+ * Test for bug where voicemails should not have remove-from-call-log entry.
+ * <p>
+ * See http://b/5054103.
+ */
+ public void testVoicemailDoesNotHaveRemoveFromCallLog() throws Throwable {
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ Menu menu = new ContextMenuBuilder(mActivityUnderTest);
+ mActivityUnderTest.onCreateOptionsMenu(menu);
+ mActivityUnderTest.onPrepareOptionsMenu(menu);
+ assertFalse(menu.findItem(R.id.menu_remove_from_call_log).isVisible());
+ }
+
+ /** Test to check that I haven't broken the remove-from-call-log entry from regular calls. */
+ public void testRegularCallDoesHaveRemoveFromCallLog() throws Throwable {
+ setActivityIntentForTestCallEntry();
+ startActivityUnderTest();
+ Menu menu = new ContextMenuBuilder(mActivityUnderTest);
+ mActivityUnderTest.onCreateOptionsMenu(menu);
+ mActivityUnderTest.onPrepareOptionsMenu(menu);
+ assertTrue(menu.findItem(R.id.menu_remove_from_call_log).isVisible());
+ }
+
+ /**
+ * Test to show that we are correctly displaying playback rate on the ui.
+ * <p>
+ * See bug http://b/5044075.
+ */
+ @Suppress
+ public void testVoicemailPlaybackRateDisplayedOnUi() throws Throwable {
+ setActivityIntentForTestVoicemailEntry();
+ startActivityUnderTest();
+ // Find the TextView containing the duration. It should be initially displaying "00:00".
+ List<TextView> views = mTestUtils.getTextViewsWithString(mActivityUnderTest, "00:00");
+ assertEquals(1, views.size());
+ TextView timeDisplay = views.get(0);
+ // Hit the plus button. At this point we should be displaying "fast speed".
+ mTestUtils.clickButton(mActivityUnderTest, R.id.rate_increase_button);
+ assertEquals("fast speed", mTestUtils.getText(timeDisplay));
+ // Hit the minus button. We should be back to "normal" speed.
+ mTestUtils.clickButton(mActivityUnderTest, R.id.rate_decrease_button);
+ assertEquals("normal speed", mTestUtils.getText(timeDisplay));
+ // Wait for one and a half seconds. The timer will be back.
+ Thread.sleep(1500);
+ assertEquals("00:00", mTestUtils.getText(timeDisplay));
+ }
+
+ @Suppress
+ public void testClickingCallStopsPlayback() throws Throwable {
+ setActivityIntentForRealFileVoicemailEntry();
+ startActivityUnderTest();
+ mFakeAsyncTaskExecutor.runTask(CHECK_FOR_CONTENT);
+ mFakeAsyncTaskExecutor.runTask(PREPARE_MEDIA_PLAYER);
+ mTestUtils.clickButton(mActivityUnderTest, R.id.playback_speakerphone);
+ mTestUtils.clickButton(mActivityUnderTest, R.id.playback_start_stop);
+ mTestUtils.clickButton(mActivityUnderTest, R.id.call_and_sms_main_action);
+ Thread.sleep(2000);
+ // TODO: Suppressed the test for now, because I'm looking for an easy way to say "the audio
+ // is not playing at this point", and I can't find it without doing dirty things.
+ }
+
+ private void setActivityIntentForTestCallEntry() {
+ assertNull(mCallLogUri);
+ ContentResolver contentResolver = getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(CallLog.Calls.NUMBER, CONTACT_NUMBER);
+ values.put(CallLog.Calls.TYPE, CallLog.Calls.INCOMING_TYPE);
+ mCallLogUri = contentResolver.insert(CallLog.Calls.CONTENT_URI, values);
+ setActivityIntent(new Intent(Intent.ACTION_VIEW, mCallLogUri));
+ }
+
+ private void setActivityIntentForTestVoicemailEntry() {
+ assertNull(mVoicemailUri);
+ ContentResolver contentResolver = getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(VoicemailContract.Voicemails.NUMBER, CONTACT_NUMBER);
+ values.put(VoicemailContract.Voicemails.HAS_CONTENT, 1);
+ values.put(VoicemailContract.Voicemails._DATA, VOICEMAIL_FILE_LOCATION);
+ mVoicemailUri = contentResolver.insert(VoicemailContract.Voicemails.CONTENT_URI, values);
+ Uri callLogUri = ContentUris.withAppendedId(CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL,
+ ContentUris.parseId(mVoicemailUri));
+ Intent intent = new Intent(Intent.ACTION_VIEW, callLogUri);
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, mVoicemailUri);
+ setActivityIntent(intent);
+ }
+
+ private void setActivityIntentForRealFileVoicemailEntry() throws IOException {
+ assertNull(mVoicemailUri);
+ ContentValues values = new ContentValues();
+ values.put(VoicemailContract.Voicemails.DATE, String.valueOf(System.currentTimeMillis()));
+ values.put(VoicemailContract.Voicemails.NUMBER, CONTACT_NUMBER);
+ values.put(VoicemailContract.Voicemails.MIME_TYPE, MIME_TYPE);
+ values.put(VoicemailContract.Voicemails.HAS_CONTENT, 1);
+ String packageName = getInstrumentation().getTargetContext().getPackageName();
+ mVoicemailUri = getContentResolver().insert(
+ VoicemailContract.Voicemails.buildSourceUri(packageName), values);
+ AssetManager assets = getAssets();
+ OutputStream outputStream = null;
+ InputStream inputStream = null;
+ try {
+ inputStream = assets.open(TEST_ASSET_NAME);
+ outputStream = getContentResolver().openOutputStream(mVoicemailUri);
+ copyBetweenStreams(inputStream, outputStream);
+ } finally {
+ Closeables.closeQuietly(outputStream);
+ Closeables.closeQuietly(inputStream);
+ }
+ Uri callLogUri = ContentUris.withAppendedId(CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL,
+ ContentUris.parseId(mVoicemailUri));
+ Intent intent = new Intent(Intent.ACTION_VIEW, callLogUri);
+ intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, mVoicemailUri);
+ setActivityIntent(intent);
+ }
+
+ public void copyBetweenStreams(InputStream in, OutputStream out) throws IOException {
+ byte[] buffer = new byte[1024];
+ int bytesRead;
+ int total = 0;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ total += bytesRead;
+ out.write(buffer, 0, bytesRead);
+ }
+ }
+
+ private void cleanUpUri() {
+ if (mVoicemailUri != null) {
+ getContentResolver().delete(VoicemailContract.Voicemails.CONTENT_URI,
+ "_ID = ?", new String[] { String.valueOf(ContentUris.parseId(mVoicemailUri)) });
+ mVoicemailUri = null;
+ }
+ if (mCallLogUri != null) {
+ getContentResolver().delete(CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL,
+ "_ID = ?", new String[] { String.valueOf(ContentUris.parseId(mCallLogUri)) });
+ mCallLogUri = null;
+ }
+ }
+
+ private ContentResolver getContentResolver() {
+ return getInstrumentation().getTargetContext().getContentResolver();
+ }
+
+ private TextView assertHasOneTextViewContaining(String text) throws Throwable {
+ assertNotNull(mActivityUnderTest);
+ List<TextView> views = mTestUtils.getTextViewsWithString(mActivityUnderTest, text);
+ assertEquals("There should have been one TextView with text '" + text + "' but found "
+ + views, 1, views.size());
+ return views.get(0);
+ }
+
+ private void assertZeroTextViewsContaining(String text) throws Throwable {
+ assertNotNull(mActivityUnderTest);
+ List<TextView> views = mTestUtils.getTextViewsWithString(mActivityUnderTest, text);
+ assertEquals("There should have been no TextViews with text '" + text + "' but found "
+ + views, 0, views.size());
+ }
+
+ private void startActivityUnderTest() throws Throwable {
+ assertNull(mActivityUnderTest);
+ mActivityUnderTest = getActivity();
+ assertNotNull("activity should not be null", mActivityUnderTest);
+ // We have to run all tasks, not just one.
+ // This is because it seems that we can have onResume, onPause, onResume during the course
+ // of a single unit test.
+ mFakeAsyncTaskExecutor.runAllTasks(UPDATE_PHONE_CALL_DETAILS);
+ }
+
+ private AssetManager getAssets() {
+ return getInstrumentation().getContext().getAssets();
+ }
+}
diff --git a/tests/src/com/android/dialer/DialerLaunchPerformance.java b/tests/src/com/android/dialer/DialerLaunchPerformance.java
new file mode 100644
index 000000000..cf64f9449
--- /dev/null
+++ b/tests/src/com/android/dialer/DialerLaunchPerformance.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2007 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;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.os.Bundle;
+import android.test.LaunchPerformanceBase;
+
+/**
+ * Instrumentation class for Address Book launch performance testing.
+ */
+public class DialerLaunchPerformance extends LaunchPerformanceBase {
+
+ @Override
+ public void onCreate(Bundle arguments) {
+ mIntent.setAction(Intent.ACTION_MAIN);
+ mIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ mIntent.setComponent(new ComponentName("com.android.contacts",
+ "testcom.android.dialer.DialtactsActivity"));
+
+ start();
+ }
+
+ /**
+ * Calls LaunchApp and finish.
+ */
+ @Override
+ public void onStart() {
+ super.onStart();
+ LaunchApp();
+ finish(Activity.RESULT_OK, mResults);
+ }
+}
diff --git a/tests/src/com/android/dialer/PhoneCallDetailsHelperTest.java b/tests/src/com/android/dialer/PhoneCallDetailsHelperTest.java
new file mode 100644
index 000000000..961764475
--- /dev/null
+++ b/tests/src/com/android/dialer/PhoneCallDetailsHelperTest.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright (C) 2010 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;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.provider.CallLog.Calls;
+import android.test.AndroidTestCase;
+import android.text.Html;
+import android.text.Spanned;
+import android.view.View;
+import android.widget.TextView;
+
+import com.android.dialer.calllog.CallTypeHelper;
+import com.android.dialer.calllog.PhoneNumberHelper;
+import com.android.dialer.calllog.TestPhoneNumberHelper;
+import com.android.dialer.util.LocaleTestUtils;
+import com.android.internal.telephony.CallerInfo;
+
+import java.util.GregorianCalendar;
+import java.util.Locale;
+
+/**
+ * Unit tests for {@link PhoneCallDetailsHelper}.
+ */
+public class PhoneCallDetailsHelperTest extends AndroidTestCase {
+ /** The number to be used to access the voicemail. */
+ private static final String TEST_VOICEMAIL_NUMBER = "125";
+ /** The date of the call log entry. */
+ private static final long TEST_DATE =
+ new GregorianCalendar(2011, 5, 3, 13, 0, 0).getTimeInMillis();
+ /** A test duration value for phone calls. */
+ private static final long TEST_DURATION = 62300;
+ /** The number of the caller/callee in the log entry. */
+ private static final String TEST_NUMBER = "14125555555";
+ /** The formatted version of {@link #TEST_NUMBER}. */
+ private static final String TEST_FORMATTED_NUMBER = "1-412-255-5555";
+ /** The country ISO name used in the tests. */
+ private static final String TEST_COUNTRY_ISO = "US";
+ /** The geocoded location used in the tests. */
+ private static final String TEST_GEOCODE = "United States";
+
+ /** The object under test. */
+ private PhoneCallDetailsHelper mHelper;
+ /** The views to fill. */
+ private PhoneCallDetailsViews mViews;
+ private TextView mNameView;
+ private PhoneNumberHelper mPhoneNumberHelper;
+ private LocaleTestUtils mLocaleTestUtils;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ Context context = getContext();
+ Resources resources = context.getResources();
+ CallTypeHelper callTypeHelper = new CallTypeHelper(resources);
+ mPhoneNumberHelper = new TestPhoneNumberHelper(resources, TEST_VOICEMAIL_NUMBER);
+ mHelper = new PhoneCallDetailsHelper(resources, callTypeHelper, mPhoneNumberHelper);
+ mHelper.setCurrentTimeForTest(
+ new GregorianCalendar(2011, 5, 4, 13, 0, 0).getTimeInMillis());
+ mViews = PhoneCallDetailsViews.createForTest(context);
+ mNameView = new TextView(context);
+ mLocaleTestUtils = new LocaleTestUtils(getContext());
+ mLocaleTestUtils.setLocale(Locale.US);
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mLocaleTestUtils.restoreLocale();
+ mNameView = null;
+ mViews = null;
+ mHelper = null;
+ mPhoneNumberHelper = null;
+ super.tearDown();
+ }
+
+ public void testSetPhoneCallDetails_Unknown() {
+ setPhoneCallDetailsWithNumber(CallerInfo.UNKNOWN_NUMBER, CallerInfo.UNKNOWN_NUMBER);
+ assertNameEqualsResource(R.string.unknown);
+ }
+
+ public void testSetPhoneCallDetails_Private() {
+ setPhoneCallDetailsWithNumber(CallerInfo.PRIVATE_NUMBER, CallerInfo.PRIVATE_NUMBER);
+ assertNameEqualsResource(R.string.private_num);
+ }
+
+ public void testSetPhoneCallDetails_Payphone() {
+ setPhoneCallDetailsWithNumber(CallerInfo.PAYPHONE_NUMBER, CallerInfo.PAYPHONE_NUMBER);
+ assertNameEqualsResource(R.string.payphone);
+ }
+
+ public void testSetPhoneCallDetails_Voicemail() {
+ setPhoneCallDetailsWithNumber(TEST_VOICEMAIL_NUMBER, TEST_VOICEMAIL_NUMBER);
+ assertNameEqualsResource(R.string.voicemail);
+ }
+
+ public void testSetPhoneCallDetails_Normal() {
+ setPhoneCallDetailsWithNumber("14125551212", "1-412-555-1212");
+ assertEquals("yesterday", mViews.callTypeAndDate.getText().toString());
+ assertEqualsHtml("<font color='#33b5e5'><b>yesterday</b></font>",
+ mViews.callTypeAndDate.getText());
+ }
+
+ /** Asserts that a char sequence is actually a Spanned corresponding to the expected HTML. */
+ private void assertEqualsHtml(String expectedHtml, CharSequence actualText) {
+ // In order to contain HTML, the text should actually be a Spanned.
+ assertTrue(actualText instanceof Spanned);
+ Spanned actualSpanned = (Spanned) actualText;
+ // Convert from and to HTML to take care of alternative formatting of HTML.
+ assertEquals(Html.toHtml(Html.fromHtml(expectedHtml)), Html.toHtml(actualSpanned));
+
+ }
+
+ public void testSetPhoneCallDetails_Date() {
+ mHelper.setCurrentTimeForTest(
+ new GregorianCalendar(2011, 5, 3, 13, 0, 0).getTimeInMillis());
+
+ setPhoneCallDetailsWithDate(
+ new GregorianCalendar(2011, 5, 3, 13, 0, 0).getTimeInMillis());
+ assertDateEquals("0 mins ago");
+
+ setPhoneCallDetailsWithDate(
+ new GregorianCalendar(2011, 5, 3, 12, 0, 0).getTimeInMillis());
+ assertDateEquals("1 hour ago");
+
+ setPhoneCallDetailsWithDate(
+ new GregorianCalendar(2011, 5, 2, 13, 0, 0).getTimeInMillis());
+ assertDateEquals("yesterday");
+
+ setPhoneCallDetailsWithDate(
+ new GregorianCalendar(2011, 5, 1, 13, 0, 0).getTimeInMillis());
+ assertDateEquals("2 days ago");
+ }
+
+ public void testSetPhoneCallDetails_CallTypeIcons() {
+ setPhoneCallDetailsWithCallTypeIcons(Calls.INCOMING_TYPE);
+ assertCallTypeIconsEquals(Calls.INCOMING_TYPE);
+
+ setPhoneCallDetailsWithCallTypeIcons(Calls.OUTGOING_TYPE);
+ assertCallTypeIconsEquals(Calls.OUTGOING_TYPE);
+
+ setPhoneCallDetailsWithCallTypeIcons(Calls.MISSED_TYPE);
+ assertCallTypeIconsEquals(Calls.MISSED_TYPE);
+
+ setPhoneCallDetailsWithCallTypeIcons(Calls.VOICEMAIL_TYPE);
+ assertCallTypeIconsEquals(Calls.VOICEMAIL_TYPE);
+ }
+
+ public void testSetPhoneCallDetails_MultipleCallTypeIcons() {
+ setPhoneCallDetailsWithCallTypeIcons(Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE);
+ assertCallTypeIconsEquals(Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE);
+
+ setPhoneCallDetailsWithCallTypeIcons(Calls.MISSED_TYPE, Calls.MISSED_TYPE);
+ assertCallTypeIconsEquals(Calls.MISSED_TYPE, Calls.MISSED_TYPE);
+ }
+
+ public void testSetPhoneCallDetails_MultipleCallTypeIconsLastOneDropped() {
+ setPhoneCallDetailsWithCallTypeIcons(Calls.MISSED_TYPE, Calls.MISSED_TYPE,
+ Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE);
+ assertCallTypeIconsEqualsPlusOverflow("(4)",
+ Calls.MISSED_TYPE, Calls.MISSED_TYPE, Calls.INCOMING_TYPE);
+ }
+
+ public void testSetPhoneCallDetails_Geocode() {
+ setPhoneCallDetailsWithNumberAndGeocode("+14125555555", "1-412-555-5555", "Pennsylvania");
+ assertNameEquals("1-412-555-5555"); // The phone number is shown as the name.
+ assertNumberEquals("Pennsylvania"); // The geocode is shown as the number.
+ }
+
+ public void testSetPhoneCallDetails_NoGeocode() {
+ setPhoneCallDetailsWithNumberAndGeocode("+14125555555", "1-412-555-5555", null);
+ assertNameEquals("1-412-555-5555"); // The phone number is shown as the name.
+ assertNumberEquals("-"); // The empty geocode is shown as the number.
+ }
+
+ public void testSetPhoneCallDetails_EmptyGeocode() {
+ setPhoneCallDetailsWithNumberAndGeocode("+14125555555", "1-412-555-5555", "");
+ assertNameEquals("1-412-555-5555"); // The phone number is shown as the name.
+ assertNumberEquals("-"); // The empty geocode is shown as the number.
+ }
+
+ public void testSetPhoneCallDetails_NoGeocodeForVoicemail() {
+ setPhoneCallDetailsWithNumberAndGeocode(TEST_VOICEMAIL_NUMBER, "", "United States");
+ assertNumberEquals("-"); // The empty geocode is shown as the number.
+ }
+
+ public void testSetPhoneCallDetails_Highlighted() {
+ setPhoneCallDetailsWithNumber(TEST_VOICEMAIL_NUMBER, "");
+ }
+
+ public void testSetCallDetailsHeader_NumberOnly() {
+ setCallDetailsHeaderWithNumberOnly(TEST_NUMBER);
+ assertEquals(View.VISIBLE, mNameView.getVisibility());
+ assertEquals("Add to contacts", mNameView.getText().toString());
+ }
+
+ public void testSetCallDetailsHeader_UnknownNumber() {
+ setCallDetailsHeaderWithNumberOnly(CallerInfo.UNKNOWN_NUMBER);
+ assertEquals(View.VISIBLE, mNameView.getVisibility());
+ assertEquals("Unknown", mNameView.getText().toString());
+ }
+
+ public void testSetCallDetailsHeader_PrivateNumber() {
+ setCallDetailsHeaderWithNumberOnly(CallerInfo.PRIVATE_NUMBER);
+ assertEquals(View.VISIBLE, mNameView.getVisibility());
+ assertEquals("Private number", mNameView.getText().toString());
+ }
+
+ public void testSetCallDetailsHeader_PayphoneNumber() {
+ setCallDetailsHeaderWithNumberOnly(CallerInfo.PAYPHONE_NUMBER);
+ assertEquals(View.VISIBLE, mNameView.getVisibility());
+ assertEquals("Pay phone", mNameView.getText().toString());
+ }
+
+ public void testSetCallDetailsHeader_VoicemailNumber() {
+ setCallDetailsHeaderWithNumberOnly(TEST_VOICEMAIL_NUMBER);
+ assertEquals(View.VISIBLE, mNameView.getVisibility());
+ assertEquals("Voicemail", mNameView.getText().toString());
+ }
+
+ public void testSetCallDetailsHeader() {
+ setCallDetailsHeader("John Doe");
+ assertEquals(View.VISIBLE, mNameView.getVisibility());
+ assertEquals("John Doe", mNameView.getText().toString());
+ }
+
+ /** Asserts that the name text field contains the value of the given string resource. */
+ private void assertNameEqualsResource(int resId) {
+ assertNameEquals(getContext().getString(resId));
+ }
+
+ /** Asserts that the name text field contains the given string value. */
+ private void assertNameEquals(String text) {
+ assertEquals(text, mViews.nameView.getText().toString());
+ }
+
+ /** Asserts that the number text field contains the given string value. */
+ private void assertNumberEquals(String text) {
+ assertEquals(text, mViews.numberView.getText().toString());
+ }
+
+ /** Asserts that the date text field contains the given string value. */
+ private void assertDateEquals(String text) {
+ assertEquals(text, mViews.callTypeAndDate.getText().toString());
+ }
+
+ /** Asserts that the call type contains the images with the given drawables. */
+ private void assertCallTypeIconsEquals(int... ids) {
+ assertEquals(ids.length, mViews.callTypeIcons.getCount());
+ for (int index = 0; index < ids.length; ++index) {
+ int id = ids[index];
+ assertEquals(id, mViews.callTypeIcons.getCallType(index));
+ }
+ assertEquals(View.VISIBLE, mViews.callTypeIcons.getVisibility());
+ assertEquals("yesterday", mViews.callTypeAndDate.getText().toString());
+ }
+
+ /**
+ * Asserts that the call type contains the images with the given drawables and shows the given
+ * text next to the icons.
+ */
+ private void assertCallTypeIconsEqualsPlusOverflow(String overflowText, int... ids) {
+ assertEquals(ids.length, mViews.callTypeIcons.getCount());
+ for (int index = 0; index < ids.length; ++index) {
+ int id = ids[index];
+ assertEquals(id, mViews.callTypeIcons.getCallType(index));
+ }
+ assertEquals(View.VISIBLE, mViews.callTypeIcons.getVisibility());
+ assertEquals(overflowText + " yesterday", mViews.callTypeAndDate.getText().toString());
+ }
+
+ /** Sets the phone call details with default values and the given number. */
+ private void setPhoneCallDetailsWithNumber(String number, String formattedNumber) {
+ setPhoneCallDetailsWithNumberAndGeocode(number, formattedNumber, TEST_GEOCODE);
+ }
+
+ /** Sets the phone call details with default values and the given number. */
+ private void setPhoneCallDetailsWithNumberAndGeocode(String number, String formattedNumber,
+ String geocodedLocation) {
+ mHelper.setPhoneCallDetails(mViews,
+ new PhoneCallDetails(number, formattedNumber, TEST_COUNTRY_ISO, geocodedLocation,
+ new int[]{ Calls.VOICEMAIL_TYPE }, TEST_DATE, TEST_DURATION),
+ true);
+ }
+
+ /** Sets the phone call details with default values and the given date. */
+ private void setPhoneCallDetailsWithDate(long date) {
+ mHelper.setPhoneCallDetails(mViews,
+ new PhoneCallDetails(TEST_NUMBER, TEST_FORMATTED_NUMBER, TEST_COUNTRY_ISO,
+ TEST_GEOCODE, new int[]{ Calls.INCOMING_TYPE }, date, TEST_DURATION),
+ false);
+ }
+
+ /** Sets the phone call details with default values and the given call types using icons. */
+ private void setPhoneCallDetailsWithCallTypeIcons(int... callTypes) {
+ mHelper.setPhoneCallDetails(mViews,
+ new PhoneCallDetails(TEST_NUMBER, TEST_FORMATTED_NUMBER, TEST_COUNTRY_ISO,
+ TEST_GEOCODE, callTypes, TEST_DATE, TEST_DURATION),
+ false);
+ }
+
+ private void setCallDetailsHeaderWithNumberOnly(String number) {
+ mHelper.setCallDetailsHeader(mNameView,
+ new PhoneCallDetails(number, TEST_FORMATTED_NUMBER, TEST_COUNTRY_ISO,
+ TEST_GEOCODE, new int[]{ Calls.INCOMING_TYPE }, TEST_DATE, TEST_DURATION));
+ }
+
+ private void setCallDetailsHeader(String name) {
+ mHelper.setCallDetailsHeader(mNameView,
+ new PhoneCallDetails(TEST_NUMBER, TEST_FORMATTED_NUMBER, TEST_COUNTRY_ISO,
+ TEST_GEOCODE, new int[]{ Calls.INCOMING_TYPE }, TEST_DATE, TEST_DURATION,
+ name, 0, "", null, null));
+ }
+}
diff --git a/tests/src/com/android/dialer/calllog/CallLogAdapterTest.java b/tests/src/com/android/dialer/calllog/CallLogAdapterTest.java
new file mode 100644
index 000000000..6ec3e76ef
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/CallLogAdapterTest.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.database.MatrixCursor;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.view.View;
+
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+/**
+ * Unit tests for {@link CallLogAdapter}.
+ */
+@SmallTest
+public class CallLogAdapterTest extends AndroidTestCase {
+ private static final String TEST_NUMBER = "12345678";
+ private static final String TEST_NAME = "name";
+ private static final String TEST_NUMBER_LABEL = "label";
+ private static final int TEST_NUMBER_TYPE = 1;
+ private static final String TEST_COUNTRY_ISO = "US";
+
+ /** The object under test. */
+ private TestCallLogAdapter mAdapter;
+
+ private MatrixCursor mCursor;
+ private View mView;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ // Use a call fetcher that does not do anything.
+ CallLogAdapter.CallFetcher fakeCallFetcher = new CallLogAdapter.CallFetcher() {
+ @Override
+ public void fetchCalls() {}
+ };
+
+ ContactInfoHelper fakeContactInfoHelper =
+ new ContactInfoHelper(getContext(), TEST_COUNTRY_ISO) {
+ @Override
+ public ContactInfo lookupNumber(String number, String countryIso) {
+ ContactInfo info = new ContactInfo();
+ info.number = number;
+ info.formattedNumber = number;
+ return info;
+ }
+ };
+
+ mAdapter = new TestCallLogAdapter(getContext(), fakeCallFetcher, fakeContactInfoHelper);
+ // The cursor used in the tests to store the entries to display.
+ mCursor = new MatrixCursor(CallLogQuery.EXTENDED_PROJECTION);
+ mCursor.moveToFirst();
+ // The views into which to store the data.
+ mView = new View(getContext());
+ mView.setTag(CallLogListItemViews.createForTest(getContext()));
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mAdapter = null;
+ mCursor = null;
+ mView = null;
+ super.tearDown();
+ }
+
+ public void testBindView_NoCallLogCacheNorMemoryCache_EnqueueRequest() {
+ mCursor.addRow(createCallLogEntry());
+
+ // Bind the views of a single row.
+ mAdapter.bindStandAloneView(mView, getContext(), mCursor);
+
+ // There is one request for contact details.
+ assertEquals(1, mAdapter.requests.size());
+
+ TestCallLogAdapter.Request request = mAdapter.requests.get(0);
+ // It is for the number we need to show.
+ assertEquals(TEST_NUMBER, request.number);
+ // It has the right country.
+ assertEquals(TEST_COUNTRY_ISO, request.countryIso);
+ // Since there is nothing in the cache, it is an immediate request.
+ assertTrue("should be immediate", request.immediate);
+ }
+
+ public void testBindView_CallLogCacheButNoMemoryCache_EnqueueRequest() {
+ mCursor.addRow(createCallLogEntryWithCachedValues());
+
+ // Bind the views of a single row.
+ mAdapter.bindStandAloneView(mView, getContext(), mCursor);
+
+ // There is one request for contact details.
+ assertEquals(1, mAdapter.requests.size());
+
+ TestCallLogAdapter.Request request = mAdapter.requests.get(0);
+ // The values passed to the request, match the ones in the call log cache.
+ assertEquals(TEST_NAME, request.callLogInfo.name);
+ assertEquals(1, request.callLogInfo.type);
+ assertEquals(TEST_NUMBER_LABEL, request.callLogInfo.label);
+ }
+
+
+ public void testBindView_NoCallLogButMemoryCache_EnqueueRequest() {
+ mCursor.addRow(createCallLogEntry());
+ mAdapter.injectContactInfoForTest(TEST_NUMBER, TEST_COUNTRY_ISO, createContactInfo());
+
+ // Bind the views of a single row.
+ mAdapter.bindStandAloneView(mView, getContext(), mCursor);
+
+ // There is one request for contact details.
+ assertEquals(1, mAdapter.requests.size());
+
+ TestCallLogAdapter.Request request = mAdapter.requests.get(0);
+ // Since there is something in the cache, it is not an immediate request.
+ assertFalse("should not be immediate", request.immediate);
+ }
+
+ public void testBindView_BothCallLogAndMemoryCache_NoEnqueueRequest() {
+ mCursor.addRow(createCallLogEntryWithCachedValues());
+ mAdapter.injectContactInfoForTest(TEST_NUMBER, TEST_COUNTRY_ISO, createContactInfo());
+
+ // Bind the views of a single row.
+ mAdapter.bindStandAloneView(mView, getContext(), mCursor);
+
+ // Cache and call log are up-to-date: no need to request update.
+ assertEquals(0, mAdapter.requests.size());
+ }
+
+ public void testBindView_MismatchBetwenCallLogAndMemoryCache_EnqueueRequest() {
+ mCursor.addRow(createCallLogEntryWithCachedValues());
+
+ // Contact info contains a different name.
+ ContactInfo info = createContactInfo();
+ info.name = "new name";
+ mAdapter.injectContactInfoForTest(TEST_NUMBER, TEST_COUNTRY_ISO, info);
+
+ // Bind the views of a single row.
+ mAdapter.bindStandAloneView(mView, getContext(), mCursor);
+
+ // There is one request for contact details.
+ assertEquals(1, mAdapter.requests.size());
+
+ TestCallLogAdapter.Request request = mAdapter.requests.get(0);
+ // Since there is something in the cache, it is not an immediate request.
+ assertFalse("should not be immediate", request.immediate);
+ }
+
+ /** Returns a contact info with default values. */
+ private ContactInfo createContactInfo() {
+ ContactInfo info = new ContactInfo();
+ info.number = TEST_NUMBER;
+ info.name = TEST_NAME;
+ info.type = TEST_NUMBER_TYPE;
+ info.label = TEST_NUMBER_LABEL;
+ return info;
+ }
+
+ /** Returns a call log entry without cached values. */
+ private Object[] createCallLogEntry() {
+ Object[] values = CallLogQueryTestUtils.createTestExtendedValues();
+ values[CallLogQuery.NUMBER] = TEST_NUMBER;
+ values[CallLogQuery.COUNTRY_ISO] = TEST_COUNTRY_ISO;
+ return values;
+ }
+
+ /** Returns a call log entry with a cached values. */
+ private Object[] createCallLogEntryWithCachedValues() {
+ Object[] values = createCallLogEntry();
+ values[CallLogQuery.CACHED_NAME] = TEST_NAME;
+ values[CallLogQuery.CACHED_NUMBER_TYPE] = TEST_NUMBER_TYPE;
+ values[CallLogQuery.CACHED_NUMBER_LABEL] = TEST_NUMBER_LABEL;
+ return values;
+ }
+
+ /**
+ * Subclass of {@link CallLogAdapter} used in tests to intercept certain calls.
+ */
+ // TODO: This would be better done by splitting the contact lookup into a collaborator class
+ // instead.
+ private static final class TestCallLogAdapter extends CallLogAdapter {
+ public static class Request {
+ public final String number;
+ public final String countryIso;
+ public final ContactInfo callLogInfo;
+ public final boolean immediate;
+
+ public Request(String number, String countryIso, ContactInfo callLogInfo,
+ boolean immediate) {
+ this.number = number;
+ this.countryIso = countryIso;
+ this.callLogInfo = callLogInfo;
+ this.immediate = immediate;
+ }
+ }
+
+ public final List<Request> requests = Lists.newArrayList();
+
+ public TestCallLogAdapter(Context context, CallFetcher callFetcher,
+ ContactInfoHelper contactInfoHelper) {
+ super(context, callFetcher, contactInfoHelper);
+ }
+
+ @Override
+ void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo,
+ boolean immediate) {
+ requests.add(new Request(number, countryIso, callLogInfo, immediate));
+ }
+ }
+}
diff --git a/tests/src/com/android/dialer/calllog/CallLogFragmentTest.java b/tests/src/com/android/dialer/calllog/CallLogFragmentTest.java
new file mode 100644
index 000000000..f4534320f
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/CallLogFragmentTest.java
@@ -0,0 +1,632 @@
+/*
+ * Copyright (C) 2009 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.calllog;
+
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.ComponentName;
+import android.content.ContentUris;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.MatrixCursor;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.net.Uri;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.VoicemailContract;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.TelephonyManager;
+import android.test.ActivityInstrumentationTestCase2;
+import android.test.suitebuilder.annotation.LargeTest;
+import android.test.suitebuilder.annotation.MediumTest;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import com.android.dialer.CallDetailActivity;
+import com.android.contacts.R;
+import com.android.contacts.common.test.FragmentTestActivity;
+import com.android.internal.telephony.CallerInfo;
+
+import java.util.Date;
+import java.util.Formatter;
+import java.util.HashMap;
+import java.util.Random;
+
+/**
+ * Tests for the contact call list activity.
+ *
+ * Running all tests:
+ *
+ * runtest contacts
+ * or
+ * adb shell am instrument \
+ * -w com.android.contacts.tests/android.test.InstrumentationTestRunner
+ */
+@LargeTest
+public class CallLogFragmentTest extends ActivityInstrumentationTestCase2<FragmentTestActivity> {
+ private static final int RAND_DURATION = -1;
+ private static final long NOW = -1L;
+
+ /** A test value for the URI of a contact. */
+ private static final Uri TEST_LOOKUP_URI = Uri.parse("content://contacts/2");
+ /** A test value for the country ISO of the phone number in the call log. */
+ private static final String TEST_COUNTRY_ISO = "US";
+ /** A phone number to be used in tests. */
+ private static final String TEST_NUMBER = "12125551000";
+ /** The formatted version of {@link #TEST_NUMBER}. */
+ private static final String TEST_FORMATTED_NUMBER = "1 212-555-1000";
+
+ /** The activity in which we are hosting the fragment. */
+ private FragmentTestActivity mActivity;
+ private CallLogFragment mFragment;
+ private FrameLayout mParentView;
+ /**
+ * The adapter used by the fragment to build the rows in the call log. We use it with our own in
+ * memory database.
+ */
+ private CallLogAdapter mAdapter;
+ private String mVoicemail;
+
+ // In memory array to hold the rows corresponding to the 'calls' table.
+ private MatrixCursor mCursor;
+ private int mIndex; // Of the next row.
+
+ private Random mRnd;
+
+ // References to the icons bitmaps used to build the list are stored in a
+ // map mIcons. The keys to retrieve the icons are:
+ // Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE and Calls.MISSED_TYPE.
+ private HashMap<Integer, Bitmap> mCallTypeIcons;
+
+ // An item in the call list. All the methods performing checks use it.
+ private CallLogListItemViews mItem;
+ // The list of views representing the data in the DB. View are in
+ // reverse order compare to the DB.
+ private View[] mList;
+
+ public CallLogFragmentTest() {
+ super("com.android.dialer", FragmentTestActivity.class);
+ mIndex = 1;
+ mRnd = new Random();
+ }
+
+ @Override
+ public void setUp() {
+ mActivity = getActivity();
+ // Needed by the CallLogFragment.
+ mActivity.setTheme(R.style.DialtactsTheme);
+
+ // Create the fragment and load it into the activity.
+ mFragment = new CallLogFragment();
+ FragmentManager fragmentManager = mActivity.getFragmentManager();
+ FragmentTransaction transaction = fragmentManager.beginTransaction();
+ transaction.add(FragmentTestActivity.LAYOUT_ID, mFragment);
+ transaction.commit();
+ // Wait for the fragment to be loaded.
+ getInstrumentation().waitForIdleSync();
+
+ mVoicemail = TelephonyManager.getDefault().getVoiceMailNumber();
+ mAdapter = mFragment.getAdapter();
+ // Do not process requests for details during tests. This would start a background thread,
+ // which makes the tests flaky.
+ mAdapter.disableRequestProcessingForTest();
+ mAdapter.stopRequestProcessing();
+ mParentView = new FrameLayout(mActivity);
+ mCursor = new MatrixCursor(CallLogQuery.EXTENDED_PROJECTION);
+ buildIconMap();
+ }
+
+ /**
+ * Checks that the call icon is not visible for private and
+ * unknown numbers.
+ * Use 2 passes, one where new views are created and one where
+ * half of the total views are updated and the other half created.
+ */
+ @MediumTest
+ public void testCallViewIsNotVisibleForPrivateAndUnknownNumbers() {
+ final int SIZE = 100;
+ mList = new View[SIZE];
+
+ // Insert the first batch of entries.
+ mCursor.moveToFirst();
+ insertRandomEntries(SIZE / 2);
+ int startOfSecondBatch = mCursor.getPosition();
+
+ buildViewListFromDb();
+ checkCallStatus();
+
+ // Append the rest of the entries. We keep the first set of
+ // views around so they get updated and not built from
+ // scratch, this exposes some bugs that are not there when the
+ // call log is launched for the 1st time but show up when the
+ // call log gets updated afterwards.
+ mCursor.move(startOfSecondBatch);
+ insertRandomEntries(SIZE / 2);
+
+ buildViewListFromDb();
+ checkCallStatus();
+ }
+
+ @MediumTest
+ public void testCallAndGroupViews_GroupView() {
+ mCursor.moveToFirst();
+ insert(CallerInfo.PRIVATE_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ insert(CallerInfo.PRIVATE_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ insert(CallerInfo.PRIVATE_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ View view = mAdapter.newGroupView(getActivity(), mParentView);
+ mAdapter.bindGroupView(view, getActivity(), mCursor, 3, false);
+ assertNotNull(view.findViewById(R.id.secondary_action_icon));
+ }
+
+ @MediumTest
+ public void testCallAndGroupViews_StandAloneView() {
+ mCursor.moveToFirst();
+ insert(CallerInfo.PRIVATE_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+ assertNotNull(view.findViewById(R.id.secondary_action_icon));
+ }
+
+ @MediumTest
+ public void testCallAndGroupViews_ChildView() {
+ mCursor.moveToFirst();
+ insert(CallerInfo.PRIVATE_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ View view = mAdapter.newChildView(getActivity(), mParentView);
+ mAdapter.bindChildView(view, getActivity(), mCursor);
+ assertNotNull(view.findViewById(R.id.secondary_action_icon));
+ }
+
+ @MediumTest
+ public void testBindView_NumberOnlyNoCache() {
+ mCursor.moveToFirst();
+ insert(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, TEST_NUMBER);
+ }
+
+ @MediumTest
+ public void testBindView_NumberOnlyDbCachedFormattedNumber() {
+ mCursor.moveToFirst();
+ Object[] values = getValuesToInsert(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ values[CallLogQuery.CACHED_FORMATTED_NUMBER] = TEST_FORMATTED_NUMBER;
+ insertValues(values);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, TEST_FORMATTED_NUMBER);
+ }
+
+ @MediumTest
+ public void testBindView_WithCachedName() {
+ mCursor.moveToFirst();
+ insertWithCachedValues(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE,
+ "John Doe", Phone.TYPE_HOME, "");
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, "John Doe");
+ assertNumberAndLabelAre(views, TEST_FORMATTED_NUMBER, getTypeLabel(Phone.TYPE_HOME));
+ }
+
+ @MediumTest
+ public void testBindView_UriNumber() {
+ mCursor.moveToFirst();
+ insertWithCachedValues("sip:johndoe@gmail.com", NOW, 0, Calls.INCOMING_TYPE,
+ "John Doe", Phone.TYPE_HOME, "");
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, "John Doe");
+ assertNumberAndLabelAre(views, "sip:johndoe@gmail.com", null);
+ }
+
+ @MediumTest
+ public void testBindView_HomeLabel() {
+ mCursor.moveToFirst();
+ insertWithCachedValues(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE,
+ "John Doe", Phone.TYPE_HOME, "");
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, "John Doe");
+ assertNumberAndLabelAre(views, TEST_FORMATTED_NUMBER, getTypeLabel(Phone.TYPE_HOME));
+ }
+
+ @MediumTest
+ public void testBindView_WorkLabel() {
+ mCursor.moveToFirst();
+ insertWithCachedValues(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE,
+ "John Doe", Phone.TYPE_WORK, "");
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, "John Doe");
+ assertNumberAndLabelAre(views, TEST_FORMATTED_NUMBER, getTypeLabel(Phone.TYPE_WORK));
+ }
+
+ @MediumTest
+ public void testBindView_CustomLabel() {
+ mCursor.moveToFirst();
+ String numberLabel = "My label";
+ insertWithCachedValues(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE,
+ "John Doe", Phone.TYPE_CUSTOM, numberLabel);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertNameIs(views, "John Doe");
+ assertNumberAndLabelAre(views, TEST_FORMATTED_NUMBER, numberLabel);
+ }
+
+ @MediumTest
+ public void testBindView_WithQuickContactBadge() {
+ mCursor.moveToFirst();
+ insertWithCachedValues(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE,
+ "John Doe", Phone.TYPE_HOME, "");
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertTrue(views.quickContactView.isEnabled());
+ }
+
+ @MediumTest
+ public void testBindView_WithoutQuickContactBadge() {
+ mCursor.moveToFirst();
+ insert(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ assertFalse(views.quickContactView.isEnabled());
+ }
+
+ @MediumTest
+ public void testBindView_CallButton() {
+ mCursor.moveToFirst();
+ insert(TEST_NUMBER, NOW, 0, Calls.INCOMING_TYPE);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ IntentProvider intentProvider = (IntentProvider) views.secondaryActionView.getTag();
+ Intent intent = intentProvider.getIntent(mActivity);
+ // Starts a call.
+ assertEquals(Intent.ACTION_CALL_PRIVILEGED, intent.getAction());
+ // To the entry's number.
+ assertEquals(Uri.parse("tel:" + TEST_NUMBER), intent.getData());
+ }
+
+ @MediumTest
+ public void testBindView_PlayButton() {
+ mCursor.moveToFirst();
+ insertVoicemail(TEST_NUMBER, NOW, 0);
+ View view = mAdapter.newStandAloneView(getActivity(), mParentView);
+ mAdapter.bindStandAloneView(view, getActivity(), mCursor);
+
+ CallLogListItemViews views = (CallLogListItemViews) view.getTag();
+ IntentProvider intentProvider = (IntentProvider) views.secondaryActionView.getTag();
+ Intent intent = intentProvider.getIntent(mActivity);
+ // Starts the call detail activity.
+ assertEquals(new ComponentName(mActivity, CallDetailActivity.class),
+ intent.getComponent());
+ // With the given entry.
+ assertEquals(ContentUris.withAppendedId(Calls.CONTENT_URI_WITH_VOICEMAIL, 1),
+ intent.getData());
+ // With the URI of the voicemail.
+ assertEquals(
+ ContentUris.withAppendedId(VoicemailContract.Voicemails.CONTENT_URI, 1),
+ intent.getParcelableExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI));
+ // And starts playback.
+ assertTrue(
+ intent.getBooleanExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, false));
+ }
+
+ /** Returns the label associated with a given phone type. */
+ private CharSequence getTypeLabel(int phoneType) {
+ return Phone.getTypeLabel(getActivity().getResources(), phoneType, "");
+ }
+
+ //
+ // HELPERS to check conditions on the DB/views
+ //
+ /**
+ * Go over all the views in the list and check that the Call
+ * icon's visibility matches the nature of the number.
+ */
+ private void checkCallStatus() {
+ for (int i = 0; i < mList.length; i++) {
+ if (null == mList[i]) {
+ break;
+ }
+ mItem = (CallLogListItemViews) mList[i].getTag();
+ String number = getPhoneNumberForListEntry(i);
+ if (CallerInfo.PRIVATE_NUMBER.equals(number) ||
+ CallerInfo.UNKNOWN_NUMBER.equals(number)) {
+ assertFalse(View.VISIBLE == mItem.secondaryActionView.getVisibility());
+ } else {
+ assertEquals(View.VISIBLE, mItem.secondaryActionView.getVisibility());
+ }
+ }
+ }
+
+
+ //
+ // HELPERS to setup the tests.
+ //
+
+ /**
+ * Get the Bitmap from the icons in the contacts package.
+ */
+ private Bitmap getBitmap(String resName) {
+ Resources r = mActivity.getResources();
+ int resid = r.getIdentifier(resName, "drawable", "com.android.dialer");
+ BitmapDrawable d = (BitmapDrawable) r.getDrawable(resid);
+ assertNotNull(d);
+ return d.getBitmap();
+ }
+
+ /**
+ * Fetch all the icons we need in tests from the contacts app and store them in a map.
+ */
+ private void buildIconMap() {
+ mCallTypeIcons = new HashMap<Integer, Bitmap>(3);
+
+ mCallTypeIcons.put(Calls.INCOMING_TYPE, getBitmap("ic_call_incoming_holo_dark"));
+ mCallTypeIcons.put(Calls.MISSED_TYPE, getBitmap("ic_call_missed_holo_dark"));
+ mCallTypeIcons.put(Calls.OUTGOING_TYPE, getBitmap("ic_call_outgoing_holo_dark"));
+ }
+
+ //
+ // HELPERS to build/update the call entries (views) from the DB.
+ //
+
+ /**
+ * Read the DB and foreach call either update the existing view if
+ * one exists already otherwise create one.
+ * The list is build from a DESC view of the DB (last inserted entry is first).
+ */
+ private void buildViewListFromDb() {
+ int i = 0;
+ mCursor.moveToLast();
+ while(!mCursor.isBeforeFirst()) {
+ if (null == mList[i]) {
+ mList[i] = mAdapter.newStandAloneView(mActivity, mParentView);
+ }
+ mAdapter.bindStandAloneView(mList[i], mActivity, mCursor);
+ mCursor.moveToPrevious();
+ i++;
+ }
+ }
+
+ /** Returns the number associated with the given entry in {{@link #mList}. */
+ private String getPhoneNumberForListEntry(int index) {
+ // The entries are added backward, so count from the end of the cursor.
+ mCursor.moveToPosition(mCursor.getCount() - index - 1);
+ return mCursor.getString(CallLogQuery.NUMBER);
+ }
+
+ //
+ // HELPERS to insert numbers in the call log DB.
+ //
+
+ /**
+ * Insert a certain number of random numbers in the DB. Makes sure
+ * there is at least one private and one unknown number in the DB.
+ * @param num Of entries to be inserted.
+ */
+ private void insertRandomEntries(int num) {
+ if (num < 10) {
+ throw new IllegalArgumentException("num should be >= 10");
+ }
+ boolean privateOrUnknownOrVm[];
+ privateOrUnknownOrVm = insertRandomRange(0, num - 2);
+
+ if (privateOrUnknownOrVm[0] && privateOrUnknownOrVm[1]) {
+ insertRandomRange(num - 2, num);
+ } else {
+ insertPrivate(NOW, RAND_DURATION);
+ insertUnknown(NOW, RAND_DURATION);
+ }
+ }
+
+ /**
+ * Insert a new call entry in the test DB.
+ *
+ * It includes the values for the cached contact associated with the number.
+ *
+ * @param number The phone number. For unknown and private numbers,
+ * use CallerInfo.UNKNOWN_NUMBER or CallerInfo.PRIVATE_NUMBER.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ * @param type Either Call.OUTGOING_TYPE or Call.INCOMING_TYPE or Call.MISSED_TYPE.
+ * @param cachedName the name of the contact with this number
+ * @param cachedNumberType the type of the number, from the contact with this number
+ * @param cachedNumberLabel the label of the number, from the contact with this number
+ */
+ private void insertWithCachedValues(String number, long date, int duration, int type,
+ String cachedName, int cachedNumberType, String cachedNumberLabel) {
+ insert(number, date, duration, type);
+ ContactInfo contactInfo = new ContactInfo();
+ contactInfo.lookupUri = TEST_LOOKUP_URI;
+ contactInfo.name = cachedName;
+ contactInfo.type = cachedNumberType;
+ contactInfo.label = cachedNumberLabel;
+ String formattedNumber = PhoneNumberUtils.formatNumber(number, TEST_COUNTRY_ISO);
+ if (formattedNumber == null) {
+ formattedNumber = number;
+ }
+ contactInfo.formattedNumber = formattedNumber;
+ contactInfo.normalizedNumber = number;
+ contactInfo.photoId = 0;
+ mAdapter.injectContactInfoForTest(number, TEST_COUNTRY_ISO, contactInfo);
+ }
+
+ /**
+ * Insert a new call entry in the test DB.
+ * @param number The phone number. For unknown and private numbers,
+ * use CallerInfo.UNKNOWN_NUMBER or CallerInfo.PRIVATE_NUMBER.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ * @param type Either Call.OUTGOING_TYPE or Call.INCOMING_TYPE or Call.MISSED_TYPE.
+ */
+ private void insert(String number, long date, int duration, int type) {
+ insertValues(getValuesToInsert(number, date, duration, type));
+ }
+
+ /** Inserts the given values in the cursor. */
+ private void insertValues(Object[] values) {
+ mCursor.addRow(values);
+ ++mIndex;
+ }
+
+ /**
+ * Returns the values for a new call entry.
+ *
+ * @param number The phone number. For unknown and private numbers,
+ * use CallerInfo.UNKNOWN_NUMBER or CallerInfo.PRIVATE_NUMBER.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ * @param type Either Call.OUTGOING_TYPE or Call.INCOMING_TYPE or Call.MISSED_TYPE.
+ */
+ private Object[] getValuesToInsert(String number, long date, int duration, int type) {
+ Object[] values = CallLogQueryTestUtils.createTestExtendedValues();
+ values[CallLogQuery.ID] = mIndex;
+ values[CallLogQuery.NUMBER] = number;
+ values[CallLogQuery.DATE] = date == NOW ? new Date().getTime() : date;
+ values[CallLogQuery.DURATION] = duration < 0 ? mRnd.nextInt(10 * 60) : duration;
+ if (mVoicemail != null && mVoicemail.equals(number)) {
+ assertEquals(Calls.OUTGOING_TYPE, type);
+ }
+ values[CallLogQuery.CALL_TYPE] = type;
+ values[CallLogQuery.COUNTRY_ISO] = TEST_COUNTRY_ISO;
+ values[CallLogQuery.SECTION] = CallLogQuery.SECTION_OLD_ITEM;
+ return values;
+ }
+
+ /**
+ * Insert a new voicemail entry in the test DB.
+ * @param number The phone number. For unknown and private numbers,
+ * use CallerInfo.UNKNOWN_NUMBER or CallerInfo.PRIVATE_NUMBER.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ */
+ private void insertVoicemail(String number, long date, int duration) {
+ Object[] values = getValuesToInsert(number, date, duration, Calls.VOICEMAIL_TYPE);
+ // Must have the same index as the row.
+ values[CallLogQuery.VOICEMAIL_URI] =
+ ContentUris.withAppendedId(VoicemailContract.Voicemails.CONTENT_URI, mIndex);
+ insertValues(values);
+ }
+
+ /**
+ * Insert a new private call entry in the test DB.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ */
+ private void insertPrivate(long date, int duration) {
+ insert(CallerInfo.PRIVATE_NUMBER, date, duration, Calls.INCOMING_TYPE);
+ }
+
+ /**
+ * Insert a new unknown call entry in the test DB.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ */
+ private void insertUnknown(long date, int duration) {
+ insert(CallerInfo.UNKNOWN_NUMBER, date, duration, Calls.INCOMING_TYPE);
+ }
+
+ /**
+ * Insert a new call to voicemail entry in the test DB.
+ * @param date In millisec since epoch. Use NOW to use the current time.
+ * @param duration In seconds of the call. Use RAND_DURATION to pick a random one.
+ */
+ private void insertCalltoVoicemail(long date, int duration) {
+ // mVoicemail may be null
+ if (mVoicemail != null) {
+ insert(mVoicemail, date, duration, Calls.OUTGOING_TYPE);
+ }
+ }
+
+ /**
+ * Insert a range [start, end) of random numbers in the DB. For
+ * each row, there is a 1/10 probability that the number will be
+ * marked as PRIVATE or UNKNOWN or VOICEMAIL. For regular numbers, a number is
+ * inserted, its last 4 digits will be the number of the iteration
+ * in the range.
+ * @param start Of the range.
+ * @param end Of the range (excluded).
+ * @return An array with 2 booleans [0 = private number, 1 =
+ * unknown number, 2 = voicemail] to indicate if at least one
+ * private or unknown or voicemail number has been inserted. Since
+ * the numbers are random some tests may want to enforce the
+ * insertion of such numbers.
+ */
+ // TODO: Should insert numbers with contact entries too.
+ private boolean[] insertRandomRange(int start, int end) {
+ boolean[] privateOrUnknownOrVm = new boolean[] {false, false, false};
+
+ for (int i = start; i < end; i++ ) {
+ int type = mRnd.nextInt(10);
+
+ if (0 == type) {
+ insertPrivate(NOW, RAND_DURATION);
+ privateOrUnknownOrVm[0] = true;
+ } else if (1 == type) {
+ insertUnknown(NOW, RAND_DURATION);
+ privateOrUnknownOrVm[1] = true;
+ } else if (2 == type) {
+ insertCalltoVoicemail(NOW, RAND_DURATION);
+ privateOrUnknownOrVm[2] = true;
+ } else {
+ int inout = mRnd.nextBoolean() ? Calls.OUTGOING_TYPE : Calls.INCOMING_TYPE;
+ String number = new Formatter().format("1800123%04d", i).toString();
+ insert(number, NOW, RAND_DURATION, inout);
+ }
+ }
+ return privateOrUnknownOrVm;
+ }
+
+ /** Asserts that the name text view is shown and contains the given text. */
+ private void assertNameIs(CallLogListItemViews views, String name) {
+ assertEquals(View.VISIBLE, views.phoneCallDetailsViews.nameView.getVisibility());
+ assertEquals(name, views.phoneCallDetailsViews.nameView.getText());
+ }
+
+ /** Asserts that the number and label text view contains the given text. */
+ private void assertNumberAndLabelAre(CallLogListItemViews views, CharSequence number,
+ CharSequence label) {
+ assertEquals(View.VISIBLE, views.phoneCallDetailsViews.numberView.getVisibility());
+ assertEquals(number, views.phoneCallDetailsViews.numberView.getText().toString());
+
+ assertEquals(label == null ? View.GONE : View.VISIBLE,
+ views.phoneCallDetailsViews.labelView.getVisibility());
+ if (label != null) {
+ assertEquals(label, views.phoneCallDetailsViews.labelView.getText().toString());
+ }
+ }
+}
diff --git a/tests/src/com/android/dialer/calllog/CallLogGroupBuilderTest.java b/tests/src/com/android/dialer/calllog/CallLogGroupBuilderTest.java
new file mode 100644
index 000000000..6c20afe1d
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/CallLogGroupBuilderTest.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import static com.google.common.collect.Lists.newArrayList;
+
+import android.database.MatrixCursor;
+import android.provider.CallLog.Calls;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import java.util.List;
+
+/**
+ * Unit tests for {@link CallLogGroupBuilder}
+ */
+@SmallTest
+public class CallLogGroupBuilderTest extends AndroidTestCase {
+ /** A phone number for testing. */
+ private static final String TEST_NUMBER1 = "14125551234";
+ /** A phone number for testing. */
+ private static final String TEST_NUMBER2 = "14125555555";
+
+ /** The object under test. */
+ private CallLogGroupBuilder mBuilder;
+ /** Records the created groups. */
+ private FakeGroupCreator mFakeGroupCreator;
+ /** Cursor to store the values. */
+ private MatrixCursor mCursor;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mFakeGroupCreator = new FakeGroupCreator();
+ mBuilder = new CallLogGroupBuilder(mFakeGroupCreator);
+ createCursor();
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mCursor = null;
+ mBuilder = null;
+ mFakeGroupCreator = null;
+ super.tearDown();
+ }
+
+ public void testAddGroups_NoCalls() {
+ mBuilder.addGroups(mCursor);
+ assertEquals(0, mFakeGroupCreator.groups.size());
+ }
+
+ public void testAddGroups_OneCall() {
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ mBuilder.addGroups(mCursor);
+ assertEquals(0, mFakeGroupCreator.groups.size());
+ }
+
+ public void testAddGroups_TwoCallsNotMatching() {
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addOldCallLogEntry(TEST_NUMBER2, Calls.INCOMING_TYPE);
+ mBuilder.addGroups(mCursor);
+ assertEquals(0, mFakeGroupCreator.groups.size());
+ }
+
+ public void testAddGroups_ThreeCallsMatching() {
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ mBuilder.addGroups(mCursor);
+ assertEquals(1, mFakeGroupCreator.groups.size());
+ assertGroupIs(0, 3, false, mFakeGroupCreator.groups.get(0));
+ }
+
+ public void testAddGroups_MatchingIncomingAndOutgoing() {
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addOldCallLogEntry(TEST_NUMBER1, Calls.OUTGOING_TYPE);
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ mBuilder.addGroups(mCursor);
+ assertEquals(1, mFakeGroupCreator.groups.size());
+ assertGroupIs(0, 3, false, mFakeGroupCreator.groups.get(0));
+ }
+
+ public void testAddGroups_HeaderSplitsGroups() {
+ addNewCallLogHeader();
+ addNewCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addNewCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addOldCallLogHeader();
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ addOldCallLogEntry(TEST_NUMBER1, Calls.INCOMING_TYPE);
+ mBuilder.addGroups(mCursor);
+ assertEquals(2, mFakeGroupCreator.groups.size());
+ assertGroupIs(1, 2, false, mFakeGroupCreator.groups.get(0));
+ assertGroupIs(4, 2, false, mFakeGroupCreator.groups.get(1));
+ }
+
+ public void testAddGroups_Voicemail() {
+ // Does not group with other types of calls, include voicemail themselves.
+ assertCallsAreNotGrouped(Calls.VOICEMAIL_TYPE, Calls.MISSED_TYPE);
+ //assertCallsAreNotGrouped(Calls.VOICEMAIL_TYPE, Calls.MISSED_TYPE, Calls.MISSED_TYPE);
+ assertCallsAreNotGrouped(Calls.VOICEMAIL_TYPE, Calls.VOICEMAIL_TYPE);
+ assertCallsAreNotGrouped(Calls.VOICEMAIL_TYPE, Calls.INCOMING_TYPE);
+ assertCallsAreNotGrouped(Calls.VOICEMAIL_TYPE, Calls.OUTGOING_TYPE);
+ }
+
+ public void testAddGroups_Missed() {
+ // Groups with one or more missed calls.
+ assertCallsAreGrouped(Calls.MISSED_TYPE, Calls.MISSED_TYPE);
+ assertCallsAreGrouped(Calls.MISSED_TYPE, Calls.MISSED_TYPE, Calls.MISSED_TYPE);
+ // Does not group with other types of calls.
+ assertCallsAreNotGrouped(Calls.MISSED_TYPE, Calls.VOICEMAIL_TYPE);
+ assertCallsAreGrouped(Calls.MISSED_TYPE, Calls.INCOMING_TYPE);
+ assertCallsAreGrouped(Calls.MISSED_TYPE, Calls.OUTGOING_TYPE);
+ }
+
+ public void testAddGroups_Incoming() {
+ // Groups with one or more incoming or outgoing.
+ assertCallsAreGrouped(Calls.INCOMING_TYPE, Calls.INCOMING_TYPE);
+ assertCallsAreGrouped(Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE);
+ assertCallsAreGrouped(Calls.INCOMING_TYPE, Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE);
+ assertCallsAreGrouped(Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE, Calls.INCOMING_TYPE);
+ assertCallsAreGrouped(Calls.INCOMING_TYPE, Calls.MISSED_TYPE);
+ // Does not group with voicemail and missed calls.
+ assertCallsAreNotGrouped(Calls.INCOMING_TYPE, Calls.VOICEMAIL_TYPE);
+ }
+
+ public void testAddGroups_Outgoing() {
+ // Groups with one or more incoming or outgoing.
+ assertCallsAreGrouped(Calls.OUTGOING_TYPE, Calls.INCOMING_TYPE);
+ assertCallsAreGrouped(Calls.OUTGOING_TYPE, Calls.OUTGOING_TYPE);
+ assertCallsAreGrouped(Calls.OUTGOING_TYPE, Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE);
+ assertCallsAreGrouped(Calls.OUTGOING_TYPE, Calls.OUTGOING_TYPE, Calls.INCOMING_TYPE);
+ assertCallsAreGrouped(Calls.INCOMING_TYPE, Calls.MISSED_TYPE);
+ // Does not group with voicemail and missed calls.
+ assertCallsAreNotGrouped(Calls.INCOMING_TYPE, Calls.VOICEMAIL_TYPE);
+ }
+
+ public void testAddGroups_Mixed() {
+ addMultipleOldCallLogEntries(TEST_NUMBER1,
+ Calls.VOICEMAIL_TYPE, // Stand-alone
+ Calls.INCOMING_TYPE, // Group 1: 1-4
+ Calls.OUTGOING_TYPE,
+ Calls.MISSED_TYPE,
+ Calls.MISSED_TYPE,
+ Calls.VOICEMAIL_TYPE, // Stand-alone
+ Calls.INCOMING_TYPE, // Stand-alone
+ Calls.VOICEMAIL_TYPE, // Stand-alone
+ Calls.MISSED_TYPE, // Group 2: 8-10
+ Calls.MISSED_TYPE,
+ Calls.OUTGOING_TYPE);
+ mBuilder.addGroups(mCursor);
+ assertEquals(2, mFakeGroupCreator.groups.size());
+ assertGroupIs(1, 4, false, mFakeGroupCreator.groups.get(0));
+ assertGroupIs(8, 3, false, mFakeGroupCreator.groups.get(1));
+ }
+
+ public void testEqualPhoneNumbers() {
+ // Identical.
+ assertTrue(mBuilder.equalNumbers("6505555555", "6505555555"));
+ assertTrue(mBuilder.equalNumbers("650 555 5555", "650 555 5555"));
+ // Formatting.
+ assertTrue(mBuilder.equalNumbers("6505555555", "650 555 5555"));
+ assertTrue(mBuilder.equalNumbers("6505555555", "(650) 555-5555"));
+ assertTrue(mBuilder.equalNumbers("650 555 5555", "(650) 555-5555"));
+ // Short codes.
+ assertTrue(mBuilder.equalNumbers("55555", "55555"));
+ assertTrue(mBuilder.equalNumbers("55555", "555 55"));
+ // Different numbers.
+ assertFalse(mBuilder.equalNumbers("6505555555", "650555555"));
+ assertFalse(mBuilder.equalNumbers("6505555555", "6505555551"));
+ assertFalse(mBuilder.equalNumbers("650 555 5555", "650 555 555"));
+ assertFalse(mBuilder.equalNumbers("650 555 5555", "650 555 5551"));
+ assertFalse(mBuilder.equalNumbers("55555", "5555"));
+ assertFalse(mBuilder.equalNumbers("55555", "55551"));
+ // SIP addresses.
+ assertTrue(mBuilder.equalNumbers("6505555555@host.com", "6505555555@host.com"));
+ assertTrue(mBuilder.equalNumbers("6505555555@host.com", "6505555555@HOST.COM"));
+ assertTrue(mBuilder.equalNumbers("user@host.com", "user@host.com"));
+ assertTrue(mBuilder.equalNumbers("user@host.com", "user@HOST.COM"));
+ assertFalse(mBuilder.equalNumbers("USER@host.com", "user@host.com"));
+ assertFalse(mBuilder.equalNumbers("user@host.com", "user@host1.com"));
+ // SIP address vs phone number.
+ assertFalse(mBuilder.equalNumbers("6505555555@host.com", "6505555555"));
+ assertFalse(mBuilder.equalNumbers("6505555555", "6505555555@host.com"));
+ assertFalse(mBuilder.equalNumbers("user@host.com", "6505555555"));
+ assertFalse(mBuilder.equalNumbers("6505555555", "user@host.com"));
+ // Nulls.
+ assertTrue(mBuilder.equalNumbers(null, null));
+ assertFalse(mBuilder.equalNumbers(null, "6505555555"));
+ assertFalse(mBuilder.equalNumbers("6505555555", null));
+ assertFalse(mBuilder.equalNumbers(null, "6505555555@host.com"));
+ assertFalse(mBuilder.equalNumbers("6505555555@host.com", null));
+ }
+
+ public void testCompareSipAddresses() {
+ // Identical.
+ assertTrue(mBuilder.compareSipAddresses("6505555555@host.com", "6505555555@host.com"));
+ assertTrue(mBuilder.compareSipAddresses("user@host.com", "user@host.com"));
+ // Host is case insensitive.
+ assertTrue(mBuilder.compareSipAddresses("6505555555@host.com", "6505555555@HOST.COM"));
+ assertTrue(mBuilder.compareSipAddresses("user@host.com", "user@HOST.COM"));
+ // Userinfo is case sensitive.
+ assertFalse(mBuilder.compareSipAddresses("USER@host.com", "user@host.com"));
+ // Different hosts.
+ assertFalse(mBuilder.compareSipAddresses("user@host.com", "user@host1.com"));
+ // Different users.
+ assertFalse(mBuilder.compareSipAddresses("user1@host.com", "user@host.com"));
+ // Nulls.
+ assertTrue(mBuilder.compareSipAddresses(null, null));
+ assertFalse(mBuilder.compareSipAddresses(null, "6505555555@host.com"));
+ assertFalse(mBuilder.compareSipAddresses("6505555555@host.com", null));
+ }
+
+ /** Creates (or recreates) the cursor used to store the call log content for the tests. */
+ private void createCursor() {
+ mCursor = new MatrixCursor(CallLogQuery.EXTENDED_PROJECTION);
+ }
+
+ /** Clears the content of the {@link FakeGroupCreator} used in the tests. */
+ private void clearFakeGroupCreator() {
+ mFakeGroupCreator.groups.clear();
+ }
+
+ /** Asserts that calls of the given types are grouped together into a single group. */
+ private void assertCallsAreGrouped(int... types) {
+ createCursor();
+ clearFakeGroupCreator();
+ addMultipleOldCallLogEntries(TEST_NUMBER1, types);
+ mBuilder.addGroups(mCursor);
+ assertEquals(1, mFakeGroupCreator.groups.size());
+ assertGroupIs(0, types.length, false, mFakeGroupCreator.groups.get(0));
+
+ }
+
+ /** Asserts that calls of the given types are not grouped together at all. */
+ private void assertCallsAreNotGrouped(int... types) {
+ createCursor();
+ clearFakeGroupCreator();
+ addMultipleOldCallLogEntries(TEST_NUMBER1, types);
+ mBuilder.addGroups(mCursor);
+ assertEquals(0, mFakeGroupCreator.groups.size());
+ }
+
+ /** Adds a set of calls with the given types, all from the same number, in the old section. */
+ private void addMultipleOldCallLogEntries(String number, int... types) {
+ for (int type : types) {
+ addOldCallLogEntry(number, type);
+ }
+ }
+
+ /** Adds a call with the given number and type to the old section of the call log. */
+ private void addOldCallLogEntry(String number, int type) {
+ addCallLogEntry(number, type, CallLogQuery.SECTION_OLD_ITEM);
+ }
+
+ /** Adds a call with the given number and type to the new section of the call log. */
+ private void addNewCallLogEntry(String number, int type) {
+ addCallLogEntry(number, type, CallLogQuery.SECTION_NEW_ITEM);
+ }
+
+ /** Adds a call log entry with the given number and type to the cursor. */
+ private void addCallLogEntry(String number, int type, int section) {
+ if (section != CallLogQuery.SECTION_NEW_ITEM
+ && section != CallLogQuery.SECTION_OLD_ITEM) {
+ throw new IllegalArgumentException("not an item section: " + section);
+ }
+ mCursor.moveToNext();
+ Object[] values = CallLogQueryTestUtils.createTestExtendedValues();
+ values[CallLogQuery.ID] = mCursor.getPosition();
+ values[CallLogQuery.NUMBER] = number;
+ values[CallLogQuery.CALL_TYPE] = type;
+ values[CallLogQuery.SECTION] = section;
+ mCursor.addRow(values);
+ }
+
+ /** Adds the old section header to the call log. */
+ private void addOldCallLogHeader() {
+ addCallLogHeader(CallLogQuery.SECTION_OLD_HEADER);
+ }
+
+ /** Adds the new section header to the call log. */
+ private void addNewCallLogHeader() {
+ addCallLogHeader(CallLogQuery.SECTION_NEW_HEADER);
+ }
+
+ /** Adds a call log entry with a header to the cursor. */
+ private void addCallLogHeader(int section) {
+ if (section != CallLogQuery.SECTION_NEW_HEADER
+ && section != CallLogQuery.SECTION_OLD_HEADER) {
+ throw new IllegalArgumentException("not a header section: " + section);
+ }
+ mCursor.moveToNext();
+ Object[] values = CallLogQueryTestUtils.createTestExtendedValues();
+ values[CallLogQuery.ID] = mCursor.getPosition();
+ values[CallLogQuery.SECTION] = section;
+ mCursor.addRow(values);
+ }
+
+ /** Asserts that the group matches the given values. */
+ private void assertGroupIs(int cursorPosition, int size, boolean expanded, GroupSpec group) {
+ assertEquals(cursorPosition, group.cursorPosition);
+ assertEquals(size, group.size);
+ assertEquals(expanded, group.expanded);
+ }
+
+ /** Defines an added group. Used by the {@link FakeGroupCreator}. */
+ private static class GroupSpec {
+ /** The starting position of the group. */
+ public final int cursorPosition;
+ /** The number of elements in the group. */
+ public final int size;
+ /** Whether the group should be initially expanded. */
+ public final boolean expanded;
+
+ public GroupSpec(int cursorPosition, int size, boolean expanded) {
+ this.cursorPosition = cursorPosition;
+ this.size = size;
+ this.expanded = expanded;
+ }
+ }
+
+ /** Fake implementation of a GroupCreator which stores the created groups in a member field. */
+ private static class FakeGroupCreator implements CallLogGroupBuilder.GroupCreator {
+ /** The list of created groups. */
+ public final List<GroupSpec> groups = newArrayList();
+
+ @Override
+ public void addGroup(int cursorPosition, int size, boolean expanded) {
+ groups.add(new GroupSpec(cursorPosition, size, expanded));
+ }
+ }
+}
diff --git a/tests/src/com/android/dialer/calllog/CallLogListItemHelperTest.java b/tests/src/com/android/dialer/calllog/CallLogListItemHelperTest.java
new file mode 100644
index 000000000..3ad5abe0a
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/CallLogListItemHelperTest.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.provider.CallLog.Calls;
+import android.test.AndroidTestCase;
+import android.view.View;
+
+import com.android.dialer.PhoneCallDetails;
+import com.android.dialer.PhoneCallDetailsHelper;
+import com.android.internal.telephony.CallerInfo;
+
+/**
+ * Unit tests for {@link CallLogListItemHelper}.
+ */
+public class CallLogListItemHelperTest extends AndroidTestCase {
+ /** A test phone number for phone calls. */
+ private static final String TEST_NUMBER = "14125555555";
+ /** The formatted version of {@link #TEST_NUMBER}. */
+ private static final String TEST_FORMATTED_NUMBER = "1-412-255-5555";
+ /** A test date value for phone calls. */
+ private static final long TEST_DATE = 1300000000;
+ /** A test duration value for phone calls. */
+ private static final long TEST_DURATION = 62300;
+ /** A test voicemail number. */
+ private static final String TEST_VOICEMAIL_NUMBER = "123";
+ /** The country ISO name used in the tests. */
+ private static final String TEST_COUNTRY_ISO = "US";
+ /** The geocoded location used in the tests. */
+ private static final String TEST_GEOCODE = "United States";
+
+ /** The object under test. */
+ private CallLogListItemHelper mHelper;
+
+ /** The views used in the tests. */
+ private CallLogListItemViews mViews;
+ private PhoneNumberHelper mPhoneNumberHelper;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ Context context = getContext();
+ Resources resources = context.getResources();
+ CallTypeHelper callTypeHelper = new CallTypeHelper(resources);
+ mPhoneNumberHelper = new TestPhoneNumberHelper(resources, TEST_VOICEMAIL_NUMBER);
+ PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper(
+ resources, callTypeHelper, mPhoneNumberHelper);
+ mHelper = new CallLogListItemHelper(phoneCallDetailsHelper, mPhoneNumberHelper, resources);
+ mViews = CallLogListItemViews.createForTest(context);
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mHelper = null;
+ mViews = null;
+ super.tearDown();
+ }
+
+ public void testSetPhoneCallDetails() {
+ setPhoneCallDetailsWithNumber("12125551234", "1-212-555-1234");
+ assertEquals(View.VISIBLE, mViews.secondaryActionView.getVisibility());
+ }
+
+ public void testSetPhoneCallDetails_Unknown() {
+ setPhoneCallDetailsWithNumber(CallerInfo.UNKNOWN_NUMBER, CallerInfo.UNKNOWN_NUMBER);
+ assertNoCallButton();
+ }
+
+ public void testSetPhoneCallDetails_Private() {
+ setPhoneCallDetailsWithNumber(CallerInfo.PRIVATE_NUMBER, CallerInfo.PRIVATE_NUMBER);
+ assertNoCallButton();
+ }
+
+ public void testSetPhoneCallDetails_Payphone() {
+ setPhoneCallDetailsWithNumber(CallerInfo.PAYPHONE_NUMBER, CallerInfo.PAYPHONE_NUMBER);
+ assertNoCallButton();
+ }
+
+ public void testSetPhoneCallDetails_VoicemailNumber() {
+ setPhoneCallDetailsWithNumber(TEST_VOICEMAIL_NUMBER, TEST_VOICEMAIL_NUMBER);
+ assertEquals(View.VISIBLE, mViews.secondaryActionView.getVisibility());
+ }
+
+ public void testSetPhoneCallDetails_ReadVoicemail() {
+ setPhoneCallDetailsWithTypes(Calls.VOICEMAIL_TYPE);
+ assertEquals(View.VISIBLE, mViews.secondaryActionView.getVisibility());
+ }
+
+ public void testSetPhoneCallDetails_UnreadVoicemail() {
+ setUnreadPhoneCallDetailsWithTypes(Calls.VOICEMAIL_TYPE);
+ assertEquals(View.VISIBLE, mViews.secondaryActionView.getVisibility());
+ }
+
+ public void testSetPhoneCallDetails_VoicemailFromUnknown() {
+ setPhoneCallDetailsWithNumberAndType(CallerInfo.UNKNOWN_NUMBER, CallerInfo.UNKNOWN_NUMBER,
+ Calls.VOICEMAIL_TYPE);
+ assertEquals(View.VISIBLE, mViews.secondaryActionView.getVisibility());
+ }
+
+ /** Asserts that the whole call area is gone. */
+ private void assertNoCallButton() {
+ assertEquals(View.GONE, mViews.secondaryActionView.getVisibility());
+ assertEquals(View.GONE, mViews.dividerView.getVisibility());
+ }
+
+ /** Sets the details of a phone call using the specified phone number. */
+ private void setPhoneCallDetailsWithNumber(String number, String formattedNumber) {
+ setPhoneCallDetailsWithNumberAndType(number, formattedNumber, Calls.INCOMING_TYPE);
+ }
+
+ /** Sets the details of a phone call using the specified phone number. */
+ private void setPhoneCallDetailsWithNumberAndType(String number, String formattedNumber,
+ int callType) {
+ mHelper.setPhoneCallDetails(mViews,
+ new PhoneCallDetails(number, formattedNumber, TEST_COUNTRY_ISO, TEST_GEOCODE,
+ new int[]{ callType }, TEST_DATE, TEST_DURATION),
+ false);
+ }
+
+ /** Sets the details of a phone call using the specified call type. */
+ private void setPhoneCallDetailsWithTypes(int... types) {
+ mHelper.setPhoneCallDetails(mViews,
+ new PhoneCallDetails(TEST_NUMBER, TEST_FORMATTED_NUMBER, TEST_COUNTRY_ISO,
+ TEST_GEOCODE, types, TEST_DATE, TEST_DURATION),
+ false);
+ }
+
+ /** Sets the details of a phone call using the specified call type. */
+ private void setUnreadPhoneCallDetailsWithTypes(int... types) {
+ mHelper.setPhoneCallDetails(mViews,
+ new PhoneCallDetails(TEST_NUMBER, TEST_FORMATTED_NUMBER, TEST_COUNTRY_ISO,
+ TEST_GEOCODE, types, TEST_DATE, TEST_DURATION),
+ true);
+ }
+}
diff --git a/tests/src/com/android/dialer/calllog/CallLogQueryTestUtils.java b/tests/src/com/android/dialer/calllog/CallLogQueryTestUtils.java
new file mode 100644
index 000000000..4be84aede
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/CallLogQueryTestUtils.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import static junit.framework.Assert.assertEquals;
+
+import android.provider.CallLog.Calls;
+
+import junit.framework.Assert;
+
+/**
+ * Helper class to create test values for {@link CallLogQuery}.
+ */
+public class CallLogQueryTestUtils {
+ public static Object[] createTestValues() {
+ Object[] values = new Object[]{
+ 0L, "", 0L, 0L, Calls.INCOMING_TYPE, "", "", "", null, 0, null, null, null, null,
+ 0L, null, 0,
+ };
+ assertEquals(CallLogQuery._PROJECTION.length, values.length);
+ return values;
+ }
+
+ public static Object[] createTestExtendedValues() {
+ Object[] values = new Object[]{
+ 0L, "", 0L, 0L, Calls.INCOMING_TYPE, "", "", "", null, 0, null, null, null, null,
+ 0L, null, 1, CallLogQuery.SECTION_OLD_ITEM
+ };
+ Assert.assertEquals(CallLogQuery.EXTENDED_PROJECTION.length, values.length);
+ return values;
+ }
+}
diff --git a/tests/src/com/android/dialer/calllog/TestPhoneNumberHelper.java b/tests/src/com/android/dialer/calllog/TestPhoneNumberHelper.java
new file mode 100644
index 000000000..144635945
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/TestPhoneNumberHelper.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2011 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.calllog;
+
+import android.content.res.Resources;
+
+/**
+ * Modified version of {@link PhoneNumberHelper} to be used in tests that allows injecting the
+ * voicemail number.
+ */
+public final class TestPhoneNumberHelper extends PhoneNumberHelper {
+ private CharSequence mVoicemailNumber;
+
+ public TestPhoneNumberHelper(Resources resources, CharSequence voicemailNumber) {
+ super(resources);
+ mVoicemailNumber = voicemailNumber;
+ }
+
+ @Override
+ public boolean isVoicemailNumber(CharSequence number) {
+ return mVoicemailNumber.equals(number);
+ }
+}
diff --git a/tests/src/com/android/dialer/tests/calllog/FillCallLogTestActivity.java b/tests/src/com/android/dialer/tests/calllog/FillCallLogTestActivity.java
new file mode 100644
index 000000000..ed492205c
--- /dev/null
+++ b/tests/src/com/android/dialer/tests/calllog/FillCallLogTestActivity.java
@@ -0,0 +1,308 @@
+/*
+ * Copyright (C) 2011 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.tests.calllog;
+
+import android.app.Activity;
+import android.app.LoaderManager;
+import android.content.ContentProviderClient;
+import android.content.ContentValues;
+import android.content.CursorLoader;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.provider.CallLog.Calls;
+import android.util.Log;
+import android.view.View;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.dialer.tests.R;
+
+import java.util.Random;
+
+/**
+ * Activity to add entries to the call log for testing.
+ */
+public class FillCallLogTestActivity extends Activity {
+ private static final String TAG = "FillCallLogTestActivity";
+ /** Identifier of the loader for querying the call log. */
+ private static final int CALLLOG_LOADER_ID = 1;
+
+ private static final Random RNG = new Random();
+ private static final int[] CALL_TYPES = new int[] {
+ Calls.INCOMING_TYPE, Calls.OUTGOING_TYPE, Calls.MISSED_TYPE,
+ };
+
+ private TextView mNumberTextView;
+ private Button mAddButton;
+ private ProgressBar mProgressBar;
+ private CheckBox mUseRandomNumbers;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.fill_call_log_test);
+ mNumberTextView = (TextView) findViewById(R.id.number);
+ mAddButton = (Button) findViewById(R.id.add);
+ mProgressBar = (ProgressBar) findViewById(R.id.progress);
+ mUseRandomNumbers = (CheckBox) findViewById(R.id.use_random_numbers);
+
+ mAddButton.setOnClickListener(new View.OnClickListener(){
+ @Override
+ public void onClick(View v) {
+ int count;
+ try {
+ count = Integer.parseInt(mNumberTextView.getText().toString());
+ if (count > 100) {
+ throw new RuntimeException("Number too large. Max=100");
+ }
+ } catch (RuntimeException e) {
+ Toast.makeText(FillCallLogTestActivity.this, e.toString(), Toast.LENGTH_LONG)
+ .show();
+ return;
+ }
+ addEntriesToCallLog(count, mUseRandomNumbers.isChecked());
+ mNumberTextView.setEnabled(false);
+ mAddButton.setEnabled(false);
+ mProgressBar.setProgress(0);
+ mProgressBar.setMax(count);
+ mProgressBar.setVisibility(View.VISIBLE);
+ }
+ });
+ }
+
+ /**
+ * Adds a number of entries to the call log. The content of the entries is based on existing
+ * entries.
+ *
+ * @param count the number of entries to add
+ */
+ private void addEntriesToCallLog(final int count, boolean useRandomNumbers) {
+ if (useRandomNumbers) {
+ addRandomNumbers(count);
+ } else {
+ getLoaderManager().initLoader(CALLLOG_LOADER_ID, null,
+ new CallLogLoaderListener(count));
+ }
+ }
+
+ /**
+ * Calls when the insertion has completed.
+ *
+ * @param message the message to show in a toast to the user
+ */
+ private void insertCompleted(String message) {
+ // Hide the progress bar.
+ mProgressBar.setVisibility(View.GONE);
+ // Re-enable the add button.
+ mNumberTextView.setEnabled(true);
+ mAddButton.setEnabled(true);
+ mNumberTextView.setText("");
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ }
+
+
+ /**
+ * Creates a {@link ContentValues} object containing values corresponding to the given cursor.
+ *
+ * @param cursor the cursor from which to get the values
+ * @return a newly created content values object
+ */
+ private ContentValues createContentValuesFromCursor(Cursor cursor) {
+ ContentValues values = new ContentValues();
+ for (int column = 0; column < cursor.getColumnCount();
+ ++column) {
+ String name = cursor.getColumnName(column);
+ switch (cursor.getType(column)) {
+ case Cursor.FIELD_TYPE_STRING:
+ values.put(name, cursor.getString(column));
+ break;
+ case Cursor.FIELD_TYPE_INTEGER:
+ values.put(name, cursor.getLong(column));
+ break;
+ case Cursor.FIELD_TYPE_FLOAT:
+ values.put(name, cursor.getDouble(column));
+ break;
+ case Cursor.FIELD_TYPE_BLOB:
+ values.put(name, cursor.getBlob(column));
+ break;
+ case Cursor.FIELD_TYPE_NULL:
+ values.putNull(name);
+ break;
+ default:
+ Log.d(TAG, "Invalid value in cursor: " + cursor.getType(column));
+ break;
+ }
+ }
+ return values;
+ }
+
+ private void addRandomNumbers(int count) {
+ ContentValues[] values = new ContentValues[count];
+ for (int i = 0; i < count; i++) {
+ values[i] = new ContentValues();
+ values[i].put(Calls.NUMBER, generateRandomNumber());
+ values[i].put(Calls.DATE, System.currentTimeMillis()); // Will be randomized later
+ values[i].put(Calls.DURATION, 1); // Will be overwritten later
+ }
+ new AsyncCallLogInserter(values).execute(new Void[0]);
+ }
+
+ private static String generateRandomNumber() {
+ return String.format("5%09d", RNG.nextInt(1000000000));
+ }
+
+ /** Invokes {@link AsyncCallLogInserter} when the call log has loaded. */
+ private final class CallLogLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> {
+ /** The number of items to insert when done. */
+ private final int mCount;
+
+ private CallLogLoaderListener(int count) {
+ mCount = count;
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ Log.d(TAG, "onCreateLoader");
+ return new CursorLoader(FillCallLogTestActivity.this, Calls.CONTENT_URI,
+ null, null, null, null);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ try {
+ Log.d(TAG, "onLoadFinished");
+
+ if (data.getCount() == 0) {
+ // If there are no entries in the call log, we cannot generate new ones.
+ insertCompleted(getString(R.string.noLogEntriesToast));
+ return;
+ }
+
+ data.moveToPosition(-1);
+
+ ContentValues[] values = new ContentValues[mCount];
+ for (int index = 0; index < mCount; ++index) {
+ if (!data.moveToNext()) {
+ data.moveToFirst();
+ }
+ values[index] = createContentValuesFromCursor(data);
+ }
+ new AsyncCallLogInserter(values).execute(new Void[0]);
+ } finally {
+ // This is a one shot loader.
+ getLoaderManager().destroyLoader(CALLLOG_LOADER_ID);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {}
+ }
+
+ /** Inserts a given number of entries in the call log based on the values given. */
+ private final class AsyncCallLogInserter extends AsyncTask<Void, Integer, Integer> {
+ /** The number of items to insert. */
+ private final ContentValues[] mValues;
+
+ public AsyncCallLogInserter(ContentValues[] values) {
+ mValues = values;
+ }
+
+ @Override
+ protected Integer doInBackground(Void... params) {
+ Log.d(TAG, "doInBackground");
+ return insertIntoCallLog();
+ }
+
+ @Override
+ protected void onProgressUpdate(Integer... values) {
+ Log.d(TAG, "onProgressUpdate");
+ updateCount(values[0]);
+ }
+
+ @Override
+ protected void onPostExecute(Integer count) {
+ Log.d(TAG, "onPostExecute");
+ insertCompleted(getString(R.string.addedLogEntriesToast, count));
+ }
+
+ /**
+ * Inserts a number of entries in the call log based on the given templates.
+ *
+ * @return the number of inserted entries
+ */
+ private Integer insertIntoCallLog() {
+ int inserted = 0;
+
+ for (int index = 0; index < mValues.length; ++index) {
+ ContentValues values = mValues[index];
+ // These should not be set.
+ values.putNull(Calls._ID);
+ // Add some randomness to the date. For each new entry being added, add an extra
+ // day to the maximum possible offset from the original.
+ values.put(Calls.DATE,
+ values.getAsLong(Calls.DATE)
+ - RNG.nextInt(24 * 60 * 60 * (index + 1)) * 1000L);
+ // Add some randomness to the duration.
+ if (values.getAsLong(Calls.DURATION) > 0) {
+ values.put(Calls.DURATION, RNG.nextInt(30 * 60 * 60 * 1000));
+ }
+
+ // Overwrite type.
+ values.put(Calls.TYPE, CALL_TYPES[RNG.nextInt(CALL_TYPES.length)]);
+
+ // Clear cached columns.
+ values.putNull(Calls.CACHED_FORMATTED_NUMBER);
+ values.putNull(Calls.CACHED_LOOKUP_URI);
+ values.putNull(Calls.CACHED_MATCHED_NUMBER);
+ values.putNull(Calls.CACHED_NAME);
+ values.putNull(Calls.CACHED_NORMALIZED_NUMBER);
+ values.putNull(Calls.CACHED_NUMBER_LABEL);
+ values.putNull(Calls.CACHED_NUMBER_TYPE);
+ values.putNull(Calls.CACHED_PHOTO_ID);
+
+ // Insert into the call log the newly generated entry.
+ ContentProviderClient contentProvider =
+ getContentResolver().acquireContentProviderClient(
+ Calls.CONTENT_URI);
+ try {
+ Log.d(TAG, "adding entry to call log");
+ contentProvider.insert(Calls.CONTENT_URI, values);
+ ++inserted;
+ this.publishProgress(inserted);
+ } catch (RemoteException e) {
+ Log.d(TAG, "insert failed", e);
+ }
+ }
+ return inserted;
+ }
+ }
+
+ /**
+ * Updates the count shown to the user corresponding to the number of entries added.
+ *
+ * @param count the number of entries inserted so far
+ */
+ public void updateCount(Integer count) {
+ mProgressBar.setProgress(count);
+ }
+}
diff --git a/tests/src/com/android/dialer/util/ExpirableCacheTest.java b/tests/src/com/android/dialer/util/ExpirableCacheTest.java
new file mode 100644
index 000000000..b81ad754f
--- /dev/null
+++ b/tests/src/com/android/dialer/util/ExpirableCacheTest.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2011 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.util;
+
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.util.LruCache;
+
+import com.android.dialer.util.ExpirableCache.CachedValue;
+
+/**
+ * Unit tests for {@link ExpirableCache}.
+ */
+@SmallTest
+public class ExpirableCacheTest extends AndroidTestCase {
+ /** The object under test. */
+ private ExpirableCache<String, Integer> mCache;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ LruCache<String, CachedValue<Integer>> lruCache =
+ new LruCache<String, ExpirableCache.CachedValue<Integer>>(20);
+ mCache = ExpirableCache.create(lruCache);
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mCache = null;
+ super.tearDown();
+ }
+
+ public void testPut() {
+ mCache.put("a", 1);
+ mCache.put("b", 2);
+ assertEquals(1, mCache.getPossiblyExpired("a").intValue());
+ assertEquals(2, mCache.getPossiblyExpired("b").intValue());
+ mCache.put("a", 3);
+ assertEquals(3, mCache.getPossiblyExpired("a").intValue());
+ }
+
+ public void testGet_NotExisting() {
+ assertNull(mCache.getPossiblyExpired("a"));
+ mCache.put("b", 1);
+ assertNull(mCache.getPossiblyExpired("a"));
+ }
+
+ public void testGet_Expired() {
+ mCache.put("a", 1);
+ assertEquals(1, mCache.getPossiblyExpired("a").intValue());
+ mCache.expireAll();
+ assertEquals(1, mCache.getPossiblyExpired("a").intValue());
+ }
+
+ public void testGetNotExpired_NotExisting() {
+ assertNull(mCache.get("a"));
+ mCache.put("b", 1);
+ assertNull(mCache.get("a"));
+ }
+
+ public void testGetNotExpired_Expired() {
+ mCache.put("a", 1);
+ assertEquals(1, mCache.get("a").intValue());
+ mCache.expireAll();
+ assertNull(mCache.get("a"));
+ }
+
+ public void testGetCachedValue_NotExisting() {
+ assertNull(mCache.getCachedValue("a"));
+ mCache.put("b", 1);
+ assertNull(mCache.getCachedValue("a"));
+ }
+
+ public void testGetCachedValue_Expired() {
+ mCache.put("a", 1);
+ assertFalse("Should not be expired", mCache.getCachedValue("a").isExpired());
+ mCache.expireAll();
+ assertTrue("Should be expired", mCache.getCachedValue("a").isExpired());
+ }
+
+ public void testGetChangedValue_PutAfterExpired() {
+ mCache.put("a", 1);
+ mCache.expireAll();
+ mCache.put("a", 1);
+ assertFalse("Should not be expired", mCache.getCachedValue("a").isExpired());
+ }
+
+ public void testComputingCache() {
+ // Creates a cache in which all unknown values default to zero.
+ mCache = ExpirableCache.create(
+ new LruCache<String, ExpirableCache.CachedValue<Integer>>(10) {
+ @Override
+ protected CachedValue<Integer> create(String key) {
+ return mCache.newCachedValue(0);
+ }
+ });
+
+ // The first time we request a new value, we add it to the cache.
+ CachedValue<Integer> cachedValue = mCache.getCachedValue("a");
+ assertNotNull("Should have been created implicitly", cachedValue);
+ assertEquals(0, cachedValue.getValue().intValue());
+ assertFalse("Should not be expired", cachedValue.isExpired());
+
+ // If we expire all the values, the implicitly created value will also be marked as expired.
+ mCache.expireAll();
+ CachedValue<Integer> expiredCachedValue = mCache.getCachedValue("a");
+ assertNotNull("Should have been created implicitly", expiredCachedValue);
+ assertEquals(0, expiredCachedValue.getValue().intValue());
+ assertTrue("Should be expired", expiredCachedValue.isExpired());
+ }
+}
diff --git a/tests/src/com/android/dialer/util/FakeAsyncTaskExecutor.java b/tests/src/com/android/dialer/util/FakeAsyncTaskExecutor.java
new file mode 100644
index 000000000..064587e4b
--- /dev/null
+++ b/tests/src/com/android/dialer/util/FakeAsyncTaskExecutor.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright (C) 2011 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.util;
+
+import android.app.Instrumentation;
+import android.os.AsyncTask;
+
+import com.android.contacts.util.AsyncTaskExecutor;
+import com.android.contacts.util.AsyncTaskExecutors;
+import com.google.common.collect.Lists;
+
+import junit.framework.Assert;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * Test implementation of AsyncTaskExecutor.
+ * <p>
+ * This class is thread-safe. As per the contract of the AsyncTaskExecutor, the submit methods must
+ * be called from the main ui thread, however the other public methods may be called from any thread
+ * (most commonly the test thread).
+ * <p>
+ * Tasks submitted to this executor will not be run immediately. Rather they will be stored in a
+ * list of submitted tasks, where they can be examined. They can also be run on-demand using the run
+ * methods, so that different ordering of AsyncTask execution can be simulated.
+ * <p>
+ * The onPreExecute method of the submitted AsyncTask will be called synchronously during the
+ * call to {@link #submit(Object, AsyncTask, Object...)}.
+ */
+@ThreadSafe
+public class FakeAsyncTaskExecutor implements AsyncTaskExecutor {
+ private static final long DEFAULT_TIMEOUT_MS = 10000;
+
+ /** The maximum length of time in ms to wait for tasks to execute during tests. */
+ private final long mTimeoutMs = DEFAULT_TIMEOUT_MS;
+
+ private final Object mLock = new Object();
+ @GuardedBy("mLock") private final List<SubmittedTask> mSubmittedTasks = Lists.newArrayList();
+
+ private final DelayedExecutor mBlockingExecutor = new DelayedExecutor();
+ private final Instrumentation mInstrumentation;
+
+ /** Create a fake AsyncTaskExecutor for use in unit tests. */
+ public FakeAsyncTaskExecutor(Instrumentation instrumentation) {
+ Assert.assertNotNull(instrumentation);
+ mInstrumentation = instrumentation;
+ }
+
+ /** Encapsulates an async task with the params and identifier it was submitted with. */
+ public interface SubmittedTask {
+ Runnable getRunnable();
+ Object getIdentifier();
+ AsyncTask<?, ?, ?> getAsyncTask();
+ }
+
+ private static final class SubmittedTaskImpl implements SubmittedTask {
+ private final Object mIdentifier;
+ private final Runnable mRunnable;
+ private final AsyncTask<?, ?, ?> mAsyncTask;
+
+ public SubmittedTaskImpl(Object identifier, Runnable runnable,
+ AsyncTask<?, ?, ?> asyncTask) {
+ mIdentifier = identifier;
+ mRunnable = runnable;
+ mAsyncTask = asyncTask;
+ }
+
+ @Override
+ public Object getIdentifier() {
+ return mIdentifier;
+ }
+
+ @Override
+ public Runnable getRunnable() {
+ return mRunnable;
+ }
+
+ @Override
+ public AsyncTask<?, ?, ?> getAsyncTask() {
+ return mAsyncTask;
+ }
+
+ @Override
+ public String toString() {
+ return "SubmittedTaskImpl [mIdentifier=" + mIdentifier + "]";
+ }
+ }
+
+ private class DelayedExecutor implements Executor {
+ private final Object mNextLock = new Object();
+ @GuardedBy("mNextLock") private Object mNextIdentifier;
+ @GuardedBy("mNextLock") private AsyncTask<?, ?, ?> mNextTask;
+
+ @Override
+ public void execute(Runnable command) {
+ synchronized (mNextLock) {
+ Assert.assertNotNull(mNextTask);
+ mSubmittedTasks.add(new SubmittedTaskImpl(mNextIdentifier,
+ command, mNextTask));
+ mNextIdentifier = null;
+ mNextTask = null;
+ }
+ }
+
+ public <T> AsyncTask<T, ?, ?> submit(Object identifier,
+ AsyncTask<T, ?, ?> task, T... params) {
+ synchronized (mNextLock) {
+ Assert.assertNull(mNextIdentifier);
+ Assert.assertNull(mNextTask);
+ mNextIdentifier = identifier;
+ Assert.assertNotNull("Already had a valid task.\n"
+ + "Are you calling AsyncTaskExecutor.submit(...) from within the "
+ + "onPreExecute() method of another task being submitted?\n"
+ + "Sorry! Not that's not supported.", task);
+ mNextTask = task;
+ }
+ return task.executeOnExecutor(this, params);
+ }
+ }
+
+ @Override
+ public <T> AsyncTask<T, ?, ?> submit(Object identifier, AsyncTask<T, ?, ?> task, T... params) {
+ AsyncTaskExecutors.checkCalledFromUiThread();
+ return mBlockingExecutor.submit(identifier, task, params);
+ }
+
+ /**
+ * Runs a single task matching the given identifier.
+ * <p>
+ * Removes the matching task from the list of submitted tasks, then runs it. The executor used
+ * to execute this async task will be a same-thread executor.
+ * <p>
+ * Fails if there was not exactly one task matching the given identifier.
+ * <p>
+ * This method blocks until the AsyncTask has completely finished executing.
+ */
+ public void runTask(Object identifier) throws InterruptedException {
+ List<SubmittedTask> tasks = getSubmittedTasksByIdentifier(identifier, true);
+ Assert.assertEquals("Expected one task " + identifier + ", got " + tasks, 1, tasks.size());
+ runTask(tasks.get(0));
+ }
+
+ /**
+ * Runs all tasks whose identifier matches the given identifier.
+ * <p>
+ * Removes all matching tasks from the list of submitted tasks, and runs them. The executor used
+ * to execute these async tasks will be a same-thread executor.
+ * <p>
+ * Fails if there were no tasks matching the given identifier.
+ * <p>
+ * This method blocks until the AsyncTask objects have completely finished executing.
+ */
+ public void runAllTasks(Object identifier) throws InterruptedException {
+ List<SubmittedTask> tasks = getSubmittedTasksByIdentifier(identifier, true);
+ Assert.assertTrue("There were no tasks with identifier " + identifier, tasks.size() > 0);
+ for (SubmittedTask task : tasks) {
+ runTask(task);
+ }
+ }
+
+ /**
+ * Executes a single {@link SubmittedTask}.
+ * <p>
+ * Blocks until the task has completed running.
+ */
+ private <T> void runTask(final SubmittedTask submittedTask) throws InterruptedException {
+ submittedTask.getRunnable().run();
+ // Block until the onPostExecute or onCancelled has finished.
+ // Unfortunately we can't be sure when the AsyncTask will have posted its result handling
+ // code to the main ui thread, the best we can do is wait for the Status to be FINISHED.
+ final CountDownLatch latch = new CountDownLatch(1);
+ class AsyncTaskHasFinishedRunnable implements Runnable {
+ @Override
+ public void run() {
+ if (submittedTask.getAsyncTask().getStatus() == AsyncTask.Status.FINISHED) {
+ latch.countDown();
+ } else {
+ mInstrumentation.waitForIdle(this);
+ }
+ }
+ }
+ mInstrumentation.waitForIdle(new AsyncTaskHasFinishedRunnable());
+ Assert.assertTrue(latch.await(mTimeoutMs, TimeUnit.MILLISECONDS));
+ }
+
+ private List<SubmittedTask> getSubmittedTasksByIdentifier(
+ Object identifier, boolean remove) {
+ Assert.assertNotNull(identifier);
+ List<SubmittedTask> results = Lists.newArrayList();
+ synchronized (mLock) {
+ Iterator<SubmittedTask> iter = mSubmittedTasks.iterator();
+ while (iter.hasNext()) {
+ SubmittedTask task = iter.next();
+ if (identifier.equals(task.getIdentifier())) {
+ results.add(task);
+ iter.remove();
+ }
+ }
+ }
+ return results;
+ }
+
+ /** Get a factory that will return this instance - useful for testing. */
+ public AsyncTaskExecutors.AsyncTaskExecutorFactory getFactory() {
+ return new AsyncTaskExecutors.AsyncTaskExecutorFactory() {
+ @Override
+ public AsyncTaskExecutor createAsyncTaskExeuctor() {
+ return FakeAsyncTaskExecutor.this;
+ }
+ };
+ }
+}
diff --git a/tests/src/com/android/dialer/util/LocaleTestUtils.java b/tests/src/com/android/dialer/util/LocaleTestUtils.java
new file mode 100644
index 000000000..b893ccb76
--- /dev/null
+++ b/tests/src/com/android/dialer/util/LocaleTestUtils.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2011 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.util;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+
+import java.util.Locale;
+
+/**
+ * Utility class to save and restore the locale of the system.
+ * <p>
+ * This can be used for tests that assume to be run in a certain locale, e.g., because they
+ * check against strings in a particular language or require an assumption on how the system
+ * will behave in a specific locale.
+ * <p>
+ * In your test, you can change the locale with the following code:
+ * <pre>
+ * public class CanadaFrenchTest extends AndroidTestCase {
+ * private LocaleTestUtils mLocaleTestUtils;
+ *
+ * &#64;Override
+ * public void setUp() throws Exception {
+ * super.setUp();
+ * mLocaleTestUtils = new LocaleTestUtils(getContext());
+ * mLocaleTestUtils.setLocale(Locale.CANADA_FRENCH);
+ * }
+ *
+ * &#64;Override
+ * public void tearDown() throws Exception {
+ * mLocaleTestUtils.restoreLocale();
+ * mLocaleTestUtils = null;
+ * super.tearDown();
+ * }
+ *
+ * ...
+ * }
+ * </pre>
+ * Note that one should not call {@link #setLocale(Locale)} more than once without calling
+ * {@link #restoreLocale()} first.
+ * <p>
+ * This class is not thread-safe. Usually its methods should be invoked only from the test thread.
+ */
+public class LocaleTestUtils {
+ private final Context mContext;
+ private boolean mSaved;
+ private Locale mSavedContextLocale;
+ private Locale mSavedSystemLocale;
+
+ /**
+ * Create a new instance that can be used to set and reset the locale for the given context.
+ *
+ * @param context the context on which to alter the locale
+ */
+ public LocaleTestUtils(Context context) {
+ mContext = context;
+ mSaved = false;
+ }
+
+ /**
+ * Set the locale to the given value and saves the previous value.
+ *
+ * @param locale the value to which the locale should be set
+ * @throws IllegalStateException if the locale was already set
+ */
+ public void setLocale(Locale locale) {
+ if (mSaved) {
+ throw new IllegalStateException(
+ "call restoreLocale() before calling setLocale() again");
+ }
+ mSavedContextLocale = setResourcesLocale(mContext.getResources(), locale);
+ mSavedSystemLocale = setResourcesLocale(Resources.getSystem(), locale);
+ mSaved = true;
+ }
+
+ /**
+ * Restores the previously set locale.
+ *
+ * @throws IllegalStateException if the locale was not set using {@link #setLocale(Locale)}
+ */
+ public void restoreLocale() {
+ if (!mSaved) {
+ throw new IllegalStateException("call setLocale() before calling restoreLocale()");
+ }
+ setResourcesLocale(mContext.getResources(), mSavedContextLocale);
+ setResourcesLocale(Resources.getSystem(), mSavedSystemLocale);
+ mSaved = false;
+ }
+
+ /**
+ * Sets the locale for the given resources and returns the previous locale.
+ *
+ * @param resources the resources on which to set the locale
+ * @param locale the value to which to set the locale
+ * @return the previous value of the locale for the resources
+ */
+ private Locale setResourcesLocale(Resources resources, Locale locale) {
+ Configuration contextConfiguration = new Configuration(resources.getConfiguration());
+ Locale savedLocale = contextConfiguration.locale;
+ contextConfiguration.locale = locale;
+ resources.updateConfiguration(contextConfiguration, null);
+ return savedLocale;
+ }
+}
diff --git a/tests/src/com/android/dialer/voicemail/VoicemailStatusHelperImplTest.java b/tests/src/com/android/dialer/voicemail/VoicemailStatusHelperImplTest.java
new file mode 100644
index 000000000..2e75d1d01
--- /dev/null
+++ b/tests/src/com/android/dialer/voicemail/VoicemailStatusHelperImplTest.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2011 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.voicemail;
+
+import static android.provider.VoicemailContract.Status.CONFIGURATION_STATE;
+import static android.provider.VoicemailContract.Status.CONFIGURATION_STATE_CAN_BE_CONFIGURED;
+import static android.provider.VoicemailContract.Status.CONFIGURATION_STATE_NOT_CONFIGURED;
+import static android.provider.VoicemailContract.Status.DATA_CHANNEL_STATE;
+import static android.provider.VoicemailContract.Status.DATA_CHANNEL_STATE_NO_CONNECTION;
+import static android.provider.VoicemailContract.Status.DATA_CHANNEL_STATE_OK;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_MESSAGE_WAITING;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_NO_CONNECTION;
+import static android.provider.VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_OK;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.VoicemailContract.Status;
+import android.test.AndroidTestCase;
+
+import com.android.contacts.R;
+import com.android.dialer.voicemail.VoicemailStatusHelper.StatusMessage;
+
+import java.util.List;
+
+/**
+ * Unit tests for {@link VoicemailStatusHelperImpl}.
+ */
+public class VoicemailStatusHelperImplTest extends AndroidTestCase {
+ private static final String[] TEST_PACKAGES = new String[] {
+ "com.test.package1",
+ "com.test.package2"
+ };
+
+ private static final Uri TEST_SETTINGS_URI = Uri.parse("http://www.visual.voicemail.setup");
+ private static final Uri TEST_VOICEMAIL_URI = Uri.parse("tel:901");
+
+ private static final int ACTION_MSG_CALL_VOICEMAIL =
+ R.string.voicemail_status_action_call_server;
+ private static final int ACTION_MSG_CONFIGURE = R.string.voicemail_status_action_configure;
+
+ private static final int STATUS_MSG_NONE = -1;
+ private static final int STATUS_MSG_VOICEMAIL_NOT_AVAILABLE =
+ R.string.voicemail_status_voicemail_not_available;
+ private static final int STATUS_MSG_AUDIO_NOT_AVAIALABLE =
+ R.string.voicemail_status_audio_not_available;
+ private static final int STATUS_MSG_MESSAGE_WAITING = R.string.voicemail_status_messages_waiting;
+ private static final int STATUS_MSG_INVITE_FOR_CONFIGURATION =
+ R.string.voicemail_status_configure_voicemail;
+
+ // Object under test.
+ private VoicemailStatusHelper mStatusHelper;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mStatusHelper = new VoicemailStatusHelperImpl();
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ for (String sourcePackage : TEST_PACKAGES) {
+ deleteEntryForPackage(sourcePackage);
+ }
+ // Set member variables to null so that they are garbage collected across different runs
+ // of the tests.
+ mStatusHelper = null;
+ super.tearDown();
+ }
+
+
+ public void testNoStatusEntries() {
+ assertEquals(0, getStatusMessages().size());
+ }
+
+ public void testAllOK() {
+ insertEntryForPackage(TEST_PACKAGES[0], getAllOkStatusValues());
+ insertEntryForPackage(TEST_PACKAGES[1], getAllOkStatusValues());
+ assertEquals(0, getStatusMessages().size());
+ }
+
+ public void testNotAllOKForOnePackage() {
+ insertEntryForPackage(TEST_PACKAGES[0], getAllOkStatusValues());
+ insertEntryForPackage(TEST_PACKAGES[1], getAllOkStatusValues());
+
+ ContentValues values = new ContentValues();
+ // Good data channel + no notification
+ // action: call voicemail
+ // msg: voicemail not available in call log page & none in call details page.
+ values.put(NOTIFICATION_CHANNEL_STATE, NOTIFICATION_CHANNEL_STATE_NO_CONNECTION);
+ values.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_OK);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkExpectedMessage(TEST_PACKAGES[1], values, STATUS_MSG_VOICEMAIL_NOT_AVAILABLE,
+ STATUS_MSG_NONE, ACTION_MSG_CALL_VOICEMAIL);
+
+ // Message waiting + good data channel - no action.
+ values.put(NOTIFICATION_CHANNEL_STATE, NOTIFICATION_CHANNEL_STATE_MESSAGE_WAITING);
+ values.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_OK);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkNoMessages(TEST_PACKAGES[1], values);
+
+ // No data channel + no notification
+ // action: call voicemail
+ // msg: voicemail not available in call log page & audio not available in call details page.
+ values.put(NOTIFICATION_CHANNEL_STATE, NOTIFICATION_CHANNEL_STATE_OK);
+ values.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_NO_CONNECTION);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkExpectedMessage(TEST_PACKAGES[1], values, STATUS_MSG_VOICEMAIL_NOT_AVAILABLE,
+ STATUS_MSG_AUDIO_NOT_AVAIALABLE, ACTION_MSG_CALL_VOICEMAIL);
+
+ // No data channel + Notification OK
+ // action: call voicemail
+ // msg: voicemail not available in call log page & audio not available in call details page.
+ values.put(NOTIFICATION_CHANNEL_STATE, NOTIFICATION_CHANNEL_STATE_NO_CONNECTION);
+ values.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_NO_CONNECTION);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkExpectedMessage(TEST_PACKAGES[1], values, STATUS_MSG_VOICEMAIL_NOT_AVAILABLE,
+ STATUS_MSG_AUDIO_NOT_AVAIALABLE, ACTION_MSG_CALL_VOICEMAIL);
+
+ // No data channel + Notification OK
+ // action: call voicemail
+ // msg: message waiting in call log page & audio not available in call details page.
+ values.put(NOTIFICATION_CHANNEL_STATE, NOTIFICATION_CHANNEL_STATE_MESSAGE_WAITING);
+ values.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_NO_CONNECTION);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkExpectedMessage(TEST_PACKAGES[1], values, STATUS_MSG_MESSAGE_WAITING,
+ STATUS_MSG_AUDIO_NOT_AVAIALABLE, ACTION_MSG_CALL_VOICEMAIL);
+
+ // Not configured. No user action, so no message.
+ values.put(CONFIGURATION_STATE, CONFIGURATION_STATE_NOT_CONFIGURED);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkNoMessages(TEST_PACKAGES[1], values);
+
+ // Can be configured - invite user for configure voicemail.
+ values.put(CONFIGURATION_STATE, CONFIGURATION_STATE_CAN_BE_CONFIGURED);
+ updateEntryForPackage(TEST_PACKAGES[1], values);
+ checkExpectedMessage(TEST_PACKAGES[1], values, STATUS_MSG_INVITE_FOR_CONFIGURATION,
+ STATUS_MSG_NONE, ACTION_MSG_CONFIGURE, TEST_SETTINGS_URI);
+ }
+
+ // Test that priority of messages are handled well.
+ public void testMessageOrdering() {
+ insertEntryForPackage(TEST_PACKAGES[0], getAllOkStatusValues());
+ insertEntryForPackage(TEST_PACKAGES[1], getAllOkStatusValues());
+
+ final ContentValues valuesNoNotificationGoodDataChannel = new ContentValues();
+ valuesNoNotificationGoodDataChannel.put(NOTIFICATION_CHANNEL_STATE,
+ NOTIFICATION_CHANNEL_STATE_NO_CONNECTION);
+ valuesNoNotificationGoodDataChannel.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_OK);
+
+ final ContentValues valuesNoNotificationNoDataChannel = new ContentValues();
+ valuesNoNotificationNoDataChannel.put(NOTIFICATION_CHANNEL_STATE,
+ NOTIFICATION_CHANNEL_STATE_NO_CONNECTION);
+ valuesNoNotificationNoDataChannel.put(DATA_CHANNEL_STATE, DATA_CHANNEL_STATE_NO_CONNECTION);
+
+ // Package1 with valuesNoNotificationGoodDataChannel and
+ // package2 with valuesNoNotificationNoDataChannel. Package2 should be above.
+ updateEntryForPackage(TEST_PACKAGES[0], valuesNoNotificationGoodDataChannel);
+ updateEntryForPackage(TEST_PACKAGES[1], valuesNoNotificationNoDataChannel);
+ List<StatusMessage> messages = getStatusMessages();
+ assertEquals(2, messages.size());
+ assertEquals(TEST_PACKAGES[0], messages.get(1).sourcePackage);
+ assertEquals(TEST_PACKAGES[1], messages.get(0).sourcePackage);
+
+ // Now reverse the values - ordering should be reversed as well.
+ updateEntryForPackage(TEST_PACKAGES[0], valuesNoNotificationNoDataChannel);
+ updateEntryForPackage(TEST_PACKAGES[1], valuesNoNotificationGoodDataChannel);
+ messages = getStatusMessages();
+ assertEquals(2, messages.size());
+ assertEquals(TEST_PACKAGES[0], messages.get(0).sourcePackage);
+ assertEquals(TEST_PACKAGES[1], messages.get(1).sourcePackage);
+ }
+
+ /** Checks that the expected source status message is returned by VoicemailStatusHelper. */
+ private void checkExpectedMessage(String sourcePackage, ContentValues values,
+ int expectedCallLogMsg, int expectedCallDetailsMsg, int expectedActionMsg,
+ Uri expectedUri) {
+ List<StatusMessage> messages = getStatusMessages();
+ assertEquals(1, messages.size());
+ checkMessageMatches(messages.get(0), sourcePackage, expectedCallLogMsg,
+ expectedCallDetailsMsg, expectedActionMsg, expectedUri);
+ }
+
+ private void checkExpectedMessage(String sourcePackage, ContentValues values,
+ int expectedCallLogMsg, int expectedCallDetailsMessage, int expectedActionMsg) {
+ checkExpectedMessage(sourcePackage, values, expectedCallLogMsg, expectedCallDetailsMessage,
+ expectedActionMsg, TEST_VOICEMAIL_URI);
+ }
+
+ private void checkMessageMatches(StatusMessage message, String expectedSourcePackage,
+ int expectedCallLogMsg, int expectedCallDetailsMsg, int expectedActionMsg,
+ Uri expectedUri) {
+ assertEquals(expectedSourcePackage, message.sourcePackage);
+ assertEquals(expectedCallLogMsg, message.callLogMessageId);
+ assertEquals(expectedCallDetailsMsg, message.callDetailsMessageId);
+ assertEquals(expectedActionMsg, message.actionMessageId);
+ if (expectedUri == null) {
+ assertNull(message.actionUri);
+ } else {
+ assertEquals(expectedUri, message.actionUri);
+ }
+ }
+
+ private void checkNoMessages(String sourcePackage, ContentValues values) {
+ assertEquals(1, updateEntryForPackage(sourcePackage, values));
+ List<StatusMessage> messages = getStatusMessages();
+ assertEquals(0, messages.size());
+ }
+
+ private ContentValues getAllOkStatusValues() {
+ ContentValues values = new ContentValues();
+ values.put(Status.SETTINGS_URI, TEST_SETTINGS_URI.toString());
+ values.put(Status.VOICEMAIL_ACCESS_URI, TEST_VOICEMAIL_URI.toString());
+ values.put(Status.CONFIGURATION_STATE, Status.CONFIGURATION_STATE_OK);
+ values.put(Status.DATA_CHANNEL_STATE, Status.DATA_CHANNEL_STATE_OK);
+ values.put(Status.NOTIFICATION_CHANNEL_STATE, Status.NOTIFICATION_CHANNEL_STATE_OK);
+ return values;
+ }
+
+ private void insertEntryForPackage(String sourcePackage, ContentValues values) {
+ // If insertion fails then try update as the record might already exist.
+ if (getContentResolver().insert(Status.buildSourceUri(sourcePackage), values) == null) {
+ updateEntryForPackage(sourcePackage, values);
+ }
+ }
+
+ private void deleteEntryForPackage(String sourcePackage) {
+ getContentResolver().delete(Status.buildSourceUri(sourcePackage), null, null);
+ }
+
+ private int updateEntryForPackage(String sourcePackage, ContentValues values) {
+ return getContentResolver().update(
+ Status.buildSourceUri(sourcePackage), values, null, null);
+ }
+
+ private List<StatusMessage> getStatusMessages() {
+ // Restrict the cursor to only the the test packages to eliminate any side effects if there
+ // are other status messages already stored on the device.
+ Cursor cursor = getContentResolver().query(Status.CONTENT_URI,
+ VoicemailStatusHelperImpl.PROJECTION, getTestPackageSelection(), null, null);
+ return mStatusHelper.getStatusMessages(cursor);
+ }
+
+ private String getTestPackageSelection() {
+ StringBuilder sb = new StringBuilder();
+ for (String sourcePackage : TEST_PACKAGES) {
+ if (sb.length() > 0) {
+ sb.append(" OR ");
+ }
+ sb.append(String.format("(source_package='%s')", sourcePackage));
+ }
+ return sb.toString();
+ }
+
+ private ContentResolver getContentResolver() {
+ return getContext().getContentResolver();
+ }
+}