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