Merge pull request #140 from grote/66-contacts-backup
Add backup of local contacts
This commit is contained in:
commit
ef443f70a5
19 changed files with 810 additions and 0 deletions
|
@ -42,6 +42,7 @@ android_app {
|
|||
certificate: "platform",
|
||||
privileged: true,
|
||||
required: [
|
||||
"LocalContactsBackup",
|
||||
"privapp_whitelist_com.stevesoltys.backup",
|
||||
"com.stevesoltys.backup_whitelist"
|
||||
],
|
||||
|
|
1
contactsbackup/.gitignore
vendored
Normal file
1
contactsbackup/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
24
contactsbackup/Android.bp
Normal file
24
contactsbackup/Android.bp
Normal file
|
@ -0,0 +1,24 @@
|
|||
android_app {
|
||||
name: "LocalContactsBackup",
|
||||
srcs: [
|
||||
"src/main/java/**/*.java",
|
||||
],
|
||||
resource_dirs: [
|
||||
"src/main/res",
|
||||
],
|
||||
manifest: "src/main/AndroidManifest.xml",
|
||||
static_libs: [
|
||||
"com.android.vcard",
|
||||
],
|
||||
required: [
|
||||
"default-permissions_org.calyxos.backup.contacts",
|
||||
],
|
||||
sdk_version: "current",
|
||||
}
|
||||
|
||||
prebuilt_etc {
|
||||
name: "default-permissions_org.calyxos.backup.contacts",
|
||||
sub_dir: "default-permissions",
|
||||
src: "default-permissions_org.calyxos.backup.contacts.xml",
|
||||
filename_from_src: true,
|
||||
}
|
10
contactsbackup/README.md
Normal file
10
contactsbackup/README.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
# Local Contacts Backup
|
||||
|
||||
A backup application that backs up local on-device contacts via the system's backup API.
|
||||
This explicitly excludes contacts that are synced via sync accounts
|
||||
such as [DAVx⁵](https://www.davx5.com/).
|
||||
|
||||
## Permissions
|
||||
|
||||
* `android.permission.READ_CONTACTS` to back up local contacts.
|
||||
* `android.permission.WRITE_CONTACTS` to restore local contacts to the device.
|
67
contactsbackup/build.gradle
Normal file
67
contactsbackup/build.gradle
Normal file
|
@ -0,0 +1,67 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
buildToolsVersion "30.0.2"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.calyxos.backup.contacts"
|
||||
minSdkVersion 30
|
||||
targetSdkVersion 30
|
||||
versionCode 30
|
||||
versionName "0.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = 1.8
|
||||
targetCompatibility = 1.8
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
|
||||
// optional signingConfigs
|
||||
// On userdebug builds, you can use the testkey here to update the system app
|
||||
def keystorePropertiesFile = project.file("keystore.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
def keystoreProperties = new Properties()
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
buildTypes.release.signingConfig = signingConfigs.release
|
||||
buildTypes.debug.signingConfig = signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
def aospDeps = fileTree(include: [
|
||||
// out/target/common/obj/JAVA_LIBRARIES/com.android.vcard_intermediates/classes.jar
|
||||
'com.android.vcard.jar'
|
||||
], dir: 'libs')
|
||||
|
||||
dependencies {
|
||||
implementation aospDeps
|
||||
|
||||
//noinspection GradleDependency
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
testImplementation 'junit:junit:4.13'
|
||||
def mockk_version = "1.10.0"
|
||||
testImplementation "io.mockk:mockk:$mockk_version"
|
||||
|
||||
//noinspection GradleDependency
|
||||
androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
|
||||
def espresso_version = "3.3.0"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
|
||||
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<exceptions>
|
||||
<exception package="org.calyxos.backup.contacts">
|
||||
<permission name="android.permission.READ_CONTACTS" fixed="true" />
|
||||
<permission name="android.permission.WRITE_CONTACTS" fixed="true" />
|
||||
</exception>
|
||||
</exceptions>
|
BIN
contactsbackup/libs/com.android.vcard.jar
Normal file
BIN
contactsbackup/libs/com.android.vcard.jar
Normal file
Binary file not shown.
7
contactsbackup/src/androidTest/AndroidManifest.xml
Normal file
7
contactsbackup/src/androidTest/AndroidManifest.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.calyxos.backup.contacts.test">
|
||||
|
||||
<application android:extractNativeLibs="true" />
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,83 @@
|
|||
package org.calyxos.backup.contacts
|
||||
|
||||
import android.app.backup.BackupAgent
|
||||
import android.app.backup.BackupAgent.TYPE_FILE
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.ParcelFileDescriptor.MODE_READ_ONLY
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.calyxos.backup.contacts.ContactUtils.Contact
|
||||
import org.calyxos.backup.contacts.ContactsBackupAgent.BACKUP_FILE
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BackupRestoreTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val resolver = context.contentResolver
|
||||
private val utils = ContactUtils(resolver)
|
||||
|
||||
private val fileHandler = object : FullBackupFileHandler {
|
||||
var bytes: ByteArray? = null
|
||||
override fun fullBackupFile(file: File, output: FullBackupDataOutput?) {
|
||||
bytes = file.readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
// we are calling agent ourselves, because using bmgr will kill our process making test fail
|
||||
private val agent = ContactsBackupAgent(context, fileHandler)
|
||||
|
||||
@Test
|
||||
fun testBackupAndRestore() {
|
||||
assertEquals(
|
||||
"Test will remove *all* contacts and thus requires empty address book",
|
||||
0,
|
||||
utils.getNumberOfContacts()
|
||||
)
|
||||
val contacts = listOf(
|
||||
Contact("Test Contact 1", "+49123456789", "test@example.com"),
|
||||
Contact("Test Contact 2", "+559876543210", "test@example.org")
|
||||
)
|
||||
for (c in contacts) utils.addContact(c)
|
||||
assertEquals(2, utils.getNumberOfContacts())
|
||||
assertNull(fileHandler.bytes)
|
||||
|
||||
val data: FullBackupDataOutput = mockk()
|
||||
every { data.transportFlags } returns BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
|
||||
|
||||
// do actual backup by calling agent directly
|
||||
agent.onFullBackup(data)
|
||||
assertTrue(fileHandler.bytes!!.isNotEmpty())
|
||||
|
||||
// preparing file for restore
|
||||
val tmp = File(context.cacheDir, "tmp")
|
||||
tmp.writeBytes(fileHandler.bytes!!)
|
||||
val fd = ParcelFileDescriptor.open(tmp, MODE_READ_ONLY)
|
||||
val dest = File(context.filesDir, BACKUP_FILE)
|
||||
|
||||
// now delete all contacts, so we can restore them
|
||||
utils.deleteAllContacts()
|
||||
assertEquals(0, utils.getNumberOfContacts())
|
||||
|
||||
// do restore by calling agent directly
|
||||
val mode = 384L // 0600 in octal
|
||||
agent.onRestoreFile(fd, tmp.length(), dest, TYPE_FILE, mode, 0)
|
||||
|
||||
// check that restored contacts match what we backed up
|
||||
assertEquals(2, utils.getNumberOfContacts())
|
||||
assertEquals(contacts.sortedBy { it.name }, utils.getContacts().sortedBy { it.name })
|
||||
|
||||
// delete everything again
|
||||
utils.deleteAllContacts()
|
||||
assertEquals(0, utils.getNumberOfContacts())
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package org.calyxos.backup.contacts
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Email
|
||||
import android.provider.ContactsContract.CommonDataKinds.Phone
|
||||
import android.provider.ContactsContract.CommonDataKinds.StructuredName
|
||||
import android.provider.ContactsContract.Contacts
|
||||
import android.provider.ContactsContract.Data
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
|
||||
class ContactUtils(private val resolver: ContentResolver) {
|
||||
|
||||
data class Contact(
|
||||
val name: String?,
|
||||
val phone: String?,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
fun addContact(contact: Contact) {
|
||||
val ops = ArrayList<ContentProviderOperation>().apply {
|
||||
add(
|
||||
ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
|
||||
.withValue(RawContacts.ACCOUNT_TYPE, null)
|
||||
.withValue(RawContacts.ACCOUNT_NAME, null)
|
||||
.build()
|
||||
)
|
||||
add(
|
||||
ContentProviderOperation.newInsert(Data.CONTENT_URI)
|
||||
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
|
||||
.withValue(StructuredName.DISPLAY_NAME, contact.name)
|
||||
.build()
|
||||
)
|
||||
add(
|
||||
ContentProviderOperation.newInsert(Data.CONTENT_URI)
|
||||
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(Phone.NUMBER, contact.phone)
|
||||
.withValue(Phone.TYPE, Phone.TYPE_HOME)
|
||||
.build()
|
||||
)
|
||||
add(
|
||||
ContentProviderOperation.newInsert(Data.CONTENT_URI)
|
||||
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE)
|
||||
.withValue(Email.ADDRESS, contact.email)
|
||||
.withValue(Email.TYPE, Email.TYPE_WORK)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
resolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||
}
|
||||
|
||||
fun getContacts(): List<Contact> {
|
||||
val lookupKeys = ArrayList<String>()
|
||||
resolver.query(Contacts.CONTENT_URI, arrayOf(Contacts.LOOKUP_KEY), null, null, null)
|
||||
.use { cursor ->
|
||||
while (cursor!!.moveToNext()) {
|
||||
lookupKeys.add(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
val contacts = ArrayList<Contact>()
|
||||
for (key in lookupKeys) {
|
||||
val name = getDetail(key, StructuredName.DISPLAY_NAME, StructuredName.CONTENT_ITEM_TYPE)
|
||||
val phone = getDetail(key, Phone.NUMBER, Phone.CONTENT_ITEM_TYPE)
|
||||
val email = getDetail(key, Email.ADDRESS, Email.CONTENT_ITEM_TYPE)
|
||||
contacts.add(Contact(name, phone, email))
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
private fun getDetail(lookupKey: String, detail: String, mimeType: String): String? {
|
||||
val projection = arrayOf(detail)
|
||||
val selection = "${Contacts.LOOKUP_KEY}=? AND ${Data.MIMETYPE}=?"
|
||||
val args = arrayOf(lookupKey, mimeType)
|
||||
resolver.query(Data.CONTENT_URI, projection, selection, args, null)
|
||||
?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun deleteAllContacts() {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
resolver.query(
|
||||
Contacts.CONTENT_URI,
|
||||
arrayOf(Contacts.LOOKUP_KEY),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use { cursor ->
|
||||
while (cursor!!.moveToNext()) {
|
||||
val uri: Uri =
|
||||
Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, cursor.getString(0))
|
||||
ops.add(ContentProviderOperation.newDelete(uri).build())
|
||||
}
|
||||
}
|
||||
resolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||
}
|
||||
|
||||
fun getNumberOfContacts(): Int {
|
||||
return resolver.query(
|
||||
Contacts.CONTENT_URI,
|
||||
arrayOf(Contacts.LOOKUP_KEY),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)!!.count
|
||||
}
|
||||
|
||||
}
|
33
contactsbackup/src/main/AndroidManifest.xml
Normal file
33
contactsbackup/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.calyxos.backup.contacts">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:backupAgent="ContactsBackupAgent"
|
||||
android:forceQueryable="true"
|
||||
android:fullBackupOnly="true"
|
||||
android:label="@string/app_label"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="false"
|
||||
tools:ignore="AllowBackup,MissingApplicationIcon">
|
||||
|
||||
<!-- We are using the receiver to not get FLAG_STOPPED with which we won't get backed up -->
|
||||
<receiver
|
||||
android:name=".StartBroadcastReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,156 @@
|
|||
package org.calyxos.backup.contacts;
|
||||
|
||||
import android.app.backup.BackupAgent;
|
||||
import android.app.backup.BackupDataInput;
|
||||
import android.app.backup.BackupDataOutput;
|
||||
import android.app.backup.FullBackupDataOutput;
|
||||
import android.content.Context;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import static android.Manifest.permission.READ_CONTACTS;
|
||||
import static android.Manifest.permission.WRITE_CONTACTS;
|
||||
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
public class ContactsBackupAgent extends BackupAgent implements FullBackupFileHandler {
|
||||
|
||||
private final static String TAG = "ContactsBackupAgent";
|
||||
final static String BACKUP_FILE = "backup-v1.tar";
|
||||
final static boolean DEBUG = false; // don't commit with true
|
||||
|
||||
private final Context testContext;
|
||||
private final FullBackupFileHandler fileHandler;
|
||||
|
||||
public ContactsBackupAgent() {
|
||||
super();
|
||||
testContext = null;
|
||||
fileHandler = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for testing
|
||||
*/
|
||||
ContactsBackupAgent(Context context, FullBackupFileHandler fileHandler) {
|
||||
super();
|
||||
testContext = context;
|
||||
attachBaseContext(context);
|
||||
this.fileHandler = fileHandler;
|
||||
}
|
||||
|
||||
private Context getContext() {
|
||||
if (testContext != null) return testContext;
|
||||
else return getBaseContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFullBackup(FullBackupDataOutput data) throws IOException {
|
||||
if (shouldAvoidBackup(data)) {
|
||||
Log.w(TAG, "onFullBackup - will not back up due to flags: " + data.getTransportFlags());
|
||||
return;
|
||||
}
|
||||
|
||||
if (getContext().checkSelfPermission(READ_CONTACTS) != PERMISSION_GRANTED) {
|
||||
throw new IOException("Permission READ_CONTACTS not granted.");
|
||||
}
|
||||
|
||||
// get VCARDs as an InputStream
|
||||
VCardExporter vCardExporter = new VCardExporter(getContentResolver());
|
||||
Optional<InputStream> optionalInputStream = vCardExporter.getVCardInputStream();
|
||||
if (!optionalInputStream.isPresent()) {
|
||||
Log.i(TAG, "onFullBackup - found no contacts. Not backing up.");
|
||||
return;
|
||||
}
|
||||
InputStream vCardInputStream = optionalInputStream.orElseThrow(AssertionError::new);
|
||||
|
||||
Log.d(TAG, "onFullBackup - will do backup");
|
||||
|
||||
// create backup file as an OutputStream
|
||||
File backupFile = new File(getContext().getFilesDir(), BACKUP_FILE);
|
||||
FileOutputStream backupFileOutputStream = new FileOutputStream(backupFile);
|
||||
|
||||
// write VCARDs into backup file
|
||||
try {
|
||||
copyStreams(vCardInputStream, backupFileOutputStream);
|
||||
} finally {
|
||||
backupFileOutputStream.close();
|
||||
vCardInputStream.close();
|
||||
}
|
||||
|
||||
// backup file
|
||||
fileHandler.fullBackupFile(backupFile, data);
|
||||
|
||||
// delete file when done
|
||||
if (!backupFile.delete()) {
|
||||
Log.w(TAG, "Could not delete: " + backupFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldAvoidBackup(FullBackupDataOutput data) {
|
||||
boolean isEncrypted = (data.getTransportFlags() & FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) != 0;
|
||||
boolean isDeviceTransfer = (data.getTransportFlags() & FLAG_DEVICE_TO_DEVICE_TRANSFER) != 0;
|
||||
return !isEncrypted && !isDeviceTransfer;
|
||||
}
|
||||
|
||||
private void copyStreams(InputStream inputStream, OutputStream outputStream) throws IOException {
|
||||
byte[] buf = new byte[8192];
|
||||
int length;
|
||||
while ((length = inputStream.read(buf)) > 0) {
|
||||
outputStream.write(buf, 0, length);
|
||||
if (DEBUG) {
|
||||
Log.e(TAG, new String(buf, 0, length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestoreFile(ParcelFileDescriptor data, long size, File destination, int type, long mode, long mtime) throws IOException {
|
||||
Log.d(TAG, "onRestoreFile " + mode);
|
||||
super.onRestoreFile(data, size, destination, type, mode, mtime);
|
||||
|
||||
if (getContext().checkSelfPermission(WRITE_CONTACTS) != PERMISSION_GRANTED) {
|
||||
throw new IOException("Permission WRITE_CONTACTS not granted.");
|
||||
}
|
||||
|
||||
File backupFile = new File(getContext().getFilesDir(), BACKUP_FILE);
|
||||
|
||||
try (FileInputStream backupFileInputStream = new FileInputStream(backupFile)) {
|
||||
VCardImporter vCardImporter = new VCardImporter(getContentResolver());
|
||||
vCardImporter.importFromStream(backupFileInputStream);
|
||||
}
|
||||
|
||||
// delete file when done
|
||||
if (!backupFile.delete()) {
|
||||
Log.w(TAG, "Could not delete: " + backupFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onQuotaExceeded(long backupDataBytes, long quotaBytes) {
|
||||
super.onQuotaExceeded(backupDataBytes, quotaBytes);
|
||||
// TODO show error notification?
|
||||
Log.e(TAG, "onQuotaExceeded " + backupDataBytes + " / " + quotaBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The methods below are for key/value backup/restore and should never get called
|
||||
**/
|
||||
|
||||
@Override
|
||||
public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) {
|
||||
Log.e(TAG, "onBackup noSuper");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) {
|
||||
Log.e(TAG, "onRestore noSuper");
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package org.calyxos.backup.contacts;
|
||||
|
||||
import android.app.backup.FullBackupDataOutput;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
interface FullBackupFileHandler {
|
||||
|
||||
void fullBackupFile(File file, FullBackupDataOutput output);
|
||||
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package org.calyxos.backup.contacts;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.util.Log;
|
||||
|
||||
import static android.content.Intent.ACTION_BOOT_COMPLETED;
|
||||
import static android.content.Intent.ACTION_MY_PACKAGE_REPLACED;
|
||||
|
||||
/**
|
||||
* This receiver doesn't seem to do anything,
|
||||
* but we need it to prevent us from getting {@link ApplicationInfo#FLAG_STOPPED}
|
||||
* which would make us ineligible for backups.
|
||||
* <p>
|
||||
* It might be required that the app is already installed as a system app for this to work.
|
||||
*/
|
||||
public class StartBroadcastReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context ctx, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
if (ACTION_BOOT_COMPLETED.equals(action) || ACTION_MY_PACKAGE_REPLACED.equals(action)) {
|
||||
Log.d("StartBroadcastReceiver", "Broadcast received: " + intent);
|
||||
} else {
|
||||
Log.w("StartBroadcastReceiver", "Unexpected broadcast received: " + intent);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package org.calyxos.backup.contacts;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static android.provider.ContactsContract.Contacts.CONTENT_MULTI_VCARD_URI;
|
||||
import static android.provider.ContactsContract.Data.CONTENT_URI;
|
||||
import static android.provider.ContactsContract.Data.LOOKUP_KEY;
|
||||
import static android.provider.ContactsContract.RawContacts.ACCOUNT_TYPE;
|
||||
import static org.calyxos.backup.contacts.ContactsBackupAgent.DEBUG;
|
||||
|
||||
class VCardExporter {
|
||||
|
||||
private final static String TAG = "VCardExporter";
|
||||
|
||||
private final ContentResolver contentResolver;
|
||||
|
||||
VCardExporter(ContentResolver contentResolver) {
|
||||
this.contentResolver = contentResolver;
|
||||
}
|
||||
|
||||
Optional<InputStream> getVCardInputStream() throws FileNotFoundException {
|
||||
String lookupKeysStr = String.join(":", getLookupKeys());
|
||||
if (DEBUG) {
|
||||
Log.e(TAG, "lookupKeysStr: " + lookupKeysStr);
|
||||
}
|
||||
if (lookupKeysStr.isEmpty()) {
|
||||
return Optional.empty();
|
||||
} else {
|
||||
Uri uri = Uri.withAppendedPath(CONTENT_MULTI_VCARD_URI, Uri.encode(lookupKeysStr));
|
||||
return Optional.ofNullable(contentResolver.openInputStream(uri));
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<String> getLookupKeys() {
|
||||
String[] projection = new String[]{LOOKUP_KEY};
|
||||
// We can not add IS_PRIMARY here as this gets lost on restored contacts
|
||||
String selection = ACCOUNT_TYPE + " is null";
|
||||
Cursor cursor = contentResolver.query(CONTENT_URI, projection, selection, null, null);
|
||||
if (cursor == null) {
|
||||
Log.e(TAG, "Cursor for LOOKUP_KEY is null");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Set<String> lookupKeys = new HashSet<>();
|
||||
try {
|
||||
while (cursor.moveToNext()) {
|
||||
lookupKeys.add(cursor.getString(0));
|
||||
}
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
return lookupKeys;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package org.calyxos.backup.contacts;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.util.Log;
|
||||
|
||||
import com.android.vcard.VCardEntry;
|
||||
import com.android.vcard.VCardEntryCommitter;
|
||||
import com.android.vcard.VCardEntryConstructor;
|
||||
import com.android.vcard.VCardEntryHandler;
|
||||
import com.android.vcard.VCardParser;
|
||||
import com.android.vcard.VCardParser_V21;
|
||||
import com.android.vcard.exception.VCardException;
|
||||
import com.android.vcard.exception.VCardVersionException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static com.android.vcard.VCardConfig.VCARD_TYPE_V21_GENERIC;
|
||||
import static org.calyxos.backup.contacts.ContactsBackupAgent.DEBUG;
|
||||
|
||||
class VCardImporter implements VCardEntryHandler {
|
||||
|
||||
private final static String TAG = "VCardImporter";
|
||||
private final static int TYPE = VCARD_TYPE_V21_GENERIC;
|
||||
|
||||
private final ContentResolver contentResolver;
|
||||
|
||||
VCardImporter(ContentResolver contentResolver) {
|
||||
this.contentResolver = contentResolver;
|
||||
}
|
||||
|
||||
void importFromStream(InputStream is) throws IOException {
|
||||
final VCardEntryConstructor constructor = new VCardEntryConstructor(TYPE, null);
|
||||
final VCardEntryCommitter committer = new VCardEntryCommitter(contentResolver);
|
||||
constructor.addEntryHandler(committer);
|
||||
if (DEBUG) {
|
||||
constructor.addEntryHandler(this);
|
||||
}
|
||||
try {
|
||||
constructor.clear();
|
||||
VCardParser mVCardParser = new VCardParser_V21(TYPE);
|
||||
mVCardParser.addInterpreter(constructor);
|
||||
mVCardParser.parse(is);
|
||||
} catch (VCardVersionException e) {
|
||||
Log.e(TAG, "Appropriate version for this vCard is not found.", e);
|
||||
throw new IOException(e);
|
||||
} catch (VCardException e) {
|
||||
Log.e(TAG, "Error parsing vCard.", e);
|
||||
throw new IOException(e);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, e.toString());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
Log.e(TAG, "onStart");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEntryCreated(VCardEntry vCardEntry) {
|
||||
Log.e(TAG, "onEntryCreated " + vCardEntry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnd() {
|
||||
Log.e(TAG, "onEnd");
|
||||
}
|
||||
|
||||
}
|
4
contactsbackup/src/main/res/values/strings.xml
Normal file
4
contactsbackup/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_label">Local Contacts Backup</string>
|
||||
</resources>
|
|
@ -0,0 +1,122 @@
|
|||
package org.calyxos.backup.contacts
|
||||
|
||||
import android.Manifest.permission.READ_CONTACTS
|
||||
import android.Manifest.permission.WRITE_CONTACTS
|
||||
import android.app.backup.BackupAgent
|
||||
import android.app.backup.BackupAgent.TYPE_FILE
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager.PERMISSION_DENIED
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import org.calyxos.backup.contacts.ContactsBackupAgent.BACKUP_FILE
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Limited unit tests as the code itself is small and not very testable.
|
||||
*/
|
||||
class ContactsBackupAgentTest {
|
||||
|
||||
@get:Rule
|
||||
var folder = TemporaryFolder()
|
||||
|
||||
private val context: Context = mockk()
|
||||
private val fileHandler: FullBackupFileHandler = mockk()
|
||||
private val agent = ContactsBackupAgent(context, fileHandler)
|
||||
|
||||
private val data: FullBackupDataOutput = mockk()
|
||||
|
||||
@Test
|
||||
fun `backup is skipped when not encrypted or device-to-device`() {
|
||||
every { data.transportFlags } returns 0
|
||||
agent.onFullBackup(data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing read contacts permission throws`() {
|
||||
expectEncryptedOrDevice2DeviceTransport()
|
||||
every { context.checkSelfPermission(READ_CONTACTS) } returns PERMISSION_DENIED
|
||||
|
||||
try {
|
||||
agent.onFullBackup(data)
|
||||
fail("IOException was not thrown")
|
||||
} catch (e: IOException) {
|
||||
assertTrue(e.message!!.contains("READ_CONTACTS"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no contacts does not throw or attempt backup`() {
|
||||
expectEncryptedOrDevice2DeviceTransport()
|
||||
every { context.checkSelfPermission(READ_CONTACTS) } returns PERMISSION_GRANTED
|
||||
mockkConstructor(VCardExporter::class)
|
||||
every { anyConstructed<VCardExporter>().vCardInputStream } returns Optional.empty()
|
||||
|
||||
agent.onFullBackup(data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup works`() {
|
||||
val backupBytes = Random.nextBytes(42)
|
||||
val inputStream = ByteArrayInputStream(backupBytes)
|
||||
val filesDir = folder.newFolder()
|
||||
|
||||
expectEncryptedOrDevice2DeviceTransport()
|
||||
every { context.checkSelfPermission(READ_CONTACTS) } returns PERMISSION_GRANTED
|
||||
every { context.filesDir } returns filesDir
|
||||
mockkConstructor(VCardExporter::class)
|
||||
every { anyConstructed<VCardExporter>().vCardInputStream } returns Optional.of(inputStream)
|
||||
every { fileHandler.fullBackupFile(any(), data) } just Runs
|
||||
|
||||
agent.onFullBackup(data)
|
||||
}
|
||||
|
||||
private fun expectEncryptedOrDevice2DeviceTransport() {
|
||||
every { data.transportFlags } returns listOf(
|
||||
BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED,
|
||||
BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER
|
||||
).random() // both flags should allow the backup
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing write contacts permission throws`() {
|
||||
every { context.checkSelfPermission(WRITE_CONTACTS) } returns PERMISSION_DENIED
|
||||
|
||||
try {
|
||||
agent.onRestoreFile(null, 0, null, 0, 0, 0)
|
||||
fail("IOException was not thrown")
|
||||
} catch (e: IOException) {
|
||||
assertTrue(e.message!!.contains("WRITE_CONTACTS"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore works`() {
|
||||
val filesDir = folder.newFolder()
|
||||
val file = File(filesDir, BACKUP_FILE)
|
||||
val restoreBytes = Random.nextBytes(42)
|
||||
file.writeBytes(restoreBytes)
|
||||
|
||||
every { context.checkSelfPermission(WRITE_CONTACTS) } returns PERMISSION_GRANTED
|
||||
every { context.filesDir } returns filesDir
|
||||
|
||||
agent.onRestoreFile(null, file.length(), null, TYPE_FILE, 0, 0)
|
||||
|
||||
assertFalse(file.exists())
|
||||
}
|
||||
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
include ':app'
|
||||
include ':contactsbackup'
|
||||
|
|
Loading…
Add table
Reference in a new issue