Support for populating contacts DB with TS account type.

// FREEBIE
This commit is contained in:
Moxie Marlinspike 2015-07-14 14:31:03 -07:00
parent 8d9ae731ef
commit d1940fe0f9
11 changed files with 380 additions and 55 deletions

View File

@ -148,6 +148,8 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity android:name=".ConversationActivity"
@ -278,6 +280,11 @@
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact" />
</intent-filter>
</activity>
<activity android:name=".RecipientPreferenceActivity"
@ -308,6 +315,20 @@
</intent-filter>
</service>
<service android:name=".service.AccountAuthenticatorService" android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator" />
</service>
<service android:name=".service.ContactsSyncAdapterService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/syncadapter" />
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
</service>
<receiver android:name=".gcm.GcmBroadcastReceiver" android:permission="com.google.android.c2dm.permission.SEND" >
<intent-filter>

View File

@ -0,0 +1,6 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="org.thoughtcrime.securesms"
android:icon="@drawable/icon"
android:smallIcon="@drawable/icon"
android:label="@string/app_name"/>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ContactsSource xmlns:android="http://schemas.android.com/apk/res/android">
<ContactsDataKind
android:icon="@drawable/icon"
android:mimeType="vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
android:summaryColumn="data2"
android:detailColumn="data3"
android:detailSocialSummary="true"/>
</ContactsSource>

8
res/xml/syncadapter.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="com.android.contacts"
android:accountType="org.thoughtcrime.securesms"
android:userVisible="true"
android:supportsUploading="false"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="true"/>

View File

@ -2,7 +2,10 @@ package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.util.Log;
import android.widget.Toast;
@ -25,36 +28,79 @@ public class SmsSendtoActivity extends Activity {
}
private Intent getNextIntent(Intent original) {
String body = "";
String data = "";
DestinationAndBody destination;
if (original.getAction().equals(Intent.ACTION_SENDTO)) {
body = original.getStringExtra("sms_body");
data = original.getData().getSchemeSpecificPart();
destination = getDestinationForSendTo(original);
} else if (original.getData() != null && "content".equals(original.getData().getScheme())) {
destination = getDestinationForSyncAdapter(original);
} else {
try {
Rfc5724Uri smsUri = new Rfc5724Uri(original.getData().toString());
body = smsUri.getQueryParams().get("body");
data = smsUri.getPath();
} catch (URISyntaxException e) {
Log.w(TAG, "unable to parse RFC5724 URI from intent", e);
}
destination = getDestinationForView(original);
}
Recipients recipients = RecipientFactory.getRecipientsFromString(this, data, false);
Recipients recipients = RecipientFactory.getRecipientsFromString(this, destination.getDestination(), false);
long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipients);
final Intent nextIntent;
if (recipients == null || recipients.isEmpty()) {
nextIntent = new Intent(this, NewConversationActivity.class);
nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, body);
nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
} else {
nextIntent = new Intent(this, ConversationActivity.class);
nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, body);
nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, destination.getBody());
nextIntent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
nextIntent.putExtra(ConversationActivity.RECIPIENTS_EXTRA, recipients.getIds());
}
return nextIntent;
}
private @NonNull DestinationAndBody getDestinationForSendTo(Intent intent) {
return new DestinationAndBody(intent.getData().getSchemeSpecificPart(),
intent.getStringExtra("sms_body"));
}
private @NonNull DestinationAndBody getDestinationForView(Intent intent) {
try {
Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString());
return new DestinationAndBody(smsUri.getPath(), smsUri.getQueryParams().get("body"));
} catch (URISyntaxException e) {
Log.w(TAG, "unable to parse RFC5724 URI from intent", e);
return new DestinationAndBody("", "");
}
}
private @NonNull DestinationAndBody getDestinationForSyncAdapter(Intent intent) {
Cursor cursor = null;
try {
cursor = getContentResolver().query(intent.getData(), null, null, null, null);
if (cursor != null && cursor.moveToNext()) {
return new DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "");
}
return new DestinationAndBody("", "");
} finally {
if (cursor != null) cursor.close();
}
}
private static class DestinationAndBody {
private final String destination;
private final String body;
private DestinationAndBody(String destination, String body) {
this.destination = destination;
this.body = body;
}
public String getDestination() {
return destination;
}
public String getBody() {
return body;
}
}
}

View File

