Add backup of local contacts as an extra APK

so we can use the existing system backup API and keep this code
(and the tests as well as permissions) nicely separate from seedvault itself
This commit is contained in:
Torsten Grote 2020-09-18 12:04:18 -03:00
parent 50f9dd6f13
commit 0272a094ec
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
19 changed files with 810 additions and 0 deletions

View file

@ -42,6 +42,7 @@ android_app {
certificate: "platform", certificate: "platform",
privileged: true, privileged: true,
required: [ required: [
"LocalContactsBackup",
"privapp_whitelist_com.stevesoltys.backup", "privapp_whitelist_com.stevesoltys.backup",
"com.stevesoltys.backup_whitelist" "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 ':app'
include ':contactsbackup'