package org.theotech.ceaselessandroid.person;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.preference.PreferenceManager;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.v4.util.Pair;
import android.util.Log;
import com.google.android.gms.analytics.Tracker;
import org.theotech.ceaselessandroid.CeaselessApplication;
import org.theotech.ceaselessandroid.R;
import org.theotech.ceaselessandroid.cache.CacheManager;
import org.theotech.ceaselessandroid.cache.LocalDailyCacheManagerImpl;
import org.theotech.ceaselessandroid.realm.Note;
import org.theotech.ceaselessandroid.realm.Person;
import org.theotech.ceaselessandroid.realm.pojo.PersonPOJO;
import org.theotech.ceaselessandroid.util.AnalyticsUtils;
import org.theotech.ceaselessandroid.util.Constants;
import org.theotech.ceaselessandroid.util.Installation;
import org.theotech.ceaselessandroid.util.RealmUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import io.realm.Realm;
import io.realm.RealmList;
import io.realm.RealmQuery;
import io.realm.RealmResults;
/**
* Created by Ben Johnson on 10/3/15.
*/
public class PersonManagerImpl implements PersonManager {
private static final String TAG = PersonManagerImpl.class.getSimpleName();
private static final String CONTACTS_SOURCE = "Contacts";
private static final int RANDOM_FAVORITE_THRESHOLD = 5;
private static final int RANDOM_SAMPLE_POST_METRICS = 2;
private static PersonManager instance;
private Activity activity;
private Realm realm;
private ContentResolver contentResolver;
private CacheManager cacheManager;
private Tracker mTracker;
private PersonManagerImpl(Activity activity) {
this.activity = activity;
this.realm = Realm.getDefaultInstance();
this.contentResolver = this.activity.getContentResolver();
this.cacheManager = LocalDailyCacheManagerImpl.getInstance(activity);
// setup analytics
CeaselessApplication application = (CeaselessApplication) activity.getApplication();
mTracker = application.getDefaultTracker();
}
public static PersonManager getInstance(Activity activity) {
if (instance == null) {
instance = new PersonManagerImpl(activity);
}
return instance;
}
@Override
public RealmList<Person> getPersonFromPersonPOJO(List<PersonPOJO> people) {
RealmList<Person> listOfPersons = new RealmList<>();
if (people == null || people.size() == 0) {
return listOfPersons;
}
RealmQuery<Person> query = realm.where(Person.class)
.equalTo(Person.Column.ID, people.get(0).getId());
for (int i = 1; i < people.size(); i++) {
query = query.or().equalTo(Person.Column.ID, people.get(i).getId());
}
RealmResults<Person> results = query.findAll();
for (int i = 0; i < results.size(); i++) {
listOfPersons.add(results.get(i));
}
return listOfPersons;
}
@Override
public List<PersonPOJO> getNextPeopleToPrayFor(int n) throws AlreadyPrayedForAllContactsException {
List<PersonPOJO> peopleToPrayFor = new ArrayList<>();
// Get all people who haven't been prayed for, sorted by last_prayed
RealmResults<Person> results = realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, false)
.equalTo(Person.Column.PRAYED, false)
.findAllSorted(Person.Column.LAST_PRAYED);
handleAllPrayedFor(results); // resets all the prayed flags and
// throws AlreadyPrayedForAllContactsException when needed
// We still have people available to be prayed for
List<Person> peopleAvailableToBePrayedFor = getShuffledListOfAllPeople(results);
if (peopleAvailableToBePrayedFor.size() < 1) {
return peopleToPrayFor;
}
// Add preselectedPerson to prayer list if available
Person preselectedPerson = loadPreselectedPerson();
if (preselectedPerson != null) {
selectPerson(preselectedPerson);
peopleToPrayFor.add(getPerson(preselectedPerson.getId()));
peopleAvailableToBePrayedFor.remove(preselectedPerson);
}
// Add more people to pray for (until we run out of unprayed people or have enough)
Integer numToSelect = Math.min(n - peopleToPrayFor.size(),
peopleAvailableToBePrayedFor.size());
int personIndex = 0;
for (int i = 0; i < numToSelect; i++) {
Person person = peopleAvailableToBePrayedFor.get(personIndex);
// Select this person if they haven't been chosen yet
if (!peopleToPrayFor.contains(getPerson(person.getId()))) {
selectPerson(person);
peopleToPrayFor.add(getPerson(person.getId()));
peopleAvailableToBePrayedFor.remove(person);
personIndex--;
}
personIndex++;
}
if (peopleAvailableToBePrayedFor.size() > 0) {
preselectPerson(peopleAvailableToBePrayedFor);
}
return peopleToPrayFor;
}
@NonNull
private List<Person> getShuffledListOfAllPeople(RealmResults<Person> results) {
// shuffle the list of people
List<Person> allPeople = new ArrayList<>();
for (int i = 0; i < results.size(); i++) {
allPeople.add(results.get(i));
}
Collections.shuffle(allPeople);
return allPeople;
}
private void handleAllPrayedFor(RealmResults<Person> results) throws AlreadyPrayedForAllContactsException {
// if all people are prayed for, then reset and throw exception
if (getNumPeople() > 0 && results.size() == 0) {
// Reset all the prayed flags
realm.beginTransaction();
RealmResults<Person> resultsToReset = realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, false)
.findAll();
for (int i = 0; i < resultsToReset.size(); i++) {
resultsToReset.get(i).setPrayed(false);
}
realm.commitTransaction();
// Throw Exception
throw new AlreadyPrayedForAllContactsException(getNumPeople());
}
}
private Person selectFavoritedPerson() {
RealmResults<Person> favoritedPeople = realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.FAVORITE, true)
.findAllSorted(Person.Column.LAST_PRAYED);
if (favoritedPeople.size() > 0) {
if (favoritedPeople.size() < RANDOM_FAVORITE_THRESHOLD) {
// 1/4 chance of getting a favorited person
Random random = new Random();
if (random.nextInt(RANDOM_FAVORITE_THRESHOLD) == 0) {
return favoritedPeople.get(0);
}
} else {
// always show a favorited person if they have
// favorited more than 7 people
return favoritedPeople.get(0);
}
}
return null;
}
private void preselectPerson(List<Person> allPeople) {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(activity);
Person theChosenOne = selectFavoritedPerson();
if (theChosenOne == null) {
theChosenOne = allPeople.get(0);
}
sp.edit()
.putString(Constants.PRESELECTED_PERSON_ID, theChosenOne.getId())
.putString(Constants.PRESELECTED_PERSON_NAME, theChosenOne.getName())
.apply();
}
private Person loadPreselectedPerson() {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(activity);
String personId = sp.getString(Constants.PRESELECTED_PERSON_ID, null);
if (personId == null) {
return null;
}
return getRealmPerson(personId);
}
private void selectPerson(Person person) {
realm.beginTransaction();
person.setLastPrayed(new Date());
person.setPrayed(true);
realm.commitTransaction();
Log.d(TAG, "Selecting person " + person);
}
@Override
public List<PersonPOJO> getActivePeople() {
return RealmUtils.toPersonPOJOs(realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, false)
.findAllSorted(Person.Column.NAME));
}
@Override
public List<PersonPOJO> getRemovedPeople() {
return RealmUtils.toPersonPOJOs(realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, true)
.findAllSorted(Person.Column.NAME));
}
@Override
public long getNumPrayed() {
return realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, false)
.equalTo(Person.Column.PRAYED, true)
.count();
}
@Override
public long getNumPeople() {
return realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, false)
.count();
}
@Override
public long getNumFavoritedPeople() {
return realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, false)
.equalTo(Person.Column.FAVORITE, true)
.count();
}
@Override
public long getNumRemovedPeople() {
return realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.equalTo(Person.Column.IGNORED, true)
.count();
}
@Override
public PersonPOJO getPerson(String personId) {
return RealmUtils.toPersonPOJO(getRealmPerson(personId));
}
@Override
public Person getRealmPerson(String personId) {
return realm.where(Person.class)
.equalTo(Person.Column.ID, personId)
.findFirst();
}
@Override
public void ignorePerson(String personId) {
realm.beginTransaction();
Person person = getRealmPerson(personId);
person.setIgnored(true);
person.setFavorite(false);
realm.commitTransaction();
}
@Override
public void unignorePerson(String personId) {
realm.beginTransaction();
getRealmPerson(personId).setIgnored(false);
realm.commitTransaction();
}
@Override
public void favoritePerson(String personId) {
realm.beginTransaction();
getRealmPerson(personId).setFavorite(true);
realm.commitTransaction();
}
@Override
public void unfavoritePerson(String personId) {
realm.beginTransaction();
getRealmPerson(personId).setFavorite(false);
realm.commitTransaction();
}
public Realm getRealm() {
return realm;
}
public void setRealm(Realm realm) {
this.realm = realm;
}
@Override
public void populateContacts() {
Cursor cursor = null;
realm.beginTransaction();
int added = 0;
int updated = 0;
List<String> ids = new ArrayList<>();
List<Person> people = new ArrayList<>();
try {
cursor = contentResolver.query(ContactsContract.Contacts.CONTENT_URI, null, null, null, null);
while (cursor.moveToNext()) {
if (isValidContact(cursor)) {
String id = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts._ID));
ids.add(id);
String name = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME));
Log.v(TAG, String.format("Person: id=%s name=%s", id, name));
Person person = realm.where(Person.class)
.equalTo(Person.Column.ID, id)
.findFirst();
if (person == null) {
person = realm.createObject(Person.class);
person.setId(id);
person.setName(name);
person.setSource(CONTACTS_SOURCE);
person.setActive(true);
person.setIgnored(false);
person.setLastPrayed(new Date(0L));
// auto-favorite contacts that were starred by user.
boolean starred = cursor.getInt(cursor.getColumnIndex(ContactsContract.Contacts.STARRED)) == 1;
person.setFavorite(starred);
++added;
} else {
Log.v(TAG, "User already existed, updating information.");
// Update the name in case the user updates it
person.setName(name);
person.setActive(true);
++updated;
}
people.add(person);
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
// Clean up contacts whose ids have now changed
// Copy their contents to an existing contact if there is a matching one.
RealmResults<Person> fullList = realm.where(Person.class)
.equalTo(Person.Column.ACTIVE, true)
.findAll();
List<Pair<String, Person>> peopleWithUpdatedIds = new ArrayList<>();
for (int i = 0; i < fullList.size(); i++) {
Person p = fullList.get(i);
if (!ids.contains(p.getId())) {
Log.v(TAG, "Contact with id " + p.getId() + " no longer exists.");
Person matchingContact = findPersonWithName(p.getName(), people);
if (matchingContact != null) {
Log.v(TAG, "A contact with name " + p.getName() + " exists. Merging details.");
copyContact(p, matchingContact);
// update daily cache with new ids if any persons selected for today are affected
peopleWithUpdatedIds.add(Pair.create(p.getId(), matchingContact));
// cleanup the obsolete contact (must be after we've update the cache)
p.removeFromRealm();
} else {
Log.v(TAG, "Marking this contact inactive because it no longer exists on the phone.");
p.setActive(false);
peopleWithUpdatedIds.add(Pair.create(p.getId(), (Person) null));
}
}
}
realm.commitTransaction();
// we need to update the cache in a separate transaction
for (Pair<String, Person> p : peopleWithUpdatedIds) {
updateCachedPersonIds(p.first, p.second);
}
Log.d(TAG, String.format("Successfully added %d and updated %d contacts.", added, updated));
sampleAndPostMetrics();
}
private void updateCachedPersonIds(String oldContactId, Person newContact) {
List<String> personIds = cacheManager.getCachedPersonIdsToPrayFor();
if (personIds != null && personIds.contains(oldContactId)) {
Log.i(TAG, "Updating a selected contact in daily cache.");
personIds.remove(oldContactId);
if (newContact != null) {
personIds.add(newContact.getId());
}
cacheManager.cachePersonIdsToPrayFor(personIds);
}
}
private Person findPersonWithName(String name, List<Person> people) {
for (Person p : people) {
if (p.getName().equals(name)) {
return p;
}
}
return null;
}
private void copyContact(Person src, Person dst) {
// do not copy id or name.
// copy over everything else
dst.setActive(src.isActive());
dst.setFavorite(src.isFavorite());
dst.setIgnored(src.isIgnored());
dst.setLastPrayed(src.getLastPrayed());
dst.setPrayed(src.isPrayed());
dst.setSource(src.getSource());
transferNotes(src, dst);
// TODO potentially update the Cache
// so all ids that pointed to the old contact, point to the new.
}
private void transferNotes(Person src, Person dst) {
// transfer notes over
RealmList<Note> notes = src.getNotes();
for (Note n : notes) {
RealmList<Person> peopleTagged = n.getPeopleTagged();
RealmList<Person> newPeopleTagged = new RealmList<>();
Iterator<Person> i = peopleTagged.iterator();
// remove the old version of this person on the note
while (i.hasNext()) {
Person p = i.next();
if (p.getId().equals(src.getId())) {
// add the new version of this person on the note
newPeopleTagged.add(dst);
} else {
newPeopleTagged.add(p);
}
}
n.setPeopleTagged(newPeopleTagged);
}
dst.setNotes(src.getNotes());
}
@Override
public List<PersonPOJO> queryPeopleByName(String query) {
List<PersonPOJO> people = RealmUtils.toPersonPOJOs(realm.where(Person.class)
.contains(Person.Column.NAME, query, false)
.equalTo(Person.Column.ACTIVE, true)
.findAllSorted(Person.Column.NAME));
return people;
}
private boolean isValidContact(Cursor cursor) {
boolean hasPhoneNumber = cursor.getInt(cursor.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER)) == 1;
String name = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME));
return hasPhoneNumber && !name.startsWith("#") && !name.startsWith("+") && contactNameFilter(name);
}
private boolean contactNameFilter(String name) {
if (name == null || name.isEmpty()) {
return false;
}
if (Character.isDigit(name.charAt(0))) {
// if the name starts with a number ignore it.
return false;
}
if (android.util.Patterns.EMAIL_ADDRESS.matcher(name).matches()) {
return false;
}
List<String> blacklist = Arrays.asList(activity.getResources().getStringArray(R.array.contact_name_blacklist));
for (String s : blacklist) {
// match the name to the blacklist elements
// since the argument to matches is a regular expression
// note that we can also use Pattern.quote to not treat it as a regular expression.
// we do it this way since in the future our blacklist may want to consist in regular expressions
if (name.matches(s)) {
return false;
}
}
return true;
}
private void sampleAndPostMetrics() {
Random random = new Random();
if (random.nextInt(RANDOM_SAMPLE_POST_METRICS) == 0) {
Log.i(TAG, "Posting contact metrics for analytics");
String installationId = Installation.id(activity);
AnalyticsUtils.sendEventWithCategoryAndValue(mTracker,
getString(R.string.ga_address_book_sync),
getString(R.string.ga_post_total_active_contacts),
installationId,
getNumPeople());
AnalyticsUtils.sendEventWithCategoryAndValue(mTracker,
getString(R.string.ga_address_book_sync),
getString(R.string.ga_post_total_favorited),
installationId,
getNumFavoritedPeople());
AnalyticsUtils.sendEventWithCategoryAndValue(mTracker,
getString(R.string.ga_address_book_sync),
getString(R.string.ga_post_total_removed_contacts),
installationId,
getNumRemovedPeople());
AnalyticsUtils.sendEventWithCategoryAndValue(mTracker,
getString(R.string.ga_prayer_progress),
getString(R.string.ga_post_total_prayed_for),
installationId,
getNumPrayed());
}
}
private String getString(int resId) {
return this.activity.getString(resId);
}
}