@ -16,8 +16,11 @@
*/
package org.thoughtcrime.securesms.contacts;
import android.accounts.Account;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.database.CursorWrapper;
import android.database.MatrixCursor;
@ -25,7 +28,10 @@ import android.database.MergeCursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.os.RemoteException;
import android.provider.BaseColumns;
import android.provider.ContactsContract;
import android.provider.ContactsContract.RawContacts;
import android.text.TextUtils;
import android.util.Log;
@ -36,7 +42,12 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Database to supply all types of contacts that TextSecure needs to know about
@ -84,6 +95,88 @@ public class ContactsDatabase {
dbHelper.close();
}
public synchronized void setRegisteredUsers(Account account, List<String> e164numbers)
throws RemoteException, OperationApplicationException
{
Map<String, Long> currentContacts = new HashMap<>();
Set<String> registeredNumbers = new HashSet<>(e164numbers);
ArrayList<ContentProviderOperation> operations = new ArrayList<>();
Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type).build();
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(currentContactsUri, new String[] {BaseColumns._ID, RawContacts.SYNC1}, null, null, null);
while (cursor != null && cursor.moveToNext()) {
currentContacts.put(cursor.getString(1), cursor.getLong(0));
}
} finally {
if (cursor != null)
cursor.close();
}
for (String number : e164numbers) {
if (!currentContacts.containsKey(number)) {
addTextSecureRawContact(operations, account, number);
}
}
for (Map.Entry<String, Long> currentContactEntry : currentContacts.entrySet()) {
if (!registeredNumbers.contains(currentContactEntry.getKey())) {
removeTextSecureRawContact(operations, account, currentContactEntry.getValue());
}
}
if (!operations.isEmpty()) {
context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations);
}
}
private void addTextSecureRawContact(List<ContentProviderOperation> operations,
Account account, String e164number)
{
int index = operations.size();
Uri dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build();
operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
.withValue(RawContacts.ACCOUNT_NAME, account.name)
.withValue(RawContacts.ACCOUNT_TYPE, account.type)
.withValue(RawContacts.SYNC1, e164number)
.build());
operations.add(ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number)
.build());
operations.add(ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
.withValue(ContactsContract.Data.MIMETYPE, "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact")
.withValue(ContactsContract.Data.DATA1, e164number)
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
.withValue(ContactsContract.Data.DATA3, String.format("Message %s", e164number))
.withYieldAllowed(true)
.build());
}
private void removeTextSecureRawContact(List<ContentProviderOperation> operations,
Account account, long rowId)
{
operations.add(ContentProviderOperation.newDelete(RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build())
.withYieldAllowed(true)
.withSelection(BaseColumns._ID + " = ?", new String[] {String.valueOf(rowId)})
.build());
}
public Cursor query(String filter, boolean pushOnly) {
// FIXME: This doesn't make sense to me. You pass in pushOnly, but then
// conditionally check to see whether other contacts should be included

View File

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.contacts;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import android.util.Log;
import org.thoughtcrime.securesms.util.DirectoryHelper;
import java.io.IOException;
public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
private static final String TAG = ContactsSyncAdapter.class.getSimpleName();
public ContactsSyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority,
ContentProviderClient provider, SyncResult syncResult)
{
try {
DirectoryHelper.refreshDirectory(getContext());
} catch (IOException e) {
Log.w(TAG, e);
}
}
}

View File

