Merge pull request #140 from grote/66-contacts-backup

Add backup of local contacts
This commit is contained in:
Torsten Grote 2020-10-22 09:55:33 -03:00 committed by GitHub
commit ef443f70a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 810 additions and 0 deletions

View file

@ -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
View file

@ -0,0 +1 @@
/build

24
contactsbackup/Android.bp Normal file
View 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
View 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.

View 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"
}

View file

@ -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>

Binary file not shown.

View 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>

View file

@ -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())
}
}

View file

@ -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
}
}

View 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>

View file

@ -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");
}
}

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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");
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_label">Local Contacts Backup</string>
</resources>

View file

@ -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())
}
}

View file

@ -1 +1,2 @@
include ':app'
include ':contactsbackup'