/*
* Copyright 2015. Appsi Mobile
*
* 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.appsimobile.appsii.module.calls;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.AsyncTaskLoader;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.provider.CallLog;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.v4.util.SimpleArrayMap;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.text.format.Time;
import android.util.Log;
import com.appsimobile.appsii.PermissionDeniedException;
import com.appsimobile.appsii.dagger.AppInjector;
import com.appsimobile.appsii.module.BaseContactInfo;
import com.appsimobile.appsii.permissions.PermissionUtils;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import com.google.i18n.phonenumbers.geocoding.PhoneNumberOfflineGeocoder;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
import static android.provider.ContactsContract.CommonDataKinds;
/**
* A custom Loader that loads all of the installed applications.
*/
public class CallLogLoader extends AsyncTaskLoader<CallLogResult> {
public static final String UNKNOWN_NUMBER = "-1";
public static final String PRIVATE_NUMBER = "-2";
public static final String PAYPHONE_NUMBER = "-3";
static final boolean LOGD = false;
final PhoneNumberUtil mPhoneNumberUtil = PhoneNumberUtil.getInstance();
CallLogResult mCallLogEntries;
ContentObserver mCallLogObserver;
BroadcastReceiver mPermissionGrantedReceiver;
@Inject
PermissionUtils mPermissionUtils;
@Inject
TelephonyManager mTelephonyManager;
public CallLogLoader(Context context) {
super(context);
}
public static String getCountry(TelephonyManager telephonyManager) {
TelephonyManager tm = telephonyManager;
if (tm.getSimState() == TelephonyManager.SIM_STATE_ABSENT) {
return Locale.getDefault().getCountry();
}
return tm.getSimCountryIso();
}
public static int getPresentationTypeCompat(Cursor cursor) {
String phone = cursor.getString(CallLogQuery.NUMBER);
if (phone == null) phone = PRIVATE_NUMBER;
return toPresentationTypeCompat(phone);
}
// In this method we suppress the inlined api as we just return a constant we use
// later on. Be careful with adding stuff here
@SuppressLint("InlinedApi")
private static int toPresentationTypeCompat(@NonNull String phone) {
switch (phone) {
case PRIVATE_NUMBER:
return CallLog.Calls.PRESENTATION_RESTRICTED;
case UNKNOWN_NUMBER:
return CallLog.Calls.PRESENTATION_UNKNOWN;
case PAYPHONE_NUMBER:
return CallLog.Calls.PRESENTATION_PAYPHONE;
default:
return CallLog.Calls.PRESENTATION_ALLOWED;
}
}
/**
* Handles a request to cancel a load.
*/
@Override
public void onCanceled(CallLogResult apps) {
super.onCanceled(apps);
// At this point we can release the resources associated with 'apps'
// if needed.
onReleaseResources(apps);
}
/**
* This is where the bulk of our work is done. This function is
* called in a background thread and should generate a new set of
* data to be published by the loader.
*/
@Override
public CallLogResult loadInBackground() {
// Retrieve all known applications.
if (!mPermissionUtils.holdsPermission(getContext(), Manifest.permission.READ_CONTACTS)) {
return new CallLogResult(
new PermissionDeniedException(Manifest.permission.READ_CONTACTS));
}
if (!mPermissionUtils.holdsPermission(getContext(), Manifest.permission.READ_CALL_LOG)) {
return new CallLogResult(
new PermissionDeniedException(Manifest.permission.READ_CALL_LOG));
}
final Context context = getContext();
SimpleArrayMap<String, BaseContactInfo> contactsByNumber = loadContactsByNumber(context);
Uri baseUri;
// baseUri = CallLog.Calls.CONTENT_URI.buildUpon().
// appendQueryParameter(CallLog.Calls.LIMIT_PARAM_KEY, "50").
// build();
baseUri = CallLog.Calls.CONTENT_URI;
// Now create and return a CursorLoader that will take care of
// creating a Cursor for the data being displayed.
Cursor cursor = context.getContentResolver().query(baseUri,
CallLogQuery.PROJECTION, null, null,
CallLog.Calls.DEFAULT_SORT_ORDER);
if (cursor == null) {
return new CallLogResult(new ArrayList<CallLogEntry>());
}
Phonenumber.PhoneNumber recycle = new Phonenumber.PhoneNumber();
StringBuilder reuse = new StringBuilder();
try {
// Create corresponding array of entries and load their labels.
List<CallLogEntry> entries = new ArrayList<>(9);
String country = getCountry(mTelephonyManager).toUpperCase();
// We need to remember three things, to be able to group the calls
// 1. the last number, repetitive calls to and from the same number
// are grouped to one item
// 2. We only group events on a single day
// 3. If the same number is encountered on the same day, we simply add
// that occurrence to the entry
String lastNumber = null;
CallLogEntry lastEntry = null;
int lastDay = 0;
while (cursor.moveToNext()) {
// first get the values with which we can determine if we can
// group with the the previous entry
String phoneNumber = cursor.getString(CallLogQuery.NUMBER);
long timeMillis = cursor.getLong(CallLogQuery.DATE);
int julianDay = Time.getJulianDay(timeMillis, 0);
String formatted = formatNumber(recycle, reuse, phoneNumber, country);
int callType = cursor.getInt(CallLogQuery.CALL_TYPE);
// If we can group, simply add the occurrence to the previous entry
if (formatted != null && formatted.equals(lastNumber) && lastDay == julianDay) {
lastEntry.addCallType(callType);
continue;
}
// If we reach this point create the entry and populate it.
CallLogEntry entry = new CallLogEntry();
// remember the last values
lastEntry = entry;
lastNumber = formatted;
lastDay = julianDay;
// populate the entity
entry.addCallType(callType);
entry.mNumber = phoneNumber;
entry.mFormattedNumber = formatted;
entry.mMillis = timeMillis;
entry.mJulianDay = julianDay;
entry.mBaseContactInfo = contactsByNumber.get(entry.mFormattedNumber);
if (entry.mBaseContactInfo == null) {
entry.mBaseContactInfo = contactsByNumber.get(entry.mNumber);
}
populateEntry(entry, cursor, recycle);
addFormattedNumbers(entry, recycle, reuse, entry.mNumber, country);
entries.add(entry);
// if (entries.size() >= 9) break;
}
return new CallLogResult(entries);
} finally {
cursor.close();
}
}
private SimpleArrayMap<String, BaseContactInfo> loadContactsByNumber(Context context) {
SimpleArrayMap<String, BaseContactInfo> result = new SimpleArrayMap<>();
Cursor c = context.getContentResolver().query(
CommonDataKinds.Phone.CONTENT_URI,
ContactsByNumberQuery.PROJECTION,
null,
null,
null);
while (c.moveToNext()) {
String normalizedNumber = c.getString(ContactsByNumberQuery.NORMALIZED_NUMBER);
String plainNumber = c.getString(ContactsByNumberQuery.NUMBER);
if (normalizedNumber == null && plainNumber == null) continue;
if (normalizedNumber != null && result.containsKey(normalizedNumber)) continue;
if (plainNumber != null && result.containsKey(plainNumber)) continue;
BaseContactInfo info = new BaseContactInfo();
info.mContactId = c.getLong(ContactsByNumberQuery.CONTACT_ID);
info.mLookupKey = c.getString(ContactsByNumberQuery.LOOKUP_KEY);
info.mContactLookupUri =
ContactsContract.Contacts.getLookupUri(info.mContactId, info.mLookupKey);
info.mDisplayName = c.getString(ContactsByNumberQuery.DISPLAY_NAME);
info.mDisplayNameSource = c.getInt(ContactsByNumberQuery.DISPLAY_NAME_SOURCE);
info.mPhotoUri = c.getString(ContactsByNumberQuery.PHOTO_URI);
info.mStarred = c.getInt(ContactsByNumberQuery.STARRED) == 1;
result.put(normalizedNumber, info);
if (!TextUtils.equals(plainNumber, normalizedNumber)) {
result.put(plainNumber, info);
}
}
c.close();
return result;
}
private String formatNumber(Phonenumber.PhoneNumber recycle, StringBuilder reuse, String number,
String country) {
if (TextUtils.isEmpty(number)) return null;
recycle.clear();
reuse.setLength(0);
try {
mPhoneNumberUtil.parse(number, country, recycle);
mPhoneNumberUtil.format(recycle, PhoneNumberUtil.PhoneNumberFormat.E164, reuse);
} catch (NumberParseException e) {
Log.w("CallLogLoader", "error formatting nr", e);
return number;
}
return reuse.toString();
}
private void populateEntry(CallLogEntry entry, Cursor cursor,
Phonenumber.PhoneNumber number) {
Context context = getContext();
entry.mId = cursor.getLong(CallLogQuery.ID);
entry.mIsRead = cursor.getInt(CallLogQuery.IS_READ) == 1;
entry.mCachedName = cursor.getString(CallLogQuery.CACHED_NAME);
PhoneNumberOfflineGeocoder geocoder = PhoneNumberOfflineGeocoder.getInstance();
entry.mGeoCodedLocation = geocoder.getDescriptionForNumber(number, Locale.getDefault());
int presentationType = getPresentationType(cursor);
entry.mPrivateNumber = isPrivateNumber(presentationType);
int numberType = cursor.getInt(CallLogQuery.CACHED_NUMBER_TYPE);
String numberLabel = cursor.getString(CallLogQuery.CACHED_NUMBER_LABEL);
entry.mNumberTypeLabel = CommonDataKinds.Phone.getTypeLabel(
context.getResources(), numberType, numberLabel);
// TODO: we could add the time of the call to the existing entry
if (LOGD) Log.d("CallLogLoader", "Checking nr: " + entry.mNumber);
}
private void addFormattedNumbers(CallLogEntry entry, Phonenumber.PhoneNumber recycle,
StringBuilder reuse, String number, String country) {
if (TextUtils.isEmpty(number)) return;
try {
int localCountry = mPhoneNumberUtil.getCountryCodeForRegion(country);
recycle.clear();
mPhoneNumberUtil.parse(number, country, recycle);
reuse.setLength(0);
mPhoneNumberUtil.format(recycle, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL,
reuse);
entry.mNumberInternational = reuse.toString();
entry.mCanRenderAsNational = recycle.getCountryCode() == localCountry;
recycle.clear();
mPhoneNumberUtil.parse(number, country, recycle);
reuse.setLength(0);
mPhoneNumberUtil.format(recycle, PhoneNumberUtil.PhoneNumberFormat.NATIONAL, reuse);
entry.mNumberNational = reuse.toString();
recycle.clear();
mPhoneNumberUtil.parse(number, country, recycle);
reuse.setLength(0);
mPhoneNumberUtil.format(recycle, PhoneNumberUtil.PhoneNumberFormat.RFC3966, reuse);
entry.mNumberRfc3966 = reuse.toString();
} catch (NumberParseException e) {
Log.w("CallLogLoader", "error formatting nr", e);
}
}
private int getPresentationType(Cursor cursor) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return getPresentationTypeV19(cursor);
}
return getPresentationTypeCompat(cursor);
}
boolean isPrivateNumber(int presentationType) {
return presentationType != CallLog.Calls.PRESENTATION_ALLOWED;
}
private int getPresentationTypeV19(Cursor cursor) {
return cursor.getInt(CallLogQuery.NUMBER_PRESENTATION);
}
/**
* Helper function to take care of releasing resources associated
* with an actively loaded data set.
*/
protected void onReleaseResources(CallLogResult apps) {
// For a simple List<> there is nothing to do. For something
// like a Cursor, we would close it here.
}
/**
* Called when there is new data to deliver to the client. The
* super class will take care of delivering it; the implementation
* here just adds a little more logic.
*/
@Override
public void deliverResult(CallLogResult apps) {
if (isReset()) {
// An async query came in while the loader is stopped. We
// don't need the result.
if (apps != null) {
onReleaseResources(apps);
}
}
CallLogResult oldApps = mCallLogEntries;
mCallLogEntries = apps;
if (isStarted()) {
// If the Loader is currently started, we can immediately
// deliver its results.
super.deliverResult(apps);
}
// At this point we can release the resources associated with
// 'oldApps' if needed; now that the new result is delivered we
// know that it is no longer in use.
if (oldApps != null) {
onReleaseResources(oldApps);
}
}
/**
* Handles a request to start the Loader.
*/
@Override
protected void onStartLoading() {
AppInjector.inject(this);
if (mCallLogEntries != null) {
// If we currently have a result available, deliver it
// immediately.
deliverResult(mCallLogEntries);
}
// Start watching for changes in the app data.
if (mCallLogObserver == null) {
mCallLogObserver = new ContentObserver(new Handler()) {
@Override
public void onChange(boolean selfChange) {
onChange(selfChange, null);
}
@Override
public void onChange(boolean selfChange, Uri uri) {
onContentChanged();
}
};
getContext().getContentResolver()
.registerContentObserver(CallLog.Calls.CONTENT_URI, true, mCallLogObserver);
}
mPermissionGrantedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
int req = intent.getIntExtra(PermissionUtils.EXTRA_REQUEST_CODE, 0);
if (req == PermissionUtils.REQUEST_CODE_PERMISSION_READ_CALL_LOG) {
onContentChanged();
}
}
};
IntentFilter filter2 = new IntentFilter(PermissionUtils.ACTION_PERMISSION_RESULT);
getContext().registerReceiver(mPermissionGrantedReceiver, filter2);
// Has something interesting in the configuration changed since we
// last built the app list?
if (takeContentChanged() || mCallLogEntries == null) {
// If the data has changed since the last time it was loaded
// or is not currently available, start a load.
forceLoad();
}
}
/**
* Handles a request to stop the Loader.
*/
@Override
protected void onStopLoading() {
// Attempt to cancel the current load task if possible.
cancelLoad();
}
/**
* Handles a request to completely reset the Loader.
*/
@Override
protected void onReset() {
super.onReset();
// Ensure the loader is stopped
onStopLoading();
// At this point we can release the resources associated with 'apps'
// if needed.
if (mCallLogEntries != null) {
//onReleaseResources(mCallLogEntries);
//mCallLogEntries = null;
}
// Stop monitoring for changes.
if (mCallLogObserver != null) {
getContext().getContentResolver().unregisterContentObserver(mCallLogObserver);
mCallLogObserver = null;
}
if (mPermissionGrantedReceiver != null) {
getContext().unregisterReceiver(mPermissionGrantedReceiver);
}
}
static class ContactsByNumberQuery {
final static String[] PROJECTION = new String[]{
CommonDataKinds.Phone.CONTACT_ID,
CommonDataKinds.Phone.NUMBER,
CommonDataKinds.Phone.NORMALIZED_NUMBER,
CommonDataKinds.Phone.DISPLAY_NAME,
CommonDataKinds.Phone.PHONETIC_NAME,
CommonDataKinds.Phone.CONTACT_PRESENCE,
CommonDataKinds.Phone.PHOTO_ID,
CommonDataKinds.Phone.LOOKUP_KEY,
CommonDataKinds.Phone.PHOTO_URI,
CommonDataKinds.Phone.DISPLAY_NAME_SOURCE,
CommonDataKinds.Phone.STARRED,
};
final static int CONTACT_ID = 0;
final static int NUMBER = 1;
final static int NORMALIZED_NUMBER = 2;
final static int DISPLAY_NAME = 3;
final static int PHONETIC_NAME = 4;
final static int CONTACT_PRESENCE = 5;
final static int PHOTO_ID = 6;
final static int LOOKUP_KEY = 7;
final static int PHOTO_URI = 8;
final static int DISPLAY_NAME_SOURCE = 9;
final static int STARRED = 10;
}
}