@ -24,20 +24,18 @@ public class TextSecureDirectory {
private static final int INTRODUCED_CHANGE_FROM_TOKEN_TO_E164_NUMBER = 2;
private static final String DATABASE_NAME = "whisper_directory.db";
private static final int DATABASE_VERSION = 2;
private static final int DATABASE_VERSION = 3;
private static final String TABLE_NAME = "directory";
private static final String ID = "_id";
private static final String NUMBER = "number";
private static final String REGISTERED = "registered";
private static final String RELAY = "relay";
private static final String SUPPORTS_SMS = "supports_sms";
private static final String TIMESTAMP = "timestamp";
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY, " +
NUMBER + " TEXT UNIQUE, " +
REGISTERED + " INTEGER, " +
RELAY + " TEXT, " +
SUPPORTS_SMS + " INTEGER, " +
TIMESTAMP + " INTEGER);";
private static final Object instanceLock = new Object();
@ -63,25 +61,6 @@ public class TextSecureDirectory {
this.databaseHelper = new DatabaseHelper(context, DATABASE_NAME, null, DATABASE_VERSION);
}
public boolean isSmsFallbackSupported(String e164number) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
cursor = db.query(TABLE_NAME, new String[] {SUPPORTS_SMS}, NUMBER + " = ?",
new String[]{e164number}, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0) == 1;
} else {
return false;
}
} finally {
if (cursor != null)
cursor.close();
}
}
public boolean isActiveNumber(String e164number) throws NotInDirectoryException {
if (e164number == null || e164number.length() == 0) {
return false;
@ -131,7 +110,6 @@ public class TextSecureDirectory {
values.put(NUMBER, token.getNumber());
values.put(RELAY, token.getRelay());
values.put(REGISTERED, active ? 1 : 0);
values.put(SUPPORTS_SMS, token.isSupportsSms() ? 1 : 0);
values.put(TIMESTAMP, System.currentTimeMillis());
db.replace(TABLE_NAME, null, values);
}
@ -149,7 +127,6 @@ public class TextSecureDirectory {
values.put(REGISTERED, 1);
values.put(TIMESTAMP, timestamp);
values.put(RELAY, token.getRelay());
values.put(SUPPORTS_SMS, token.isSupportsSms() ? 1 : 0);
db.replace(TABLE_NAME, null, values);
}
@ -169,7 +146,7 @@ public class TextSecureDirectory {
public Set<String> getPushEligibleContactNumbers(String localNumber) {
final Uri uri = Phone.CONTENT_URI;
final Set<String> results = new HashSet<String>();
final Set<String> results = new HashSet<>();
Cursor cursor = null;
try {
@ -208,7 +185,7 @@ public class TextSecureDirectory {
}
public List<String> getActiveNumbers() {
final List<String> results = new ArrayList<String>();
final List<String> results = new ArrayList<>();
Cursor cursor = null;
try {
cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{NUMBER},

View File

@ -0,0 +1,80 @@
package org.thoughtcrime.securesms.service;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.NetworkErrorException;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
public class AccountAuthenticatorService extends Service {
private static AccountAuthenticatorImpl accountAuthenticator = null;
@Override
public IBinder onBind(Intent intent) {
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT)) {
return getAuthenticator().getIBinder();
} else {
return null;
}
}
private synchronized AccountAuthenticatorImpl getAuthenticator() {
if (accountAuthenticator == null) {
accountAuthenticator = new AccountAuthenticatorImpl(this);
}
return accountAuthenticator;
}
private static class AccountAuthenticatorImpl extends AbstractAccountAuthenticator {
public AccountAuthenticatorImpl(Context context) {
super(context);
}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
String[] requiredFeatures, Bundle options)
throws NetworkErrorException
{
return null;
}
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) {
return null;
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
return null;
}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType,
Bundle options) throws NetworkErrorException {
return null;
}
@Override
public String getAuthTokenLabel(String authTokenType) {
return null;
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features)
throws NetworkErrorException {
return null;
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType,
Bundle options) {
return null;
}
}
}

View File

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.service;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.contacts.ContactsSyncAdapter;
public class ContactsSyncAdapterService extends Service {
private static ContactsSyncAdapter syncAdapter;
@Override
public synchronized void onCreate() {
if (syncAdapter == null) {
syncAdapter = new ContactsSyncAdapter(this, true);
}
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return syncAdapter.getSyncAdapterBinder();
}
}

View File

@ -1,19 +1,28 @@
package org.thoughtcrime.securesms.util;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.OperationApplicationException;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.util.Log;
import android.widget.Toast;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactsDatabase;
import org.thoughtcrime.securesms.database.NotInDirectoryException;
import org.thoughtcrime.securesms.database.TextSecureDirectory;
import org.thoughtcrime.securesms.push.TextSecureCommunicationFactory;
import org.thoughtcrime.securesms.recipients.Recipients;
import org.whispersystems.libaxolotl.util.guava.Optional;
import org.whispersystems.textsecure.api.TextSecureAccountManager;
import org.whispersystems.textsecure.api.push.ContactTokenDetails;
import org.whispersystems.textsecure.api.util.InvalidNumberException;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@ -65,6 +74,7 @@ public class DirectoryHelper {
throws IOException
{
TextSecureDirectory directory = TextSecureDirectory.getInstance(context);
Optional<Account> account = getOrCreateAccount(context);
Set<String> eligibleContactNumbers = directory.getPushEligibleContactNumbers(localNumber);
List<ContactTokenDetails> activeTokens = accountManager.getContacts(eligibleContactNumbers);
@ -75,6 +85,20 @@ public class DirectoryHelper {
}
directory.setNumbers(activeTokens, eligibleContactNumbers);
if (account.isPresent()) {
List<String> e164numbers = new LinkedList<>();
for (ContactTokenDetails contactTokenDetails : activeTokens) {
e164numbers.add(contactTokenDetails.getNumber());
}
try {
new ContactsDatabase(context).setRegisteredUsers(account.get(), e164numbers);
} catch (RemoteException | OperationApplicationException e) {
Log.w(TAG, e);
}
}
}
}
@ -113,24 +137,25 @@ public class DirectoryHelper {
}
}
public static boolean isSmsFallbackAllowed(Context context, Recipients recipients) {
try {
if (recipients == null || !recipients.isSingleRecipient() || recipients.isGroupRecipient()) {
return false;
private static Optional<Account> getOrCreateAccount(Context context) {
AccountManager accountManager = AccountManager.get(context);
Account[] accounts = accountManager.getAccountsByType("org.thoughtcrime.securesms");
if (accounts.length == 0) return createAccount(context);
else return Optional.of(accounts[0]);
}
final String number = recipients.getPrimaryRecipient().getNumber();
private static Optional<Account> createAccount(Context context) {
AccountManager accountManager = AccountManager.get(context);
Account account = new Account(context.getString(R.string.app_name), "org.thoughtcrime.securesms");
if (number == null) {
return false;
}
final String e164number = Util.canonicalizeNumber(context, number);
return TextSecureDirectory.getInstance(context).isSmsFallbackSupported(e164number);
} catch (InvalidNumberException e) {
Log.w(TAG, e);
return false;
if (accountManager.addAccountExplicitly(account, null, null)) {
Log.w(TAG, "Created new account...");
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1);
return Optional.of(account);
} else {
Log.w(TAG, "Failed to create account!");
return Optional.absent();
}
}