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");
+ }
+
+}
diff --git a/contactsbackup/src/main/java/org/calyxos/backup/contacts/FullBackupFileHandler.java b/contactsbackup/src/main/java/org/calyxos/backup/contacts/FullBackupFileHandler.java
new file mode 100644
index 00000000..033a1ba7
--- /dev/null
+++ b/contactsbackup/src/main/java/org/calyxos/backup/contacts/FullBackupFileHandler.java
@@ -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);
+
+}
diff --git a/contactsbackup/src/main/java/org/calyxos/backup/contacts/StartBroadcastReceiver.java b/contactsbackup/src/main/java/org/calyxos/backup/contacts/StartBroadcastReceiver.java
new file mode 100644
index 00000000..a398718b
--- /dev/null
+++ b/contactsbackup/src/main/java/org/calyxos/backup/contacts/StartBroadcastReceiver.java
@@ -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.
+ *
+ * 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);
+ }
+ }
+
+}
diff --git a/contactsbackup/src/main/java/org/calyxos/backup/contacts/VCardExporter.java b/contactsbackup/src/main/java/org/calyxos/backup/contacts/VCardExporter.java
new file mode 100644
index 00000000..c83de68e
--- /dev/null
+++ b/contactsbackup/src/main/java/org/calyxos/backup/contacts/VCardExporter.java
@@ -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 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 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 lookupKeys = new HashSet<>();
+ try {
+ while (cursor.moveToNext()) {
+ lookupKeys.add(cursor.getString(0));
+ }
+ } finally {
+ cursor.close();
+ }
+ return lookupKeys;
+ }
+
+}
diff --git a/contactsbackup/src/main/java/org/calyxos/backup/contacts/VCardImporter.java b/contactsbackup/src/main/java/org/calyxos/backup/contacts/VCardImporter.java
new file mode 100644
index 00000000..01e69790
--- /dev/null
+++ b/contactsbackup/src/main/java/org/calyxos/backup/contacts/VCardImporter.java
@@ -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");
+ }
+
+}
diff --git a/contactsbackup/src/main/res/values/strings.xml b/contactsbackup/src/main/res/values/strings.xml
new file mode 100644
index 00000000..db4fbc24
--- /dev/null
+++ b/contactsbackup/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Local Contacts Backup
+
diff --git a/contactsbackup/src/test/java/org/calyxos/backup/contacts/ContactsBackupAgentTest.kt b/contactsbackup/src/test/java/org/calyxos/backup/contacts/ContactsBackupAgentTest.kt
new file mode 100644
index 00000000..f0bed13a
--- /dev/null
+++ b/contactsbackup/src/test/java/org/calyxos/backup/contacts/ContactsBackupAgentTest.kt
@@ -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().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().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())
+ }
+
+}
diff --git a/settings.gradle b/settings.gradle
index e7b4def4..717326dd 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1,2 @@
include ':app'
+include ':contactsbackup'