diff --git a/Android.bp b/Android.bp index ae3116dd..258391b7 100644 --- a/Android.bp +++ b/Android.bp @@ -42,6 +42,7 @@ android_app { certificate: "platform", privileged: true, required: [ + "LocalContactsBackup", "privapp_whitelist_com.stevesoltys.backup", "com.stevesoltys.backup_whitelist" ], diff --git a/contactsbackup/.gitignore b/contactsbackup/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/contactsbackup/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/contactsbackup/Android.bp b/contactsbackup/Android.bp new file mode 100644 index 00000000..9a4e2e60 --- /dev/null +++ b/contactsbackup/Android.bp @@ -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, +} diff --git a/contactsbackup/README.md b/contactsbackup/README.md new file mode 100644 index 00000000..28246f0c --- /dev/null +++ b/contactsbackup/README.md @@ -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. diff --git a/contactsbackup/build.gradle b/contactsbackup/build.gradle new file mode 100644 index 00000000..eb622f90 --- /dev/null +++ b/contactsbackup/build.gradle @@ -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" +} diff --git a/contactsbackup/default-permissions_org.calyxos.backup.contacts.xml b/contactsbackup/default-permissions_org.calyxos.backup.contacts.xml new file mode 100644 index 00000000..16e8ab90 --- /dev/null +++ b/contactsbackup/default-permissions_org.calyxos.backup.contacts.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/contactsbackup/libs/com.android.vcard.jar b/contactsbackup/libs/com.android.vcard.jar new file mode 100644 index 00000000..9cd01893 Binary files /dev/null and b/contactsbackup/libs/com.android.vcard.jar differ diff --git a/contactsbackup/src/androidTest/AndroidManifest.xml b/contactsbackup/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..1c51a97e --- /dev/null +++ b/contactsbackup/src/androidTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/contactsbackup/src/androidTest/java/org/calyxos/backup/contacts/BackupRestoreTest.kt b/contactsbackup/src/androidTest/java/org/calyxos/backup/contacts/BackupRestoreTest.kt new file mode 100644 index 00000000..6d2d6e54 --- /dev/null +++ b/contactsbackup/src/androidTest/java/org/calyxos/backup/contacts/BackupRestoreTest.kt @@ -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()) + } + +} diff --git a/contactsbackup/src/androidTest/java/org/calyxos/backup/contacts/ContactUtils.kt b/contactsbackup/src/androidTest/java/org/calyxos/backup/contacts/ContactUtils.kt new file mode 100644 index 00000000..0eb79785 --- /dev/null +++ b/contactsbackup/src/androidTest/java/org/calyxos/backup/contacts/ContactUtils.kt @@ -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().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 { + val lookupKeys = ArrayList() + 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() + 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() + 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 + } + +} diff --git a/contactsbackup/src/main/AndroidManifest.xml b/contactsbackup/src/main/AndroidManifest.xml new file mode 100644 index 00000000..43fa2699 --- /dev/null +++ b/contactsbackup/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/contactsbackup/src/main/java/org/calyxos/backup/contacts/ContactsBackupAgent.java b/contactsbackup/src/main/java/org/calyxos/backup/contacts/ContactsBackupAgent.java new file mode 100644 index 00000000..7ebf0a9d --- /dev/null +++ b/contactsbackup/src/main/java/org/calyxos/backup/contacts/ContactsBackupAgent.java @@ -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 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'