From 128a1201b8cd4a68fd42b7c2228c12a3be14cf98 Mon Sep 17 00:00:00 2001 From: Rhodey Orbits Date: Fri, 22 Aug 2014 01:17:54 -0700 Subject: [PATCH] fixed bug in KeyHelper's getMasterCipher method where cipher key bytes were used in both the cipher and mac key. added versioning to all ciphertext. created a set of tests for the crypto package. added logic to automatically migrate all ciphertext to new versioned ciphertext format. FREEBIE. --- .../flock/test/crypto/KeyHelperTest.java | 151 +++ .../flock/test/sync/MockMasterCipher.java | 6 +- flock/src/main/AndroidManifest.xml | 19 +- .../flock/AbstractMyCollectionsFragment.java | 5 +- .../flock/AccountAndKeyRequiredActivity.java | 11 +- .../flock/AccountAndKeyRequiredFragment.java | 12 +- .../ChangeEncryptionPasswordActivity.java | 3 +- .../flock/DavAccountHelper.java | 13 +- .../flock/DeleteAllContactsActivity.java | 21 +- .../flock/ImportAccountService.java | 14 + .../flock/ImportCalendarsFragment.java | 4 +- .../flock/ImportContactsFragment.java | 5 +- .../flock/ImportOwsAccountService.java | 2 + .../MigrationHelperBroadcastReceiver.java | 151 +++ .../flock/MigrationReleaseNotesActivity.java | 59 ++ .../flock/MigrationService.java | 890 ++++++++++++++++++ .../flock/StatusHeaderView.java | 113 ++- .../flock/UnregisterAccountActivity.java | 3 +- .../crypto/InvalidCipherVersionException.java | 14 + .../flock/crypto/KeyHelper.java | 25 +- .../anhonesteffort/flock/crypto/KeyStore.java | 23 +- .../flock/crypto/MasterCipher.java | 66 +- .../flock/registration/OwsRegistration.java | 1 + .../flock/registration/RegistrationApi.java | 19 +- .../flock/sync/AbstractDavSyncAdapter.java | 26 +- .../AbstractLocalComponentCollection.java | 21 +- .../flock/sync/AbstractSyncScheduler.java | 35 +- .../flock/sync/AndroidDavClient.java | 9 + .../addressbook/AddressbookSyncService.java | 9 +- .../addressbook/AddressbookSyncWorker.java | 11 + .../addressbook/LocalContactCollection.java | 5 + .../sync/calendar/CalendarsSyncService.java | 9 +- .../sync/calendar/LocalEventCollection.java | 10 +- .../flock/sync/key/DavKeyCollection.java | 101 ++ .../flock/sync/key/KeySyncService.java | 9 +- .../flock/sync/key/KeySyncWorker.java | 189 ++-- .../drawable-xhdpi/migration_in_progress.png | Bin 0 -> 974 bytes .../res/layout/migration_release_notes.xml | 77 ++ flock/src/main/res/values/strings.xml | 30 + 39 files changed, 2015 insertions(+), 156 deletions(-) create mode 100644 flock/src/androidTest/java/org/anhonesteffort/flock/test/crypto/KeyHelperTest.java create mode 100644 flock/src/main/java/org/anhonesteffort/flock/MigrationHelperBroadcastReceiver.java create mode 100644 flock/src/main/java/org/anhonesteffort/flock/MigrationReleaseNotesActivity.java create mode 100644 flock/src/main/java/org/anhonesteffort/flock/MigrationService.java create mode 100644 flock/src/main/java/org/anhonesteffort/flock/crypto/InvalidCipherVersionException.java create mode 100644 flock/src/main/res/drawable-xhdpi/migration_in_progress.png create mode 100644 flock/src/main/res/layout/migration_release_notes.xml diff --git a/flock/src/androidTest/java/org/anhonesteffort/flock/test/crypto/KeyHelperTest.java b/flock/src/androidTest/java/org/anhonesteffort/flock/test/crypto/KeyHelperTest.java new file mode 100644 index 0000000..3ec3341 --- /dev/null +++ b/flock/src/androidTest/java/org/anhonesteffort/flock/test/crypto/KeyHelperTest.java @@ -0,0 +1,151 @@ +package org.anhonesteffort.flock.test.crypto; + +import android.content.Context; +import android.content.SharedPreferences; +import android.test.AndroidTestCase; + +import com.google.common.base.Optional; + +import org.anhonesteffort.flock.crypto.KeyHelper; +import org.anhonesteffort.flock.crypto.KeyStore; +import org.anhonesteffort.flock.crypto.MasterCipher; +import org.anhonesteffort.flock.util.Base64; +import org.anhonesteffort.flock.util.Util; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * rhodey. + */ +public class KeyHelperTest extends AndroidTestCase { + + private static final String HACK_PREFERENCES_NAME = "org.anhonesteffort.flock.crypto.KeyStore"; + + private Context context; + + @Override + protected void setUp() throws Exception { + context = this.getContext(); + } + + private static void saveBytes(Context context, String key, byte[] value) { + SharedPreferences settings = context.getSharedPreferences(HACK_PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); + SharedPreferences.Editor editor = settings.edit(); + + editor.putString(key, Base64.encodeBytes(value)); + editor.commit(); + } + + private static void saveString(Context context, String key, String value) { + SharedPreferences settings = context.getSharedPreferences(HACK_PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); + SharedPreferences.Editor editor = settings.edit(); + + editor.putString(key, value); + editor.commit(); + } + + private static Optional retrieveBytes(Context context, String key) throws IOException { + SharedPreferences settings = context.getSharedPreferences(HACK_PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); + String encodedValue = settings.getString(key, null); + + if (encodedValue == null) + return Optional.absent(); + + return Optional.of(Base64.decode(encodedValue)); + } + + private byte[] encryptAndEncode(byte[] iv, SecretKey cipherKey, SecretKey macKey, byte[] data) + throws IOException, GeneralSecurityException + { + IvParameterSpec ivSpec = new IvParameterSpec(iv); + Cipher encryptingCipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); + Mac hmac = Mac.getInstance("HmacSHA256"); + + encryptingCipher.init(Cipher.ENCRYPT_MODE, cipherKey, ivSpec); + hmac.init(macKey); + + byte[] ciphertext = encryptingCipher.doFinal(data); + byte[] mac = hmac.doFinal(Util.combine(new byte[]{MasterCipher.CURRENT_CIPHER_VERSION}, iv, ciphertext)); + + return Base64.encodeBytesToBytes(Util.combine(new byte[]{MasterCipher.CURRENT_CIPHER_VERSION}, iv, ciphertext, mac)); + } + + public void testKeyHelperGenerateKeyMaterial() throws Exception { + KeyHelper.generateAndSaveSaltAndKeyMaterial(context); + + Optional resultCipherKeyBytes = retrieveBytes(context, "KEY_CIPHER_KEY"); + Optional resultMacKeyBytes = retrieveBytes(context, "KEY_MAC_KEY"); + Optional resultSaltBytes = retrieveBytes(context, "KEY_KEY_MATERIAL_SALT"); + + assertTrue("KeyHelper can generate key material.", + resultCipherKeyBytes.get().length > 0 && + resultMacKeyBytes.get().length > 0 && + resultSaltBytes.get().length > 0 && + !Arrays.equals(resultCipherKeyBytes.get(), resultMacKeyBytes.get()) && + !Arrays.equals(resultCipherKeyBytes.get(), resultSaltBytes.get()) && + !Arrays.equals(resultMacKeyBytes.get(), resultSaltBytes.get())); + } + + public void testKeyHelperMasterCipher() throws Exception { + final byte[] cipherKeyBytes = new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + final byte[] macKeyBytes = new byte[] {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + final byte[] plaintext = new byte[] {2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}; + + final SecretKey testCipherKey = new SecretKeySpec(cipherKeyBytes, "AES"); + final SecretKey testMacKey = new SecretKeySpec(macKeyBytes, "SHA256"); + + saveBytes(context, "KEY_CIPHER_KEY", cipherKeyBytes); + saveBytes(context, "KEY_MAC_KEY", macKeyBytes); + + byte[] encodedKeyHelperResult = KeyHelper.getMasterCipher(context).get().encryptAndEncode(plaintext); + byte[] keyHelperResult = Base64.decode(encodedKeyHelperResult); + byte[] keyHelperIv = Arrays.copyOfRange(keyHelperResult, 1, 1 + 16); + + byte[] encodedTestResult = encryptAndEncode(keyHelperIv, testCipherKey, testMacKey, plaintext); + byte[] testResult = Base64.decode(encodedTestResult); + + assertTrue("KeyHelper's MasterCipher works.", + new String(testResult).equals(new String(keyHelperResult))); + } + + public void testBuildAndImportEncryptedKeyMaterial() throws Exception { + final String masterPassphrase = "oioioi"; + final byte[] cipherKeyBytes = new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + final byte[] macKeyBytes = new byte[] {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + final byte[] saltBytes = new byte[] {2, 2, 2, 2, 2, 2, 2, 2}; + + saveBytes(context, "KEY_CIPHER_KEY", cipherKeyBytes); + saveBytes(context, "KEY_MAC_KEY", macKeyBytes); + saveBytes(context, "KEY_KEY_MATERIAL_SALT", saltBytes); + saveString(context, "KEY_OLD_MASTER_PASSPHRASE", masterPassphrase); + + Optional encodedSalt = KeyHelper.buildEncodedSalt(context); + Optional encryptedKeyMaterial = KeyHelper.buildEncryptedKeyMaterial(context); + String[] saltAndEncryptedKeyMaterial = new String[] { + encodedSalt.get(), + encryptedKeyMaterial.get() + }; + + KeyStore.invalidateKeyMaterial(context); + saveString(context, "KEY_OLD_MASTER_PASSPHRASE", masterPassphrase); + + KeyHelper.importSaltAndEncryptedKeyMaterial(context, saltAndEncryptedKeyMaterial); + + Optional resultCipherKeyBytes = retrieveBytes(context, "KEY_CIPHER_KEY"); + Optional resultMacKeyBytes = retrieveBytes(context, "KEY_MAC_KEY"); + Optional resultSaltBytes = retrieveBytes(context, "KEY_KEY_MATERIAL_SALT"); + + assertTrue("KeyHelper can export and import encrypted key material.", + Arrays.equals(resultCipherKeyBytes.get(), cipherKeyBytes) && + Arrays.equals(resultMacKeyBytes.get(), macKeyBytes) && + Arrays.equals(resultSaltBytes.get(), saltBytes)); + } +} diff --git a/flock/src/androidTest/java/org/anhonesteffort/flock/test/sync/MockMasterCipher.java b/flock/src/androidTest/java/org/anhonesteffort/flock/test/sync/MockMasterCipher.java index d753a2e..fa163fd 100644 --- a/flock/src/androidTest/java/org/anhonesteffort/flock/test/sync/MockMasterCipher.java +++ b/flock/src/androidTest/java/org/anhonesteffort/flock/test/sync/MockMasterCipher.java @@ -14,7 +14,7 @@ public class MockMasterCipher extends MasterCipher { public MockMasterCipher() { - super(null, null); + super(false, null, null); } @Override @@ -28,8 +28,8 @@ public String encryptAndEncode(String data) { } @Override - public byte[] decodeAndDecrypt(byte[] encodedIvCiphertextAndMac) { - return Base64.decode(encodedIvCiphertextAndMac); + public byte[] decodeAndDecrypt(byte[] encodedVersionIvCiphertextAndMac) { + return Base64.decode(encodedVersionIvCiphertextAndMac); } public String decodeAndDecrypt(String data) throws IOException { diff --git a/flock/src/main/AndroidManifest.xml b/flock/src/main/AndroidManifest.xml index 975454b..e4ee5af 100644 --- a/flock/src/main/AndroidManifest.xml +++ b/flock/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="8" + android:versionName="0.8" > + + @@ -197,12 +199,25 @@ + + + + + + + + + + + + diff --git a/flock/src/main/java/org/anhonesteffort/flock/AbstractMyCollectionsFragment.java b/flock/src/main/java/org/anhonesteffort/flock/AbstractMyCollectionsFragment.java index 49e0048..a87e72d 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/AbstractMyCollectionsFragment.java +++ b/flock/src/main/java/org/anhonesteffort/flock/AbstractMyCollectionsFragment.java @@ -78,7 +78,7 @@ public View onCreateView(LayoutInflater inflater, activity = getActivity(); View fragmentView = inflater.inflate(R.layout.fragment_list_sync_collections, container, false); - if (accountAndKeyAvailable()) + if (accountAndKeyAvailableAndMigrationComplete()) initButtons(); return fragmentView; @@ -105,6 +105,9 @@ public void onClick(View view) { public void onResume() { super.onResume(); + if (!accountAndKeyAvailableAndMigrationComplete()) + return; + activity = getActivity(); initializeList(); } diff --git a/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredActivity.java b/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredActivity.java index 4f5ef88..e3e7628 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredActivity.java +++ b/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredActivity.java @@ -97,7 +97,16 @@ protected void onCreate(Bundle savedInstanceState) { masterCipher = handleGetMasterCipherOrFail(this); } - protected boolean accountAndKeyAvailable() { + protected boolean accountAndKeyAvailableAndMigrationComplete() { + if (MigrationHelperBroadcastReceiver.getUiDisabledForMigration(getBaseContext())) { + Toast.makeText(getBaseContext(), + R.string.migration_in_progress_please_wait, + Toast.LENGTH_LONG).show(); + + finish(); + return false; + } + return account != null && masterCipher != null; } diff --git a/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredFragment.java b/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredFragment.java index 59ea31c..e74d764 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredFragment.java +++ b/flock/src/main/java/org/anhonesteffort/flock/AccountAndKeyRequiredFragment.java @@ -21,6 +21,7 @@ import android.app.Activity; import android.support.v4.app.Fragment; +import android.widget.Toast; import org.anhonesteffort.flock.auth.DavAccount; import org.anhonesteffort.flock.crypto.MasterCipher; @@ -41,7 +42,16 @@ public void onAttach(Activity activity) { masterCipher = AccountAndKeyRequiredActivity.handleGetMasterCipherOrFail(getActivity()); } - protected boolean accountAndKeyAvailable() { + protected boolean accountAndKeyAvailableAndMigrationComplete() { + if (MigrationHelperBroadcastReceiver.getUiDisabledForMigration(getActivity())) { + Toast.makeText(getActivity(), + R.string.migration_in_progress_please_wait, + Toast.LENGTH_LONG).show(); + + getActivity().finish(); + return false; + } + return account != null && masterCipher != null; } diff --git a/flock/src/main/java/org/anhonesteffort/flock/ChangeEncryptionPasswordActivity.java b/flock/src/main/java/org/anhonesteffort/flock/ChangeEncryptionPasswordActivity.java index 274863c..a62b4c0 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/ChangeEncryptionPasswordActivity.java +++ b/flock/src/main/java/org/anhonesteffort/flock/ChangeEncryptionPasswordActivity.java @@ -24,7 +24,6 @@ import android.os.Handler; import android.os.Message; import android.os.Messenger; -import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.MenuItem; @@ -55,7 +54,7 @@ public class ChangeEncryptionPasswordActivity extends AccountAndKeyRequiredActiv protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return; requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); diff --git a/flock/src/main/java/org/anhonesteffort/flock/DavAccountHelper.java b/flock/src/main/java/org/anhonesteffort/flock/DavAccountHelper.java index 1241634..f1e44d1 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/DavAccountHelper.java +++ b/flock/src/main/java/org/anhonesteffort/flock/DavAccountHelper.java @@ -19,6 +19,7 @@ package org.anhonesteffort.flock; +import android.accounts.Account; import android.accounts.AccountManager; import android.content.Context; import android.content.SharedPreferences; @@ -92,8 +93,8 @@ public static Optional getAccountDavHREF(Context context) { return Optional.fromNullable(getSharedPreferences(context).getString(KEY_DAV_HOST, null)); } - public static void setAccountDavHREF(Context context, String username) { - getSharedPreferences(context).edit().putString(KEY_DAV_HOST, username).commit(); + public static void setAccountDavHREF(Context context, String href) { + getSharedPreferences(context).edit().putString(KEY_DAV_HOST, href).commit(); } public static void invalidateAccount(Context context) { @@ -111,6 +112,14 @@ public static boolean isUsingOurServers(Context context) { getAccountDavHREF(context).get().equals(OwsWebDav.HREF_WEBDAV_HOST); } + public static Optional getOsAccount(Context context) { + Optional accountUsername = getAccountUsername(context); + if (!accountUsername.isPresent()) + return Optional.absent(); + + return Optional.of(new Account(accountUsername.get(), DavAccount.SYNC_ACCOUNT_TYPE)); + } + public static Optional getAccount(Context context) { Optional davHREF = getAccountDavHREF(context); Optional accountUsername = getAccountUsername(context); diff --git a/flock/src/main/java/org/anhonesteffort/flock/DeleteAllContactsActivity.java b/flock/src/main/java/org/anhonesteffort/flock/DeleteAllContactsActivity.java index d7a320f..d104fc3 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/DeleteAllContactsActivity.java +++ b/flock/src/main/java/org/anhonesteffort/flock/DeleteAllContactsActivity.java @@ -1,3 +1,22 @@ +/* + * * + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * / + */ + package org.anhonesteffort.flock; import android.app.AlertDialog; @@ -34,7 +53,7 @@ public class DeleteAllContactsActivity extends AccountAndKeyRequiredActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return; requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); diff --git a/flock/src/main/java/org/anhonesteffort/flock/ImportAccountService.java b/flock/src/main/java/org/anhonesteffort/flock/ImportAccountService.java index 81f1cb6..a57b41b 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/ImportAccountService.java +++ b/flock/src/main/java/org/anhonesteffort/flock/ImportAccountService.java @@ -12,9 +12,11 @@ import org.anhonesteffort.flock.crypto.InvalidMacException; import org.anhonesteffort.flock.crypto.KeyHelper; import org.anhonesteffort.flock.crypto.KeyStore; +import org.anhonesteffort.flock.sync.OwsWebDav; import org.anhonesteffort.flock.sync.key.DavKeyCollection; import org.anhonesteffort.flock.sync.key.DavKeyStore; import org.anhonesteffort.flock.sync.key.KeySyncScheduler; +import org.anhonesteffort.flock.webdav.InvalidComponentException; import org.anhonesteffort.flock.webdav.PropertyParseException; import org.apache.jackrabbit.webdav.DavException; @@ -36,6 +38,7 @@ private void handleImportOrGenerateKeyMaterial(Bundle result, { Optional saltAndEncryptedKeyMaterial = Optional.absent(); KeyStore.saveMasterPassphrase(getBaseContext(), cipherPassphrase); + DavAccountHelper.setAccountDavHREF(getBaseContext(), account.getDavHostHREF()); try { @@ -53,6 +56,11 @@ private void handleImportOrGenerateKeyMaterial(Bundle result, } ); } + if (!keyCollection.get().isMigrationComplete() && + !keyCollection.get().isMigrationStarted()) + { + KeyStore.setUseCipherVersionZero(getBaseContext(), true); + } } else { DavKeyStore.createCollection(getBaseContext(), account); @@ -62,8 +70,14 @@ private void handleImportOrGenerateKeyMaterial(Bundle result, result.putInt(ErrorToaster.KEY_STATUS_CODE, ErrorToaster.CODE_DAV_SERVER_ERROR); return; } + + keyCollection.get().setMigrationComplete(getBaseContext()); + MigrationHelperBroadcastReceiver.setMigrationUpdateHandled(getBaseContext()); + MigrationHelperBroadcastReceiver.setUiDisabledForMigration(getBaseContext(), false); } + } catch (InvalidComponentException e) { + ErrorToaster.handleBundleError(e, result); } catch (PropertyParseException e) { ErrorToaster.handleBundleError(e, result); } catch (DavException e) { diff --git a/flock/src/main/java/org/anhonesteffort/flock/ImportCalendarsFragment.java b/flock/src/main/java/org/anhonesteffort/flock/ImportCalendarsFragment.java index 29889e4..479a34b 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/ImportCalendarsFragment.java +++ b/flock/src/main/java/org/anhonesteffort/flock/ImportCalendarsFragment.java @@ -82,7 +82,7 @@ public View onCreateView(LayoutInflater inflater, { View fragmentView = inflater.inflate(R.layout.fragment_simple_list, container, false); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return fragmentView; initButtons(); @@ -94,7 +94,7 @@ public View onCreateView(LayoutInflater inflater, public void onResume() { super.onResume(); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return ; initializeList(); diff --git a/flock/src/main/java/org/anhonesteffort/flock/ImportContactsFragment.java b/flock/src/main/java/org/anhonesteffort/flock/ImportContactsFragment.java index 275c0e6..de07347 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/ImportContactsFragment.java +++ b/flock/src/main/java/org/anhonesteffort/flock/ImportContactsFragment.java @@ -22,7 +22,6 @@ import android.accounts.Account; import android.accounts.AccountManager; import android.app.Activity; -import android.app.ProgressDialog; import android.content.ContentProviderClient; import android.content.Intent; import android.database.Cursor; @@ -76,7 +75,7 @@ public View onCreateView(LayoutInflater inflater, { View fragmentView = inflater.inflate(R.layout.fragment_simple_list, container, false); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return fragmentView; initButtons(); @@ -88,7 +87,7 @@ public View onCreateView(LayoutInflater inflater, public void onResume() { super.onResume(); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return ; initializeList(); diff --git a/flock/src/main/java/org/anhonesteffort/flock/ImportOwsAccountService.java b/flock/src/main/java/org/anhonesteffort/flock/ImportOwsAccountService.java index 53b3bfc..a9df75a 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/ImportOwsAccountService.java +++ b/flock/src/main/java/org/anhonesteffort/flock/ImportOwsAccountService.java @@ -122,6 +122,8 @@ private void handleDavLogin(Bundle result) { String authToken = KeyUtil.getAuthTokenForPassphrase(masterPassphrase); importAccount = new DavAccount(accountId, authToken, OwsWebDav.HREF_WEBDAV_HOST); + DavAccountHelper.setAccountDavHREF(getBaseContext(), importAccount.getDavHostHREF()); + if (DavAccountHelper.isAuthenticated(getBaseContext(), importAccount)) { if (DavAccountHelper.isExpired(getBaseContext(), importAccount)) result.putInt(ErrorToaster.KEY_STATUS_CODE, ErrorToaster.CODE_SUBSCRIPTION_EXPIRED); diff --git a/flock/src/main/java/org/anhonesteffort/flock/MigrationHelperBroadcastReceiver.java b/flock/src/main/java/org/anhonesteffort/flock/MigrationHelperBroadcastReceiver.java new file mode 100644 index 0000000..379d707 --- /dev/null +++ b/flock/src/main/java/org/anhonesteffort/flock/MigrationHelperBroadcastReceiver.java @@ -0,0 +1,151 @@ +/* + * * + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * / + */ + +package org.anhonesteffort.flock; + +import android.accounts.Account; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.util.Log; + +import com.google.common.base.Optional; + +import org.anhonesteffort.flock.auth.DavAccount; +import org.anhonesteffort.flock.sync.addressbook.AddressbookSyncScheduler; +import org.anhonesteffort.flock.sync.addressbook.AddressbookSyncWorker; +import org.anhonesteffort.flock.sync.calendar.CalendarsSyncScheduler; +import org.anhonesteffort.flock.sync.key.DavKeyCollection; +import org.anhonesteffort.flock.sync.key.KeySyncScheduler; +import org.anhonesteffort.flock.sync.key.KeySyncWorker; + +/** + * rhodey. + */ +public class MigrationHelperBroadcastReceiver extends BroadcastReceiver { + + private static final String TAG = MigrationHelperBroadcastReceiver.class.getSimpleName(); + + private static final String KEY_MIGRATION_UPDATED_HANDLED = "MigrationHelperBroadcastReceiver.KEY_MIGRATION_UPDATED_HANDLED"; + private static final String KEY_UI_DISABLED_FOR_MIGRATION = "MigrationHelperBroadcastReceiver.KEY_UI_DISABLED_FOR_MIGRATION"; + + public static void setMigrationUpdateHandled(Context context) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + preferences.edit().putBoolean(KEY_MIGRATION_UPDATED_HANDLED, true).commit(); + } + + private static boolean getMigrationUpdateHandled(Context context) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + return preferences.getBoolean(KEY_MIGRATION_UPDATED_HANDLED, false); + } + + public static void setUiDisabledForMigration(Context context, + boolean uiDisabled) + { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + preferences.edit().putBoolean(KEY_UI_DISABLED_FOR_MIGRATION, uiDisabled).commit(); + } + + public static boolean getUiDisabledForMigration(Context context) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + return preferences.getBoolean(KEY_UI_DISABLED_FOR_MIGRATION, false); + } + + private void handleActionMyPackageReplaced(Context context) { + Log.d(TAG, "handleActionMyPackageReplaced"); + + if (MigrationHelperBroadcastReceiver.getMigrationUpdateHandled(context)) { + Log.d(TAG, "migration already handled for this update, nothing to do."); + return; + } + + if (!DavAccountHelper.isUsingOurServers(context)) { + Intent nextIntent = new Intent(context, MigrationReleaseNotesActivity.class); + nextIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(nextIntent); + } + + new KeySyncScheduler(context).requestSync(); + MigrationHelperBroadcastReceiver.setUiDisabledForMigration(context, true); + MigrationHelperBroadcastReceiver.setMigrationUpdateHandled(context); + + Optional account = DavAccountHelper.getOsAccount(context); + if (account.isPresent()) { + new CalendarsSyncScheduler(context).cancelPendingSyncs(account.get()); + new AddressbookSyncScheduler(context).cancelPendingSyncs(account.get()); + + new CalendarsSyncScheduler(context).setSyncEnabled(account.get(), false); + new AddressbookSyncScheduler(context).setSyncEnabled(account.get(), false); + } + else + Log.w(TAG, "account not present at handleActionMyPackageReplaced()"); + } + + private void handleActionMigrationStarted(Context context) { + Log.d(TAG, "handleActionMigrationStarted"); + MigrationHelperBroadcastReceiver.setUiDisabledForMigration(context, true); + } + + private void handleActionMigrationComplete(Context context) { + Log.d(TAG, "handleActionMigrationComplete"); + MigrationHelperBroadcastReceiver.setUiDisabledForMigration(context, false); + } + + private void handleActionKeyMaterialImported(Context context) { + Log.d(TAG, "handleActionKeyMaterialImported"); + + if (!DavKeyCollection.weStartedMigration(context)) + MigrationHelperBroadcastReceiver.setUiDisabledForMigration(context, false); + } + + private void handleActionPushCreatedContacts(Context context) { + Log.d(TAG, "handleActionPushCreatedContacts"); + MigrationService.hackOnActionPushCreatedContacts(context); + } + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "onReceive()"); + + if (intent == null || intent.getAction() == null) { + Log.e(TAG, "received null intent or intent with null action."); + return; + } + + if (intent.getAction().equals(Intent.ACTION_MY_PACKAGE_REPLACED)) + handleActionMyPackageReplaced(context); + else if (intent.getAction().equals(AddressbookSyncWorker.ACTION_PUSH_CREATED_CONTACTS)) + handleActionPushCreatedContacts(context); + else if (intent.getPackage() == null || + !intent.getPackage().equals(MigrationHelperBroadcastReceiver.class.getPackage().getName())) + { + Log.e(TAG, "received intent from untrusted package."); + } + else if (intent.getAction().equals(MigrationService.ACTION_MIGRATION_STARTED)) + handleActionMigrationStarted(context); + else if (intent.getAction().equals(MigrationService.ACTION_MIGRATION_COMPLETE)) + handleActionMigrationComplete(context); + else if (intent.getAction().equals(KeySyncWorker.ACTION_KEY_MATERIAL_IMPORTED)) + handleActionKeyMaterialImported(context); + else + Log.e(TAG, "received intent with unwanted action."); + } +} diff --git a/flock/src/main/java/org/anhonesteffort/flock/MigrationReleaseNotesActivity.java b/flock/src/main/java/org/anhonesteffort/flock/MigrationReleaseNotesActivity.java new file mode 100644 index 0000000..042959b --- /dev/null +++ b/flock/src/main/java/org/anhonesteffort/flock/MigrationReleaseNotesActivity.java @@ -0,0 +1,59 @@ +/* + * * + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * / + */ + +package org.anhonesteffort.flock; + +import android.app.Activity; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; + +/** + * rhodey + */ +public class MigrationReleaseNotesActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.migration_release_notes); + getActionBar().setDisplayHomeAsUpEnabled(false); + getActionBar().setTitle(R.string.release_notes); + + initButtons(); + } + + private void initButtons() { + findViewById(R.id.button_ok).setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + finish(); + } + + }); + } + + @Override + public void onBackPressed() { + + } + +} diff --git a/flock/src/main/java/org/anhonesteffort/flock/MigrationService.java b/flock/src/main/java/org/anhonesteffort/flock/MigrationService.java new file mode 100644 index 0000000..4cc07fa --- /dev/null +++ b/flock/src/main/java/org/anhonesteffort/flock/MigrationService.java @@ -0,0 +1,890 @@ +/* + * * + * Copyright (C) 2014 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * / + */ + +package org.anhonesteffort.flock; + +import android.app.NotificationManager; +import android.app.Service; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.OperationApplicationException; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.RemoteException; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +import com.google.common.base.Optional; + +import org.anhonesteffort.flock.auth.DavAccount; +import org.anhonesteffort.flock.crypto.KeyHelper; +import org.anhonesteffort.flock.crypto.KeyStore; +import org.anhonesteffort.flock.crypto.MasterCipher; +import org.anhonesteffort.flock.registration.RegistrationApi; +import org.anhonesteffort.flock.registration.RegistrationApiException; +import org.anhonesteffort.flock.sync.AbstractLocalComponentCollection; +import org.anhonesteffort.flock.sync.addressbook.AddressbookSyncScheduler; +import org.anhonesteffort.flock.sync.addressbook.HidingCardDavStore; +import org.anhonesteffort.flock.sync.addressbook.LocalAddressbookStore; +import org.anhonesteffort.flock.sync.addressbook.LocalContactCollection; +import org.anhonesteffort.flock.sync.calendar.CalendarsSyncScheduler; +import org.anhonesteffort.flock.sync.calendar.HidingCalDavStore; +import org.anhonesteffort.flock.sync.calendar.LocalCalendarStore; +import org.anhonesteffort.flock.sync.calendar.LocalEventCollection; +import org.anhonesteffort.flock.sync.key.DavKeyCollection; +import org.anhonesteffort.flock.sync.key.DavKeyStore; +import org.anhonesteffort.flock.webdav.InvalidComponentException; +import org.anhonesteffort.flock.webdav.PropertyParseException; +import org.anhonesteffort.flock.webdav.caldav.CalDavStore; +import org.anhonesteffort.flock.webdav.carddav.CardDavStore; +import org.apache.jackrabbit.webdav.DavException; +import org.apache.jackrabbit.webdav.DavServletResponse; + +import java.io.IOException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.security.GeneralSecurityException; +import java.util.Date; +import java.util.Timer; +import java.util.TimerTask; + + +/** + * rhodey + */ +public class MigrationService extends Service { + + public static final String ACTION_MIGRATION_STARTED = "org.anhonesteffort.flock.MigrationService.ACTION_MIGRATION_STARTED"; + public static final String ACTION_MIGRATION_COMPLETE = "org.anhonesteffort.flock.MigrationService.ACTION_MIGRATION_COMPLETE"; + + private static final String TAG = MigrationService.class.getSimpleName(); + private static final String PREFERENCES_NAME = "MigrationService.PREFERENCES_NAME"; + + private static final String KEY_STATE = "MigrationService.KEY_STATE"; + private static final String KEY_TIME_FIRST_CALENDAR_SYNC = "MigrationService.KEY_TIME_FIRST_CALENDAR_SYNC"; + private static final String KEY_TIME_FIRST_ADDRESSBOOK_SYNC = "MigrationService.KEY_TIME_FIRST_ADDRESSBOOK_SYNC"; + + private static final int STATE_STARTED_MIGRATION = 1; + private static final int STATE_SYNCED_WITH_REMOTE = 2; + private static final int STATE_DELETED_KEY_COLLECTION = 3; + private static final int STATE_GENERATED_NEW_KEYS = 4; + private static final int STATE_REPLACED_KEY_COLLECTION = 5; + private static final int STATE_REPLACED_KEYS = 6; + private static final int STATE_DELETED_REMOTE_CALENDARS_AND_ADDRESSBOOKS = 7; + private static final int STATE_REPLACED_REMOTE_CALENDARS = 8; + private static final int STATE_REPLACED_REMOTE_ADDRESSBOOKS = 9; + private static final int STATE_READY_TO_REPLACE_EVENTS_AND_CONTACTS = 10; + private static final int STATE_REPLACED_EVENTS_AND_CONTACTS = 11; + private static final int STATE_MIGRATION_COMPLETE = 12; + + private static final long KICK_INTERVAL_MILLISECONDS = 5000; + + private final Handler messageHandler = new Handler(); + private ServiceHandler serviceHandler; + private Timer intervalTimer; + private NotificationManager notifyManager; + private NotificationCompat.Builder notificationBuilder; + + private DavAccount account; + private MasterCipher masterCipher; + + private void setState(int state) { + Log.d(TAG, "setState() >> " + state); + + SharedPreferences settings = + getBaseContext().getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + + settings.edit().putInt(KEY_STATE, state).commit(); + handleUpdateNotificationUsingState(); + } + + private static int getState(Context context) { + SharedPreferences settings = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + + Log.d(TAG, "getState() >> " + settings.getInt(KEY_STATE, 0)); + + return settings.getInt(KEY_STATE, 0); + } + + public static void hackOnActionPushCreatedContacts(Context context) { + if (getState(context) == STATE_READY_TO_REPLACE_EVENTS_AND_CONTACTS) { + Log.d(TAG, "just finished pushing new contacts during replace events and contacts state" + + ", marking early finish of contact sync."); + new AddressbookSyncScheduler(context).setTimeLastSync(new Date().getTime()); + } + } + + private void recordTimeFirstSync(String key, long timeMilliseconds) { + Log.d(TAG, "recordTimeFirstSync() >> " + key + " " + timeMilliseconds); + + SharedPreferences settings = + getBaseContext().getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + + settings.edit().putLong(key, timeMilliseconds).commit(); + } + + private long getTimeFirstSync(String key) { + SharedPreferences settings = + getBaseContext().getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + + return settings.getLong(key, -1); + } + + private void handleInitializeNotification() { + Log.d(TAG, "handleInitializeNotification()"); + + notificationBuilder.setContentTitle(getString(R.string.migrating_to_new_version)) + .setContentText(getString(R.string.preparing_to_upgrade_sync_protocol)) + .setProgress(0, 0, true) + .setSmallIcon(R.drawable.flock_actionbar_icon); + + startForeground(1030, notificationBuilder.build()); + } + + private void handleAskUserToEnableSync() { + Log.d(TAG, "handleAskUserToEnableSync()"); + + notificationBuilder.setContentTitle(getString(R.string.please_enable_sync)) + .setContentText(getString(R.string.please_enable_anrdoid_sync_to_complete_migration)) + .setProgress(0, 0, false) + .setSmallIcon(R.drawable.alert_warning_light); + + startForeground(1030, notificationBuilder.build()); + } + + private void handleUpdateNotificationUsingState() { + Log.d(TAG, "handleUpdateNotificationUsingState() >> " + getState(getBaseContext())); + + notificationBuilder.setContentTitle(getString(R.string.migrating_to_new_version)) + .setSmallIcon(R.drawable.flock_actionbar_icon) + .setProgress(0, 0, true); + + switch (getState(getBaseContext())) { + case STATE_STARTED_MIGRATION: + notificationBuilder.setContentText(getString(R.string.checking_sync_for_new_contacts_and_calendar)); + break; + + case STATE_SYNCED_WITH_REMOTE: + case STATE_DELETED_KEY_COLLECTION: + case STATE_GENERATED_NEW_KEYS: + case STATE_REPLACED_KEY_COLLECTION: + notificationBuilder.setContentText(getString(R.string.generating_new_encryption_secrets)); + break; + + case STATE_REPLACED_KEYS: + case STATE_DELETED_REMOTE_CALENDARS_AND_ADDRESSBOOKS: + case STATE_REPLACED_REMOTE_CALENDARS: + notificationBuilder.setContentText(getString(R.string.replacing_addressbooks_and_calendars)); + break; + + case STATE_REPLACED_REMOTE_ADDRESSBOOKS: + case STATE_READY_TO_REPLACE_EVENTS_AND_CONTACTS: + notificationBuilder.setContentText(getString(R.string.replacing_old_contacts_and_events)); + break; + + case STATE_REPLACED_EVENTS_AND_CONTACTS: + notificationBuilder.setContentText(getString(R.string.finalizing_migration)); + break; + + case STATE_MIGRATION_COMPLETE: + notificationBuilder.setContentTitle(getString(R.string.migration_complete)); + notificationBuilder.setContentText(getString(R.string.please_update_flock_on_all_your_devices)); + break; + } + + notifyManager.notify(1030, notificationBuilder.build()); + } + + private void handleMigrationComplete() { + Log.d(TAG, "handleMigrationComplete()"); + + if (intervalTimer != null) + intervalTimer.cancel(); + + Intent intent = new Intent(); + intent.setPackage(MigrationHelperBroadcastReceiver.class.getPackage().getName()); + intent.setAction(ACTION_MIGRATION_COMPLETE); + sendBroadcast(intent); + + stopForeground(false); + stopSelf(); + } + + @Override + public void onDestroy() { + Log.d(TAG, "onDestroy()"); + + if (getState(getBaseContext()) == STATE_MIGRATION_COMPLETE) { + notificationBuilder + .setSmallIcon(R.drawable.flock_actionbar_icon) + .setContentTitle(getString(R.string.migration_complete)) + .setContentText(getString(R.string.please_update_flock_on_all_your_devices)) + .setProgress(0, 0, false); + + notifyManager.notify(1030, notificationBuilder.build()); + } + } + + private void handleEnableAllSyncAdapters() { + new CalendarsSyncScheduler(getBaseContext()).setSyncEnabled(account.getOsAccount(), true); + new AddressbookSyncScheduler(getBaseContext()).setSyncEnabled(account.getOsAccount(), true); + } + + private void handleDisableAllSyncAdapters() { + new CalendarsSyncScheduler(getBaseContext()).setSyncEnabled(account.getOsAccount(), false); + new AddressbookSyncScheduler(getBaseContext()).setSyncEnabled(account.getOsAccount(), false); + + new CalendarsSyncScheduler(getBaseContext()).cancelPendingSyncs(account.getOsAccount()); + new AddressbookSyncScheduler(getBaseContext()).cancelPendingSyncs(account.getOsAccount()); + } + + private void handleException(Exception e) { + if (e instanceof IOException) { + IOException ex = (IOException) e; + + if (ex instanceof SocketException || + ex instanceof UnknownHostException || + ex instanceof SocketTimeoutException) + { + Log.d(TAG, "experienced connection error during migration, will continue trying.", e); + } + else + Log.d(TAG, "caught unknown IOException during migration >> " + e.toString(), e); + } + else + Log.d(TAG, "caught exception during migration >> " + e.toString(), e); + } + + private void handleStartMigration() { + Log.d(TAG, "handleStartMigration()"); + + handleInitializeNotification(); + handleStartKickingMigration(); + + KeyStore.setUseCipherVersionZero(getBaseContext(), true); + + if (DavAccountHelper.isUsingOurServers(getBaseContext())) { + try { + + new RegistrationApi(getBaseContext()).setAccountVersion(account, 2); + + } catch (RegistrationApiException e) { + handleException(e); + return; + } catch (IOException e) { + handleException(e); + return; + } + } + + Intent intent = new Intent(); + intent.setPackage(MigrationHelperBroadcastReceiver.class.getPackage().getName()); + intent.setAction(ACTION_MIGRATION_STARTED); + sendBroadcast(intent); + + setState(STATE_STARTED_MIGRATION); + } + + private void handleSyncWithRemote() { + Log.d(TAG, "handleSyncWithRemote()"); + + handleEnableAllSyncAdapters(); + if (!ContentResolver.getMasterSyncAutomatically()) { + handleAskUserToEnableSync(); + return; + } + handleUpdateNotificationUsingState(); + + Long firstRecordedCalendarSync = getTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC); + Optional timeLastCalendarSync = new CalendarsSyncScheduler(getBaseContext()).getTimeLastSync(); + + if (firstRecordedCalendarSync <= 0) { + if (!timeLastCalendarSync.isPresent()) + recordTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC, new Date().getTime()); + else + recordTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC, timeLastCalendarSync.get()); + + new CalendarsSyncScheduler(getBaseContext()).requestSync(); + } + + Long firstRecordedAddressbookSync = getTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC); + Optional timeLastAddressbookSync = new AddressbookSyncScheduler(getBaseContext()).getTimeLastSync(); + + if (firstRecordedAddressbookSync <= 0) { + if (!timeLastAddressbookSync.isPresent()) + recordTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC, new Date().getTime()); + else + recordTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC, timeLastAddressbookSync.get()); + + new AddressbookSyncScheduler(getBaseContext()).requestSync(); + } + + if (firstRecordedCalendarSync > 0 && firstRecordedAddressbookSync > 0) { + if (timeLastCalendarSync.isPresent() && timeLastAddressbookSync.isPresent()) { + if (timeLastCalendarSync.get() > firstRecordedCalendarSync && + timeLastAddressbookSync.get() > firstRecordedAddressbookSync) + { + if (new CalendarsSyncScheduler(getBaseContext()).syncInProgress(account.getOsAccount()) || + new AddressbookSyncScheduler(getBaseContext()).syncInProgress(account.getOsAccount())) + { + Log.w(TAG, "finished syncing with remote but waiting for active syncs to complete."); + handleDisableAllSyncAdapters(); + return; + } + + KeyStore.setUseCipherVersionZero(getBaseContext(), false); + handleDisableAllSyncAdapters(); + + recordTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC, -1); + recordTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC, -1); + + setState(STATE_SYNCED_WITH_REMOTE); + } + } + } + } + + private void handleDeleteKeyCollection() { + Log.d(TAG, "handleDeleteKeyCollection()"); + + try { + + DavKeyStore keyStore = DavAccountHelper.getDavKeyStore(getBaseContext(), account); + + try { + + Optional calendarHomeSet = keyStore.getCalendarHomeSet(); + keyStore.removeCollection(calendarHomeSet.get().concat(DavKeyStore.PATH_KEY_COLLECTION)); + + setState(STATE_DELETED_KEY_COLLECTION); + + } catch (PropertyParseException e) { + handleException(e); + } catch (DavException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + keyStore.closeHttpConnection(); + } + + } catch (IOException e) { + handleException(e); + } + } + + private void handleGenerateNewKeys() { + Log.d(TAG, "handleGenerateNewKeys()"); + + try { + + KeyHelper.generateAndSaveSaltAndKeyMaterial(getBaseContext()); + masterCipher = KeyHelper.getMasterCipher(getBaseContext()).get(); + + setState(STATE_GENERATED_NEW_KEYS); + + } catch (GeneralSecurityException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } + } + + private void handleCreateKeyCollection() { + Log.d(TAG, "handleCreateKeyCollection()"); + + try { + + DavKeyStore.createCollection(getBaseContext(), account); + + setState(STATE_REPLACED_KEY_COLLECTION); + + } catch (PropertyParseException e) { + handleException(e); + } catch (DavException e) { + + if (e.getErrorCode() == DavServletResponse.SC_FORBIDDEN) { + Log.w(TAG, "caught 403 when trying to create key collection, assuming already exists."); + setState(STATE_REPLACED_KEY_COLLECTION); + } + else + handleException(e); + + } catch (IOException e) { + handleException(e); + } + } + + private void handleReplaceKeys() { + Log.d(TAG, "handleReplaceKeys()"); + + try { + + DavKeyStore keyStore = DavAccountHelper.getDavKeyStore(getBaseContext(), account); + + try { + + Optional keyCollection = keyStore.getCollection(); + + if (!keyCollection.isPresent()) { + Log.e(TAG, "missing key collection, reverting state to regenerate keys!"); + setState(STATE_SYNCED_WITH_REMOTE); + return; + } + + Optional localKeyMaterialSalt = KeyHelper.buildEncodedSalt(getBaseContext()); + Optional localEncryptedKeyMaterial = KeyStore.getEncryptedKeyMaterial(getBaseContext()); + + if (localKeyMaterialSalt.isPresent() && localEncryptedKeyMaterial.isPresent()) { + keyCollection.get().setKeyMaterialSalt(localKeyMaterialSalt.get()); + keyCollection.get().setEncryptedKeyMaterial(localEncryptedKeyMaterial.get()); + + setState(STATE_REPLACED_KEYS); + } + else { + Log.e(TAG, "missing key material, reverting state to regenerate keys!"); + setState(STATE_SYNCED_WITH_REMOTE); + } + + } catch (PropertyParseException e) { + handleException(e); + } catch (DavException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + keyStore.closeHttpConnection(); + } + + } catch (IOException e) { + handleException(e); + } + } + + private void handleDeleteRemoteCalendarsAndAddressbooks() { + Log.d(TAG, "handleDeleteRemoteCalendarsAndAddressbooks()"); + + try { + + CalDavStore remoteCalendarStore = DavAccountHelper.getCalDavStore(getBaseContext(), account); + CardDavStore remoteAddressbookStore = DavAccountHelper.getCardDavStore(getBaseContext(), account); + + try { + + LocalCalendarStore localCalendarStore = new LocalCalendarStore(getBaseContext(), account.getOsAccount()); + LocalAddressbookStore localAddressbookStore = new LocalAddressbookStore(getBaseContext(), account); + + for (LocalEventCollection localCollection : localCalendarStore.getCollections()) { + Log.d(TAG, "deleting remote caldav collection at >> " + localCollection.getPath()); + remoteCalendarStore.removeCollection(localCollection.getPath()); + } + + for (LocalContactCollection localCollection : localAddressbookStore.getCollections()) { + Log.d(TAG, "deleting remote carddav collection at >> " + localCollection.getPath()); + remoteAddressbookStore.removeCollection(localCollection.getPath()); + } + + setState(STATE_DELETED_REMOTE_CALENDARS_AND_ADDRESSBOOKS); + + } catch (RemoteException e) { + handleException(e); + } catch (DavException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + remoteCalendarStore.closeHttpConnection(); + remoteAddressbookStore.closeHttpConnection(); + } + + } catch (IOException e) { + handleException(e); + } + } + + private void handleReplaceRemoteCalendars() { + Log.d(TAG, "handleReplaceRemoteCalendars()"); + + try { + + HidingCalDavStore remoteCalendarStore = DavAccountHelper.getHidingCalDavStore(getBaseContext(), account, masterCipher); + + try { + + LocalCalendarStore localCalendarStore = new LocalCalendarStore(getBaseContext(), account.getOsAccount()); + + for (LocalEventCollection localCollection : localCalendarStore.getCollections()) { + if (!remoteCalendarStore.getCollection(localCollection.getPath()).isPresent()) { + Log.d(TAG, "creating remote caldav collection at >> " + localCollection.getPath()); + + Optional displayName = localCollection.getDisplayName(); + Optional color = localCollection.getColor(); + + if (displayName.isPresent() && color.isPresent()) + remoteCalendarStore.addCollection(localCollection.getPath(), displayName.get(), color.get()); + else if (displayName.isPresent()) { + remoteCalendarStore.addCollection(localCollection.getPath(), + displayName.get(), + getBaseContext().getResources().getColor(R.color.flocktheme_color)); + } + else + remoteCalendarStore.addCollection(localCollection.getPath()); + } + } + + setState(STATE_REPLACED_REMOTE_CALENDARS); + + } catch (RemoteException e) { + handleException(e); + } catch (DavException e) { + + if (e.getErrorCode() != DavServletResponse.SC_FORBIDDEN) + handleException(e); + + } catch (GeneralSecurityException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + remoteCalendarStore.releaseConnections(); + } + + } catch (IOException e) { + handleException(e); + } + } + + private void handleReplaceRemoteAddressbooks() { + Log.d(TAG, "handleReplaceRemoteAddressbooks()"); + + try { + + HidingCardDavStore remoteAddressbookStore = DavAccountHelper.getHidingCardDavStore(getBaseContext(), account, masterCipher); + + try { + + LocalAddressbookStore localAddressbookStore = new LocalAddressbookStore(getBaseContext(), account); + + for (LocalContactCollection localCollection : localAddressbookStore.getCollections()) { + if (!remoteAddressbookStore.getCollection(localCollection.getPath()).isPresent()) { + Log.d(TAG, "creating remote carddav collection at >> " + localCollection.getPath()); + + Optional displayName = localCollection.getDisplayName(); + if (displayName.isPresent()) + remoteAddressbookStore.addCollection(localCollection.getPath(), displayName.get()); + else + remoteAddressbookStore.addCollection(localCollection.getPath()); + } + } + + setState(STATE_REPLACED_REMOTE_ADDRESSBOOKS); + + } catch (DavException e) { + + if (e.getErrorCode() != DavServletResponse.SC_FORBIDDEN) + handleException(e); + + } catch (GeneralSecurityException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + remoteAddressbookStore.releaseConnections(); + } + + } catch (IOException e) { + handleException(e); + } + } + + private void handleMarkAllComponentsAsNew(AbstractLocalComponentCollection localCollection) + throws RemoteException, OperationApplicationException + { + Log.d(TAG, "handleMarkAllComponentsAsNew() >> " + localCollection.getPath()); + + for (Long componentId : localCollection.getComponentIds()) + localCollection.queueForMigration(componentId); + + localCollection.commitPendingOperations(); + } + + private void handleMarkAllLocalEventsAndContactsAsNew() { + Log.d(TAG, "handleMarkAllLocalEventsAndContactsAsNew()"); + + LocalCalendarStore localCalendarStore = new LocalCalendarStore(getBaseContext(), account.getOsAccount()); + LocalAddressbookStore localAddressbookStore = new LocalAddressbookStore(getBaseContext(), account); + + try { + + for (LocalEventCollection collection : localCalendarStore.getCollections()) { + Log.d(TAG, "marking all components as new in collection >> " + collection.getPath()); + handleMarkAllComponentsAsNew(collection); + } + + for (LocalContactCollection collection : localAddressbookStore.getCollections()) { + Log.d(TAG, "marking all components as new in collection >> " + collection.getPath()); + handleMarkAllComponentsAsNew(collection); + } + + setState(STATE_READY_TO_REPLACE_EVENTS_AND_CONTACTS); + + } catch (OperationApplicationException e) { + handleException(e); + } catch (RemoteException e) { + handleException(e); + } + } + + private void handleReplaceEventsAndContacts() { + Log.d(TAG, "handleReplaceEventsAndContacts()"); + + handleEnableAllSyncAdapters(); + if (!ContentResolver.getMasterSyncAutomatically()) { + handleAskUserToEnableSync(); + return; + } + handleUpdateNotificationUsingState(); + + Long firstRecordedCalendarSync = getTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC); + Optional timeLastCalendarSync = new CalendarsSyncScheduler(getBaseContext()).getTimeLastSync(); + + if (firstRecordedCalendarSync <= 0) { + if (!timeLastCalendarSync.isPresent()) + recordTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC, new Date().getTime()); + else + recordTimeFirstSync(KEY_TIME_FIRST_CALENDAR_SYNC, timeLastCalendarSync.get()); + + new CalendarsSyncScheduler(getBaseContext()).requestSync(); + } + + Long firstRecordedAddressbookSync = getTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC); + Optional timeLastAddressbookSync = new AddressbookSyncScheduler(getBaseContext()).getTimeLastSync(); + + if (firstRecordedAddressbookSync <= 0) { + if (!timeLastAddressbookSync.isPresent()) + recordTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC, new Date().getTime()); + else + recordTimeFirstSync(KEY_TIME_FIRST_ADDRESSBOOK_SYNC, timeLastAddressbookSync.get()); + + new AddressbookSyncScheduler(getBaseContext()).requestSync(); + } + + if (firstRecordedCalendarSync > 0 && firstRecordedAddressbookSync > 0) { + if (timeLastCalendarSync.isPresent() && timeLastAddressbookSync.isPresent()) { + if (timeLastCalendarSync.get() > firstRecordedCalendarSync && + timeLastAddressbookSync.get() > firstRecordedAddressbookSync) + { + setState(STATE_REPLACED_EVENTS_AND_CONTACTS); + } + } + } + } + + private void handleSetMigrationComplete() { + Log.d(TAG, "handleSetMigrationComplete()"); + + try { + + DavKeyStore davKeyStore = DavAccountHelper.getDavKeyStore(getBaseContext(), account); + + try { + + Optional keyCollection = davKeyStore.getCollection(); + + if (keyCollection.isPresent()) { + keyCollection.get().setMigrationComplete(getBaseContext()); + setState(STATE_MIGRATION_COMPLETE); + } + else { + Log.e(TAG, "missing key collection, reverting state to regenerate keys!"); + setState(STATE_SYNCED_WITH_REMOTE); + } + + } catch (InvalidComponentException e) { + handleException(e); + } catch (PropertyParseException e) { + handleException(e); + } catch (DavException e) { + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + davKeyStore.closeHttpConnection(); + } + + } catch (IOException e) { + handleException(e); + } + } + + private void handleStartOrResumeMigration() { + Log.d(TAG, "handleStartOrResumeMigration()"); + + switch (getState(getBaseContext())) { + case STATE_STARTED_MIGRATION: + handleSyncWithRemote(); + if (getState(getBaseContext()) == STATE_STARTED_MIGRATION) + break; + + case STATE_SYNCED_WITH_REMOTE: + handleDeleteKeyCollection(); + if (getState(getBaseContext()) <= STATE_SYNCED_WITH_REMOTE) + break; + + case STATE_DELETED_KEY_COLLECTION: + handleGenerateNewKeys(); + if (getState(getBaseContext()) <= STATE_DELETED_KEY_COLLECTION) + break; + + case STATE_GENERATED_NEW_KEYS: + handleCreateKeyCollection(); + if (getState(getBaseContext()) <= STATE_GENERATED_NEW_KEYS) + break; + + case STATE_REPLACED_KEY_COLLECTION: + handleReplaceKeys(); + if (getState(getBaseContext()) <= STATE_REPLACED_KEY_COLLECTION) + break; + + case STATE_REPLACED_KEYS: + handleDeleteRemoteCalendarsAndAddressbooks(); + if (getState(getBaseContext()) <= STATE_REPLACED_KEYS) + break; + + case STATE_DELETED_REMOTE_CALENDARS_AND_ADDRESSBOOKS: + handleReplaceRemoteCalendars(); + if (getState(getBaseContext()) <= STATE_DELETED_REMOTE_CALENDARS_AND_ADDRESSBOOKS) + break; + + case STATE_REPLACED_REMOTE_CALENDARS: + handleReplaceRemoteAddressbooks(); + if (getState(getBaseContext()) <= STATE_REPLACED_REMOTE_CALENDARS) + break; + + case STATE_REPLACED_REMOTE_ADDRESSBOOKS: + handleMarkAllLocalEventsAndContactsAsNew(); + if (getState(getBaseContext()) <= STATE_REPLACED_REMOTE_ADDRESSBOOKS) + break; + + case STATE_READY_TO_REPLACE_EVENTS_AND_CONTACTS: + handleReplaceEventsAndContacts(); + if (getState(getBaseContext()) <= STATE_READY_TO_REPLACE_EVENTS_AND_CONTACTS) + break; + + case STATE_REPLACED_EVENTS_AND_CONTACTS: + handleSetMigrationComplete(); + if (getState(getBaseContext()) <= STATE_REPLACED_EVENTS_AND_CONTACTS) + break; + + case STATE_MIGRATION_COMPLETE: + handleMigrationComplete(); + break; + + default: + handleStartMigration(); + } + } + + @Override + public void onCreate() { + HandlerThread thread = new HandlerThread("MigrationService", HandlerThread.NORM_PRIORITY); + thread.start(); + + Looper serviceLooper = thread.getLooper(); + + serviceHandler = new ServiceHandler(serviceLooper); + notifyManager = (NotificationManager)getBaseContext().getSystemService(Context.NOTIFICATION_SERVICE); + notificationBuilder = new NotificationCompat.Builder(getBaseContext()); + + try { + + Optional account = DavAccountHelper.getAccount(getBaseContext()); + Optional masterCipher = KeyHelper.getMasterCipher(getBaseContext()); + + if (account.isPresent() && masterCipher.isPresent()) { + this.account = account.get(); + this.masterCipher = masterCipher.get(); + } + else + Log.e(TAG, "ACCOUNT NOT PRESENT xxx 0.O"); + + } catch (IOException e) { + Log.e(TAG, "exception while getting MasterCipher >> " + e); + } + } + + private final class ServiceHandler extends Handler { + + public ServiceHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + Log.d(TAG, "handleMessage()"); + + if (account != null && masterCipher != null) + handleStartOrResumeMigration(); + else + Log.e(TAG, "missing account or master cipher! xxx"); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "onStartCommand()"); + + serviceHandler.sendMessage(serviceHandler.obtainMessage()); + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + Log.d(TAG, "onBind()"); + return null; + } + + private final Runnable kickMigrationRunnable = new Runnable() { + @Override + public void run() { + getBaseContext().startService(new Intent(getBaseContext(), MigrationService.class)); + } + }; + + public void handleStartKickingMigration() { + Log.d(TAG, "handleStartKickingMigration()"); + + intervalTimer = new Timer(); + TimerTask kickMigrationTask = new TimerTask() { + @Override + public void run() { + messageHandler.post(kickMigrationRunnable); + } + }; + + intervalTimer.schedule(kickMigrationTask, 0, KICK_INTERVAL_MILLISECONDS); + } +} diff --git a/flock/src/main/java/org/anhonesteffort/flock/StatusHeaderView.java b/flock/src/main/java/org/anhonesteffort/flock/StatusHeaderView.java index e349089..b941c5c 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/StatusHeaderView.java +++ b/flock/src/main/java/org/anhonesteffort/flock/StatusHeaderView.java @@ -24,6 +24,7 @@ import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; +import android.util.Log; import android.view.LayoutInflater; import android.widget.LinearLayout; import android.widget.TextView; @@ -31,12 +32,17 @@ import com.google.common.base.Optional; import org.anhonesteffort.flock.auth.DavAccount; +import org.anhonesteffort.flock.crypto.InvalidCipherVersionException; import org.anhonesteffort.flock.crypto.KeyHelper; import org.anhonesteffort.flock.registration.RegistrationApi; import org.anhonesteffort.flock.registration.RegistrationApiException; import org.anhonesteffort.flock.sync.AbstractDavSyncAdapter; import org.anhonesteffort.flock.sync.addressbook.AddressbookSyncScheduler; import org.anhonesteffort.flock.sync.calendar.CalendarsSyncScheduler; +import org.anhonesteffort.flock.sync.key.DavKeyCollection; +import org.anhonesteffort.flock.sync.key.DavKeyStore; +import org.anhonesteffort.flock.sync.key.KeySyncScheduler; +import org.anhonesteffort.flock.webdav.InvalidComponentException; import org.anhonesteffort.flock.webdav.PropertyParseException; import org.apache.jackrabbit.webdav.DavException; @@ -61,6 +67,7 @@ public class StatusHeaderView extends LinearLayout { private AsyncTask asyncTaskSubscription; private AsyncTask asyncTaskCard; private AsyncTask asyncTaskMasterPassphrase; + private AsyncTask asyncTaskMigration; private long timeLastSync = -1; private boolean syncInProgress = false; @@ -127,17 +134,20 @@ private void handleUpdateLayout() { final String syncStatusText; final int syncStatusDrawable; - if (timeLastSync == -1) { - if (!ContentResolver.getMasterSyncAutomatically()) { - syncStatusView.setText(getContext().getString(R.string.status_header_status_sync_disabled)); - syncStatusView.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.sad_cloud, 0, 0); - } - else { - syncStatusView.setText(getContext().getString(R.string.status_header_sync_in_progress)); - syncStatusView.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.sync_in_progress, 0, 0); - } + if (!ContentResolver.getMasterSyncAutomatically()) { + syncStatusView.setText(getContext().getString(R.string.status_header_status_sync_disabled)); + timeLastSyncView.setVisibility(GONE); + syncStatusView.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.sad_cloud, 0, 0); + invalidate(); + return; + } + + if (timeLastSync == -1) { + syncStatusView.setText(getContext().getString(R.string.status_header_sync_in_progress)); + syncStatusView.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.sync_in_progress, 0, 0); timeLastSyncView.setVisibility(GONE); + invalidate(); return; } @@ -168,10 +178,6 @@ else if (!DavAccountHelper.getAccountPassword(getContext()).isPresent()) { authNotificationShown = true; } } - else if (!cipherPassphraseIsValid) { - syncStatusText = getContext().getString(R.string.status_header_status_encryption_password_incorrect); - syncStatusDrawable = R.drawable.sad_cloud; - } else if (!subscriptionIsValid) { syncStatusText = getContext().getString(R.string.notification_flock_subscription_expired); syncStatusDrawable = R.drawable.sad_cloud; @@ -184,8 +190,17 @@ else if (!cardIsValid) { syncStatusText = getContext().getString(R.string.status_header_status_auto_renew_error); syncStatusDrawable = R.drawable.sad_cloud; } - else if (!ContentResolver.getMasterSyncAutomatically()) { - syncStatusText = getContext().getString(R.string.status_header_status_sync_disabled); + else if (MigrationHelperBroadcastReceiver.getUiDisabledForMigration(getContext())) { + syncStatusText = getContext().getString(R.string.status_header_status_migration_in_progress); + syncStatusDrawable = R.drawable.migration_in_progress; + + timeLastSyncView.setText(R.string.please_wait_this_will_take_a_few_minutes); + timeLastSyncView.setVisibility(VISIBLE); + + new KeySyncScheduler(getContext()).requestSync(); + } + else if (!cipherPassphraseIsValid) { + syncStatusText = getContext().getString(R.string.status_header_status_encryption_password_incorrect); syncStatusDrawable = R.drawable.sad_cloud; } else if (syncInProgress) { @@ -316,6 +331,8 @@ protected Bundle doInBackground(String... params) { } catch (IOException e) { ErrorToaster.handleBundleError(e, result); + } catch (InvalidCipherVersionException e) { + Log.d(TAG, "caught invalid cipher version exception, likely due to migration.", e); } catch (GeneralSecurityException e) { ErrorToaster.handleBundleError(e, result); } @@ -335,6 +352,58 @@ protected void onPostExecute(Bundle result) { }.execute(); } + private void handleUpdateMigrationInProgress() { + if ((asyncTaskMigration != null && !asyncTaskMigration.isCancelled()) || + !MigrationHelperBroadcastReceiver.getUiDisabledForMigration(getContext()) || + !DavKeyCollection.weStartedMigration(getContext())) + return; + + Log.d(TAG, "handleUpdateMigrationInProgress()"); + + asyncTaskMigration = new AsyncTask() { + + @Override + protected Bundle doInBackground(String... params) { + boolean migrationInProgress = true; + Bundle result = new Bundle(); + + try { + + DavKeyStore davKeyStore = DavAccountHelper.getDavKeyStore(getContext(), account.get()); + Optional keyCollection = davKeyStore.getCollection(); + + if (keyCollection.isPresent()) + migrationInProgress = !keyCollection.get().isMigrationComplete(); + + if (DavKeyCollection.weStartedMigration(getContext())) + MigrationHelperBroadcastReceiver.setUiDisabledForMigration(getContext(), migrationInProgress); + + result.putInt(ErrorToaster.KEY_STATUS_CODE, ErrorToaster.CODE_SUCCESS); + + } catch (InvalidComponentException e) { + ErrorToaster.handleBundleError(e, result); + } catch (PropertyParseException e) { + ErrorToaster.handleBundleError(e, result); + } catch (DavException e) { + ErrorToaster.handleBundleError(e, result); + } catch (IOException e) { + ErrorToaster.handleBundleError(e, result); + } + + return result; + } + + @Override + protected void onPostExecute(Bundle result) { + asyncTaskMigration = null; + + if (result.getInt(ErrorToaster.KEY_STATUS_CODE) != ErrorToaster.CODE_SUCCESS) + ErrorToaster.handleDisplayToastBundledError(getContext(), result); + } + + }.execute(); + } + private final Runnable refreshUiRunnable = new Runnable() { @Override public void run() { @@ -360,6 +429,12 @@ public void run() { handleUpdateCipherPassphraseIsValid(); } }; + private final Runnable refreshMigrationRunnable = new Runnable() { + @Override + public void run() { + handleUpdateMigrationInProgress(); + } + }; public void handleStartPerpetualRefresh() { account = DavAccountHelper.getAccount(getContext()); @@ -389,6 +464,12 @@ public void run() { uiHandler.post(refreshCipherPassphraseRunnable); } }; + TimerTask migrationTask = new TimerTask() { + @Override + public void run() { + uiHandler.post(refreshMigrationRunnable); + } + }; intervalTimer.schedule(uiTask, 0, 2000); @@ -399,6 +480,8 @@ public void run() { } else intervalTimer.schedule(passphraseTask, 0, 10000); + + intervalTimer.schedule(migrationTask, 0, 10000); } } } \ No newline at end of file diff --git a/flock/src/main/java/org/anhonesteffort/flock/UnregisterAccountActivity.java b/flock/src/main/java/org/anhonesteffort/flock/UnregisterAccountActivity.java index 18902bd..0443867 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/UnregisterAccountActivity.java +++ b/flock/src/main/java/org/anhonesteffort/flock/UnregisterAccountActivity.java @@ -23,7 +23,6 @@ import android.accounts.AccountManagerFuture; import android.app.AlertDialog; import android.content.DialogInterface; -import android.content.SharedPreferences; import android.os.AsyncTask; import android.os.Bundle; import android.preference.PreferenceManager; @@ -63,7 +62,7 @@ public class UnregisterAccountActivity extends AccountAndKeyRequiredActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!accountAndKeyAvailable()) + if (!accountAndKeyAvailableAndMigrationComplete()) return; requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); diff --git a/flock/src/main/java/org/anhonesteffort/flock/crypto/InvalidCipherVersionException.java b/flock/src/main/java/org/anhonesteffort/flock/crypto/InvalidCipherVersionException.java new file mode 100644 index 0000000..c30afa7 --- /dev/null +++ b/flock/src/main/java/org/anhonesteffort/flock/crypto/InvalidCipherVersionException.java @@ -0,0 +1,14 @@ +package org.anhonesteffort.flock.crypto; + +import java.security.GeneralSecurityException; + +/** + * rhodey + */ +public class InvalidCipherVersionException extends GeneralSecurityException { + + public InvalidCipherVersionException(String message) { + super(message); + } + +} diff --git a/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyHelper.java b/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyHelper.java index 7659c03..89a54f3 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyHelper.java +++ b/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyHelper.java @@ -57,17 +57,25 @@ public static void generateAndSaveSaltAndKeyMaterial(Context context) KeyStore.saveEncryptedKeyMaterial(context, encryptedKeyMaterial.get()); } - public static Optional getMasterCipher(Context context) throws IOException { - Optional cipherKeyBytes = KeyStore.getCipherKey(context); - Optional macKeyBytes = KeyStore.getMacKey(context); + public static Optional getMasterCipher(Context context) + throws IOException + { + boolean useCipherVersionZero = KeyStore.getUseCipherVersionZero(context); + Optional cipherKeyBytes = KeyStore.getCipherKey(context); + Optional macKeyBytes = KeyStore.getMacKey(context); if (!cipherKeyBytes.isPresent() || !macKeyBytes.isPresent()) return Optional.absent(); SecretKey cipherKey = new SecretKeySpec(cipherKeyBytes.get(), "AES"); - SecretKey macKey = new SecretKeySpec(cipherKeyBytes.get(), "SHA256"); + SecretKey macKey = null; + + if (useCipherVersionZero) + macKey = new SecretKeySpec(cipherKeyBytes.get(), "SHA256"); + else + macKey = new SecretKeySpec(macKeyBytes.get(), "SHA256"); - return Optional.of(new MasterCipher(cipherKey, macKey)); + return Optional.of(new MasterCipher(useCipherVersionZero, cipherKey, macKey)); } public static Optional buildEncodedSalt(Context context) throws IOException { @@ -94,7 +102,7 @@ public static Optional buildEncryptedKeyMaterial(Context context) SecretKey[] masterKeys = KeyUtil.getCipherAndMacKeysForPassphrase(salt.get(), masterPassphrase.get()); SecretKey masterCipherKey = masterKeys[0]; SecretKey masterMacKey = masterKeys[1]; - MasterCipher masterCipher = new MasterCipher(masterCipherKey, masterMacKey); + MasterCipher masterCipher = new MasterCipher(false, masterCipherKey, masterMacKey); byte[] keyMaterial = Util.combine(cipherKey.get(), macKey.get()); byte[] encryptedKeyMaterial = masterCipher.encryptAndEncode(keyMaterial); @@ -112,11 +120,12 @@ public static void importSaltAndEncryptedKeyMaterial(Context context, if (!masterPassphrase.isPresent()) throw new InvalidMacException("Passphrase unavailable."); + boolean useCipherVersionZero = KeyStore.getUseCipherVersionZero(context); byte[] salt = Base64.decode(saltAndEncryptedKeyMaterial[0]); SecretKey[] masterKeys = KeyUtil.getCipherAndMacKeysForPassphrase(salt, masterPassphrase.get()); SecretKey masterCipherKey = masterKeys[0]; SecretKey masterMacKey = masterKeys[1]; - MasterCipher masterCipher = new MasterCipher(masterCipherKey, masterMacKey); + MasterCipher masterCipher = new MasterCipher(useCipherVersionZero, masterCipherKey, masterMacKey); byte[] plaintextKeyMaterial = masterCipher.decodeAndDecrypt(saltAndEncryptedKeyMaterial[1].getBytes()); boolean saltLengthValid = salt.length == KeyUtil.SALT_LENGTH_BYTES; @@ -155,7 +164,7 @@ public static boolean masterPassphraseIsValid(Context context) SecretKey[] masterKeys = KeyUtil.getCipherAndMacKeysForPassphrase(salt.get(), masterPassphrase.get()); SecretKey masterCipherKey = masterKeys[0]; SecretKey masterMacKey = masterKeys[1]; - MasterCipher masterCipher = new MasterCipher(masterCipherKey, masterMacKey); + MasterCipher masterCipher = new MasterCipher(false, masterCipherKey, masterMacKey); try { diff --git a/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyStore.java b/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyStore.java index 93726ba..57e67ea 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyStore.java +++ b/flock/src/main/java/org/anhonesteffort/flock/crypto/KeyStore.java @@ -35,12 +35,23 @@ public class KeyStore { private static final String TAG = "org.anhonesteffort.flock.crypto.KeyStore"; - private static final String PREFERENCES_NAME = "org.anhonesteffort.flock.crypto.KeyStore"; - private static final String KEY_MASTER_PASSPHRASE = "KEY_OLD_MASTER_PASSPHRASE"; - private static final String KEY_CIPHER_KEY = "KEY_CIPHER_KEY"; - private static final String KEY_MAC_KEY = "KEY_MAC_KEY"; - private static final String KEY_KEY_MATERIAL_SALT = "KEY_KEY_MATERIAL_SALT"; - private static final String KEY_ENCRYPTED_KEY_MATERIAL = "KEY_ENCRYPTED_KEY_MATERIAL"; + private static final String PREFERENCES_NAME = "org.anhonesteffort.flock.crypto.KeyStore"; + private static final String KEY_USE_CIPHER_VERSION_ZERO = "KEY_USE_CIPHER_VERSION_ZERO"; + private static final String KEY_MASTER_PASSPHRASE = "KEY_OLD_MASTER_PASSPHRASE"; + private static final String KEY_CIPHER_KEY = "KEY_CIPHER_KEY"; + private static final String KEY_MAC_KEY = "KEY_MAC_KEY"; + private static final String KEY_KEY_MATERIAL_SALT = "KEY_KEY_MATERIAL_SALT"; + private static final String KEY_ENCRYPTED_KEY_MATERIAL = "KEY_ENCRYPTED_KEY_MATERIAL"; + + protected static boolean getUseCipherVersionZero(Context context) { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); + return settings.getBoolean(KEY_USE_CIPHER_VERSION_ZERO, false); + } + + public static void setUseCipherVersionZero(Context context, boolean useCipherVersionZero) { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); + settings.edit().putBoolean(KEY_USE_CIPHER_VERSION_ZERO, useCipherVersionZero).commit(); + } protected static void saveCipherKey(Context context, byte[] cipherKey) { Log.d(TAG, "SAVING CIPHER KEY MATERIAL..."); diff --git a/flock/src/main/java/org/anhonesteffort/flock/crypto/MasterCipher.java b/flock/src/main/java/org/anhonesteffort/flock/crypto/MasterCipher.java index 6808fa0..b3eb846 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/crypto/MasterCipher.java +++ b/flock/src/main/java/org/anhonesteffort/flock/crypto/MasterCipher.java @@ -36,15 +36,18 @@ */ public class MasterCipher { - protected static final int MAC_LENGTH_BYTES = 32; - protected static final int IV_LENGTH_BYTES = 16; + public static final byte CURRENT_CIPHER_VERSION = 0x01; + protected static final int MAC_LENGTH_BYTES = 32; + protected static final int IV_LENGTH_BYTES = 16; + private final boolean useCipherVersionZero; private final SecretKey cipherKey; private final SecretKey macKey; - protected MasterCipher(SecretKey cipherKey, SecretKey macKey) { - this.cipherKey = cipherKey; - this.macKey = macKey; + protected MasterCipher(boolean useCipherVersionZero, SecretKey cipherKey, SecretKey macKey) { + this.useCipherVersionZero = useCipherVersionZero; + this.cipherKey = cipherKey; + this.macKey = macKey; } public byte[] encryptAndEncode(byte[] data) @@ -58,9 +61,15 @@ public byte[] encryptAndEncode(byte[] data) byte[] iv = encryptingCipher.getIV(); byte[] ciphertext = encryptingCipher.doFinal(data); - byte[] mac = hmac.doFinal(Util.combine(iv, ciphertext)); - return Base64.encodeBytesToBytes(Util.combine(iv, ciphertext, mac)); + if (useCipherVersionZero) { + byte[] mac = hmac.doFinal(Util.combine(iv, ciphertext)); + return Base64.encodeBytesToBytes(Util.combine(iv, ciphertext, mac)); + } + else { + byte[] mac = hmac.doFinal(Util.combine(new byte[] {CURRENT_CIPHER_VERSION}, iv, ciphertext)); + return Base64.encodeBytesToBytes(Util.combine(new byte[] {CURRENT_CIPHER_VERSION}, iv, ciphertext, mac)); + } } public String encryptAndEncode(String data) @@ -69,7 +78,7 @@ public String encryptAndEncode(String data) return new String(encryptAndEncode(data.getBytes())); } - public byte[] decodeAndDecrypt(byte[] encodedIvCiphertextAndMac) + private byte[] decodeAndDecryptCipherVersionZero(byte[] encodedIvCiphertextAndMac) throws InvalidMacException, IOException, GeneralSecurityException { byte[] ivCiphertextAndMac = Base64.decode(encodedIvCiphertextAndMac); @@ -86,9 +95,9 @@ public byte[] decodeAndDecrypt(byte[] encodedIvCiphertextAndMac) Cipher decryptingCipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); IvParameterSpec ivSpec = new IvParameterSpec(iv); - decryptingCipher.init(Cipher.DECRYPT_MODE, cipherKey, ivSpec); + Mac hmac = Mac.getInstance("HmacSHA256"); - Mac hmac = Mac.getInstance("HmacSHA256"); + decryptingCipher.init(Cipher.DECRYPT_MODE, cipherKey, ivSpec); hmac.init(macKey); verifyMac(hmac, Util.combine(iv, ciphertext), mac); @@ -96,6 +105,40 @@ public byte[] decodeAndDecrypt(byte[] encodedIvCiphertextAndMac) return decryptingCipher.doFinal(ciphertext); } + public byte[] decodeAndDecrypt(byte[] encodedVersionIvCiphertextAndMac) + throws InvalidMacException, IOException, GeneralSecurityException + { + if (useCipherVersionZero) + return decodeAndDecryptCipherVersionZero(encodedVersionIvCiphertextAndMac); + + byte[] versionIvCiphertextAndMac = Base64.decode(encodedVersionIvCiphertextAndMac); + if (versionIvCiphertextAndMac.length <= (1 + IV_LENGTH_BYTES + MAC_LENGTH_BYTES)) + throw new GeneralSecurityException("invalid length on decoded cipherVersion, iv, ciphertext and mac"); + + byte version = versionIvCiphertextAndMac[0]; + if (version != CURRENT_CIPHER_VERSION) + throw new InvalidCipherVersionException("invalid cipher cipherVersion >> " + version); + + byte[] iv = Arrays.copyOfRange(versionIvCiphertextAndMac, 1, 1 + IV_LENGTH_BYTES); + byte[] ciphertext = Arrays.copyOfRange(versionIvCiphertextAndMac, + 1 + IV_LENGTH_BYTES, + versionIvCiphertextAndMac.length - MAC_LENGTH_BYTES); + byte[] mac = Arrays.copyOfRange(versionIvCiphertextAndMac, + versionIvCiphertextAndMac.length - MAC_LENGTH_BYTES, + versionIvCiphertextAndMac.length); + + Cipher decryptingCipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + Mac hmac = Mac.getInstance("HmacSHA256"); + + decryptingCipher.init(Cipher.DECRYPT_MODE, cipherKey, ivSpec); + hmac.init(macKey); + + verifyMac(hmac, Util.combine(new byte[]{version}, iv, ciphertext), mac); + + return decryptingCipher.doFinal(ciphertext); + } + public String decodeAndDecrypt(String data) throws InvalidMacException, IOException, GeneralSecurityException { @@ -110,5 +153,4 @@ protected static void verifyMac(Mac hmac, byte[] theirData, byte[] theirMac) if (!MessageDigest.isEqual(theirMac, ourMac)) throw new InvalidMacException("INVALID MAC"); } - -} +} \ No newline at end of file diff --git a/flock/src/main/java/org/anhonesteffort/flock/registration/OwsRegistration.java b/flock/src/main/java/org/anhonesteffort/flock/registration/OwsRegistration.java index cffee72..1316ea9 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/registration/OwsRegistration.java +++ b/flock/src/main/java/org/anhonesteffort/flock/registration/OwsRegistration.java @@ -48,6 +48,7 @@ public class OwsRegistration { protected static final String PRICING_CONTROLLER = "pricing"; protected static final String PARAM_ACCOUNT_ID = "id"; + protected static final String PARAM_ACCOUNT_VERSION = "version"; protected static final String PARAM_ACCOUNT_PASSWORD = "password"; protected static final String PARAM_STRIPE_CARD_TOKEN = "stripe_card_token"; protected static final String PARAM_AUTO_RENEW = "auto_renew"; diff --git a/flock/src/main/java/org/anhonesteffort/flock/registration/RegistrationApi.java b/flock/src/main/java/org/anhonesteffort/flock/registration/RegistrationApi.java index cc4c0ea..3e06cc0 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/registration/RegistrationApi.java +++ b/flock/src/main/java/org/anhonesteffort/flock/registration/RegistrationApi.java @@ -35,7 +35,6 @@ import org.anhonesteffort.flock.auth.DavAccount; import org.anhonesteffort.flock.registration.model.AugmentedFlockAccount; import org.anhonesteffort.flock.registration.model.FlockCardInformation; -import org.anhonesteffort.flock.sync.OwsWebDav; import org.anhonesteffort.flock.util.Base64; import org.apache.http.HttpResponse; import org.apache.http.NameValuePair; @@ -202,6 +201,7 @@ public AugmentedFlockAccount createAccount(DavAccount account) List nameValuePairs = new ArrayList(1); nameValuePairs.add(new BasicNameValuePair(OwsRegistration.PARAM_ACCOUNT_ID, account.getUserId())); + nameValuePairs.add(new BasicNameValuePair(OwsRegistration.PARAM_ACCOUNT_VERSION, Integer.toString(2))); nameValuePairs.add(new BasicNameValuePair(OwsRegistration.PARAM_ACCOUNT_PASSWORD, account.getAuthToken())); String HREF = OwsRegistration.getHrefWithParameters(OwsRegistration.HREF_ACCOUNT_COLLECTION, @@ -289,6 +289,23 @@ public Optional getCard(DavAccount account) return Optional.of(buildFlockCardInformation(response)); } + public void setAccountVersion(DavAccount account, Integer version) + throws RegistrationApiException, IOException + { + Log.d(TAG, "setAccountVersion()"); + + List nameValuePairs = new ArrayList(1); + nameValuePairs.add(new BasicNameValuePair(OwsRegistration.PARAM_ACCOUNT_VERSION, version.toString())); + + String HREF = OwsRegistration.getHrefWithParameters(OwsRegistration.getHrefForAccount(account.getUserId()), nameValuePairs); + HttpPut httpPut = new HttpPut(HREF); + DefaultHttpClient httpClient = getClient(context); + authorizeRequest(httpPut, account); + + HttpResponse response = httpClient.execute(httpPut); + throwExceptionIfNotOK(response); + } + public void setAccountPassword(DavAccount account, String newPassword) throws RegistrationApiException, IOException { diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractDavSyncAdapter.java b/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractDavSyncAdapter.java index 80497a0..65cd724 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractDavSyncAdapter.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractDavSyncAdapter.java @@ -54,6 +54,7 @@ import java.io.IOException; import java.security.GeneralSecurityException; +import java.util.Date; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -138,9 +139,7 @@ public AbstractDavSyncAdapter(Context context) { super(context, true); } - protected abstract String getAuthority(); - - protected abstract void setTimeLastSync(); + protected abstract AbstractSyncScheduler getSyncScheduler(); protected abstract void handlePreSyncOperations(DavAccount account, MasterCipher masterCipher, @@ -169,6 +168,11 @@ public void onPerformSync(Account account, { Log.d(TAG, "performing sync for authority >> " + authority); + if (!getSyncScheduler().getIsSyncEnabled(account)) { + Log.w(TAG, "sync disabled for authority " + authority + ", not gonna sync"); + return; + } + Optional davAccount = DavAccountHelper.getAccount(getContext()); if (!davAccount.isPresent()) { Log.d(TAG, "dav account is missing"); @@ -185,6 +189,7 @@ public void onPerformSync(Account account, if (!masterCipher.isPresent()) { Log.d(TAG, "master cipher is missing"); syncResult.stats.numAuthExceptions++; + showNotifications(syncResult); Log.d(TAG, "completed sync for authority >> " + authority); return ; @@ -209,9 +214,7 @@ public void onPerformSync(Account account, } handlePostSyncOperations(davAccount.get(), masterCipher.get(), provider); - setTimeLastSync(); - - Log.d(TAG, "completed sync for authority >> " + authority); + getSyncScheduler().setTimeLastSync(new Date().getTime()); } catch (PropertyParseException e) { handleException(getContext(), e, syncResult); @@ -228,6 +231,9 @@ public void onPerformSync(Account account, } catch (InterruptedException e) { handleException(getContext(), e, syncResult); } + + Log.d(TAG, "completed sync for authority >> " + authority); + showNotifications(syncResult); } public static void disableAuthNotificationsForRunningAdapters(Context context, Account account) { @@ -255,13 +261,13 @@ public static void disableAuthNotificationsForRunningAdapters(Context context, A private boolean isAuthNotificationDisabled() { SharedPreferences settings = getContext().getSharedPreferences(PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); - if (settings.getBoolean(KEY_VOID_AUTH_NOTIFICATIONS + getAuthority(), false)) { - settings.edit().putBoolean(KEY_VOID_AUTH_NOTIFICATIONS + getAuthority(), false).commit(); - Log.e(TAG, "auth notification is disabled for " + getAuthority()); + if (settings.getBoolean(KEY_VOID_AUTH_NOTIFICATIONS + getSyncScheduler().getAuthority(), false)) { + settings.edit().putBoolean(KEY_VOID_AUTH_NOTIFICATIONS + getSyncScheduler().getAuthority(), false).commit(); + Log.e(TAG, "auth notification is disabled for " + getSyncScheduler().getAuthority()); return true; } - Log.e(TAG, "auth notification is not disabled for " + getAuthority()); + Log.e(TAG, "auth notification is not disabled for " + getSyncScheduler().getAuthority()); return false; } diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractLocalComponentCollection.java b/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractLocalComponentCollection.java index 5078ec1..e1a5302 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractLocalComponentCollection.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractLocalComponentCollection.java @@ -27,7 +27,6 @@ import android.database.Cursor; import android.net.Uri; import android.os.RemoteException; -import android.provider.ContactsContract; import android.util.Log; import android.util.Pair; @@ -92,11 +91,13 @@ public Long getLocalId() { protected abstract String getColumnNameDirty(); protected abstract String getColumnNameDeleted(); + protected abstract String getColumnNameQueuedForMigration(); public List getNewComponentIds() throws RemoteException { final String[] PROJECTION = new String[]{getColumnNameComponentLocalId(), getColumnNameComponentUid()}; - final String SELECTION = getColumnNameComponentUid() + " IS NULL AND " + - getColumnNameCollectionLocalId() + "=" + localId; + final String SELECTION = "(" + getColumnNameComponentUid() + " IS NULL OR " + + getColumnNameQueuedForMigration() + "=1) AND " + + getColumnNameCollectionLocalId() + "=" + localId; Cursor cursor = client.query(getUriForComponents(), PROJECTION, SELECTION, null, null); List newIds = new LinkedList(); @@ -293,7 +294,9 @@ public void cleanComponent(Long localId) { pendingOperations.add(ContentProviderOperation .newUpdate(ContentUris.withAppendedId(getUriForComponents(), localId)) - .withValue(getColumnNameDirty(), 0).build()); + .withValue(getColumnNameDirty(), 0) + .withValue(getColumnNameQueuedForMigration(), 0) + .build()); } public void dirtyComponent(Long localId) { @@ -313,6 +316,16 @@ public void setUidToNull(Long localId) { .build()); } + public void queueForMigration(Long localId) + throws RemoteException + { + pendingOperations.add(ContentProviderOperation + .newUpdate(ContentUris.withAppendedId(getUriForComponents(), localId)) + .withValue(getColumnNameQueuedForMigration(), 1) + .withYieldAllowed(false) + .build()); + } + public void commitPendingOperations() throws OperationApplicationException, RemoteException { Log.d(TAG, "commitPendingOperations()"); diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractSyncScheduler.java b/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractSyncScheduler.java index d024155..078d134 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractSyncScheduler.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/AbstractSyncScheduler.java @@ -23,6 +23,7 @@ import android.content.ContentResolver; import android.content.Context; import android.content.SharedPreferences; +import android.content.SyncInfo; import android.database.ContentObserver; import android.net.Uri; import android.os.Bundle; @@ -33,6 +34,7 @@ import org.anhonesteffort.flock.DavAccountHelper; import org.anhonesteffort.flock.PreferencesActivity; import org.anhonesteffort.flock.auth.DavAccount; +import org.anhonesteffort.flock.sync.key.KeySyncScheduler; /** * Programmer: rhodey @@ -54,13 +56,35 @@ public AbstractSyncScheduler(Context context) { protected abstract String getAuthority(); protected abstract Uri getUri(); + public void registerSelfForBroadcasts() { + context.getContentResolver().unregisterContentObserver(this); + context.getContentResolver().registerContentObserver(getUri(), false, this); + } + public boolean syncInProgress(Account account) { + for (SyncInfo syncInfo : ContentResolver.getCurrentSyncs()) { + if (syncInfo.account.type.equals(account.type) && syncInfo.authority.equals(getAuthority())) + return true; + } + return ContentResolver.isSyncActive(account, getAuthority()); } - public void registerSelfForBroadcasts() { - context.getContentResolver().unregisterContentObserver(this); - context.getContentResolver().registerContentObserver(getUri(), false, this); + public boolean getIsSyncEnabled(Account account) { + return ContentResolver.getIsSyncable(account, getAuthority()) > 0; + } + + public void setSyncEnabled(Account account, boolean enabled) { + if (!enabled && getAuthority().equals(KeySyncScheduler.CONTENT_AUTHORITY)) { + Log.w(getTAG(), "cannot disable key sync service, not changing sync setting."); + return; + } + + ContentResolver.setIsSyncable(account, getAuthority(), enabled ? 1 : 0); + } + + public void cancelPendingSyncs(Account account) { + ContentResolver.cancelSync(account, getAuthority()); } public void requestSync() { @@ -95,6 +119,11 @@ public void setSyncInterval(int minutes) { } } + public void restoreSyncIntervalFromSharedPreferences() { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + setSyncInterval(settings.getInt(PreferencesActivity.KEY_PREF_SYNC_INTERVAL_MINUTES, 60)); + } + public void setTimeLastSync(Long timeMilliseconds) { SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_MULTI_PROCESS); settings.edit().putLong(KEY_TIME_LAST_SYNC + getAuthority(), timeMilliseconds).commit(); diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/AndroidDavClient.java b/flock/src/main/java/org/anhonesteffort/flock/sync/AndroidDavClient.java index e1ae2f4..d30b9d4 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/AndroidDavClient.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/AndroidDavClient.java @@ -48,6 +48,13 @@ private void fixClientTrust() { Protocol.registerProtocol("https", appHttps); } + private void handleVersionUsername() { + if (DavAccountHelper.isUsingOurServers(context)) { + davUsername = davUsername.concat("@V2"); + initClient(); + } + } + public AndroidDavClient(Context context, URL davHost, String username, @@ -58,5 +65,7 @@ public AndroidDavClient(Context context, if (davHost.getProtocol().equals("https")) fixClientTrust(); + + handleVersionUsername(); } } diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncService.java b/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncService.java index 613a907..d897458 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncService.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncService.java @@ -74,13 +74,8 @@ public ContactsSyncAdapter(Context context) { } @Override - protected String getAuthority() { - return AddressbookSyncScheduler.CONTENT_AUTHORITY; - } - - @Override - protected void setTimeLastSync() { - new AddressbookSyncScheduler(getContext()).setTimeLastSync(new Date().getTime()); + protected AddressbookSyncScheduler getSyncScheduler() { + return new AddressbookSyncScheduler(getContext()); } @Override diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncWorker.java b/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncWorker.java index f3443c0..318505d 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncWorker.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/AddressbookSyncWorker.java @@ -20,6 +20,7 @@ package org.anhonesteffort.flock.sync.addressbook; import android.content.Context; +import android.content.Intent; import android.content.SyncResult; import ezvcard.VCard; @@ -35,6 +36,8 @@ public class AddressbookSyncWorker extends AbstractDavSyncWorker { private static final String TAG = "org.anhonesteffort.flock.sync.addressbook.AddressbookSyncWorker"; + public static final String ACTION_PUSH_CREATED_CONTACTS = "org.anhonesteffort.flock.sync.addressbook.AddressbookSyncWorker.ACTION_PUSH_CREATED_CONTACTS"; + protected AddressbookSyncWorker(Context context, SyncResult result, LocalContactCollection localCollection, @@ -58,4 +61,12 @@ protected void prePushLocallyCreatedComponent(VCard component) { } + @Override + protected void pushLocallyCreatedComponents(SyncResult result) { + super.pushLocallyCreatedComponents(result); + + Intent intent = new Intent(); + intent.setAction(ACTION_PUSH_CREATED_CONTACTS); + context.sendBroadcast(intent); + } } diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/LocalContactCollection.java b/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/LocalContactCollection.java index 573fb0a..c2eb2a4 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/LocalContactCollection.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/addressbook/LocalContactCollection.java @@ -135,6 +135,11 @@ protected String getColumnNameDeleted() { return ContactsContract.RawContacts.DELETED; } + @Override + protected String getColumnNameQueuedForMigration() { + return ContactsContract.RawContacts.SYNC2; + } + @Override public Optional getDisplayName() { SharedPreferences preferences = context.getSharedPreferences(PREFERENCES_NAME, diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/CalendarsSyncService.java b/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/CalendarsSyncService.java index eb008c7..8d38e59 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/CalendarsSyncService.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/CalendarsSyncService.java @@ -82,13 +82,8 @@ public CalendarsSyncAdapter(Context context) { } @Override - protected String getAuthority() { - return CalendarsSyncScheduler.CONTENT_AUTHORITY; - } - - @Override - protected void setTimeLastSync() { - new CalendarsSyncScheduler(getContext()).setTimeLastSync(new Date().getTime()); + protected CalendarsSyncScheduler getSyncScheduler() { + return new CalendarsSyncScheduler(getContext()); } @Override diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/LocalEventCollection.java b/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/LocalEventCollection.java index 0a250cc..63e5fa5 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/LocalEventCollection.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/calendar/LocalEventCollection.java @@ -149,12 +149,18 @@ protected String getColumnNameDeleted() { return CalendarContract.Events.DELETED; } + @Override + protected String getColumnNameQueuedForMigration() { + return CalendarContract.Events.SYNC_DATA3; + } + @Override public List getNewComponentIds() throws RemoteException { final String[] PROJECTION = new String[]{getColumnNameComponentLocalId()}; final String SELECTION = "(" + getColumnNameComponentUid() + " IS NULL OR " + - CalendarContract.Events.SYNC_DATA2 + " > 0) AND " + - getColumnNameCollectionLocalId() + "=" + localId; + CalendarContract.Events.SYNC_DATA2 + " > 0 OR " + + getColumnNameQueuedForMigration() + "=1) AND " + + getColumnNameCollectionLocalId() + "=" + localId; Cursor cursor = client.query(getUriForComponents(), PROJECTION, SELECTION, null, null); List newIds = new LinkedList(); diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/key/DavKeyCollection.java b/flock/src/main/java/org/anhonesteffort/flock/sync/key/DavKeyCollection.java index 1ab698c..9389ec7 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/key/DavKeyCollection.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/key/DavKeyCollection.java @@ -1,10 +1,19 @@ package org.anhonesteffort.flock.sync.key; +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; import android.util.Log; import com.google.common.base.Optional; import net.fortuna.ical4j.model.Calendar; +import net.fortuna.ical4j.model.Date; +import net.fortuna.ical4j.model.component.VEvent; +import net.fortuna.ical4j.model.property.CalScale; +import net.fortuna.ical4j.model.property.Description; +import net.fortuna.ical4j.model.property.Uid; +import net.fortuna.ical4j.model.property.Version; import org.anhonesteffort.flock.sync.OwsWebDav; import org.anhonesteffort.flock.webdav.AbstractDavComponentCollection; @@ -15,6 +24,7 @@ import org.anhonesteffort.flock.webdav.caldav.CalDavConstants; import org.anhonesteffort.flock.webdav.caldav.CalDavStore; import org.apache.jackrabbit.webdav.DavException; +import org.apache.jackrabbit.webdav.DavServletResponse; import org.apache.jackrabbit.webdav.MultiStatusResponse; import org.apache.jackrabbit.webdav.property.DavPropertyName; import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; @@ -33,6 +43,11 @@ public class DavKeyCollection extends AbstractDavComponentCollection { private static final String TAG = "org.anhonesteffort.flock.sync.key.DavKeyCollection"; + private static final String KEY_WE_STARTED_MIGRATION = "KEY_WE_STARTED_MIGRATION"; + + private static final String UID_MIGRATION_COMPLETE = "migration-complete"; + private static final String UID_MIGRATION_STARTED = "migration00-started"; + protected static final String PROPERTY_NAME_KEY_MATERIAL_SALT = "X-KEY-MATERIAL-SALT"; protected static final String PROPERTY_NAME_ENCRYPTED_KEY_MATERIAL = "X-ENCRYPTED-KEY-MATERIAL"; @@ -120,6 +135,92 @@ public void setEncryptedKeyMaterial(String encryptedKeyMaterial) patchProperties(updateProperties, new DavPropertyNameSet()); } + public static boolean weStartedMigration(Context context) { + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); + return settings.getBoolean(KEY_WE_STARTED_MIGRATION, false); + } + + private static void setWeStartedMigration(Context context, boolean weStarted) { + Log.w(TAG, "setWeStartedMigration() >> " + weStarted); + + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); + settings.edit().putBoolean(KEY_WE_STARTED_MIGRATION, weStarted).commit(); + } + + private static Calendar getMockCalendarForUid(String uid) { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.set(java.util.Calendar.MONTH, java.util.Calendar.JULY); + calendar.set(java.util.Calendar.DAY_OF_MONTH, 24); + + net.fortuna.ical4j.model.Calendar putCalendar = new net.fortuna.ical4j.model.Calendar(); + putCalendar.getProperties().add(Version.VERSION_2_0); + putCalendar.getProperties().add(CalScale.GREGORIAN); + + Date putStartDate = new Date(calendar.getTime()); + Date putEndDate = new Date(putStartDate.getTime() + (1000 * 60 * 60 * 24)); + + VEvent vEventPut = new VEvent(putStartDate, putEndDate, "Mock"); + vEventPut.getProperties().add(new Uid(uid)); + vEventPut.getProperties().add(new Description("Mock")); + putCalendar.getComponents().add(vEventPut); + + return putCalendar; + } + + public boolean setMigrationStarted(Context context) + throws InvalidComponentException, DavException, IOException + { + Log.w(TAG, "setMigrationStarted()"); + + CalDavCollection calDavCollection = new CalDavCollection((CalDavStore) getStore(), getPath()); + + try { + + calDavCollection.addComponent(getMockCalendarForUid(UID_MIGRATION_STARTED)); + setWeStartedMigration(context, true); + + } catch (DavException e) { + if (e.getErrorCode() == DavServletResponse.SC_PRECONDITION_FAILED) + return false; + + throw e; + } + + return true; + } + + public boolean isMigrationStarted() + throws InvalidComponentException, DavException, IOException + { + CalDavCollection calDavCollection = new CalDavCollection((CalDavStore) getStore(), getPath()); + return calDavCollection.getComponent(UID_MIGRATION_STARTED).isPresent(); + } + + public boolean isMigrationComplete() + throws InvalidComponentException, DavException, IOException + { + CalDavCollection calDavCollection = new CalDavCollection((CalDavStore) getStore(), getPath()); + return calDavCollection.getComponent(UID_MIGRATION_COMPLETE).isPresent(); + } + + public void setMigrationComplete(Context context) + throws InvalidComponentException, DavException, IOException + { + Log.w(TAG, "setMigrationComplete()"); + + CalDavCollection calDavCollection = new CalDavCollection((CalDavStore) getStore(), getPath()); + + try { + + calDavCollection.addComponent(getMockCalendarForUid(UID_MIGRATION_COMPLETE)); + setWeStartedMigration(context, false); + + } catch (DavException e) { + if (e.getErrorCode() != DavServletResponse.SC_PRECONDITION_FAILED) + throw e; + } + } + @Override protected List> getComponentsFromMultiStatus(MultiStatusResponse[] msResponses) throws InvalidComponentException diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncService.java b/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncService.java index 8d813dd..d762aec 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncService.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncService.java @@ -78,13 +78,8 @@ public KeySyncAdapter(Context context) { } @Override - protected String getAuthority() { - return KeySyncScheduler.CONTENT_AUTHORITY; - } - - @Override - protected void setTimeLastSync() { - new KeySyncScheduler(getContext()).setTimeLastSync(new Date().getTime()); + protected KeySyncScheduler getSyncScheduler() { + return new KeySyncScheduler(getContext()); } @Override diff --git a/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncWorker.java b/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncWorker.java index e082628..c978ece 100644 --- a/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncWorker.java +++ b/flock/src/main/java/org/anhonesteffort/flock/sync/key/KeySyncWorker.java @@ -20,17 +20,21 @@ package org.anhonesteffort.flock.sync.key; import android.content.Context; +import android.content.Intent; import android.content.SyncResult; import android.util.Log; import com.google.common.base.Optional; import org.anhonesteffort.flock.DavAccountHelper; +import org.anhonesteffort.flock.MigrationHelperBroadcastReceiver; +import org.anhonesteffort.flock.MigrationService; import org.anhonesteffort.flock.auth.DavAccount; import org.anhonesteffort.flock.crypto.InvalidMacException; import org.anhonesteffort.flock.crypto.KeyHelper; import org.anhonesteffort.flock.crypto.KeyStore; import org.anhonesteffort.flock.sync.AbstractDavSyncAdapter; -import org.anhonesteffort.flock.sync.calendar.HidingCalDavCollection; +import org.anhonesteffort.flock.sync.addressbook.AddressbookSyncScheduler; +import org.anhonesteffort.flock.sync.calendar.CalendarsSyncScheduler; import org.anhonesteffort.flock.webdav.InvalidComponentException; import org.anhonesteffort.flock.webdav.PropertyParseException; import org.apache.jackrabbit.webdav.DavException; @@ -45,12 +49,136 @@ public class KeySyncWorker { private static final String TAG = "org.anhonesteffort.flock.sync.key.KeySyncWorker"; + public static final String ACTION_KEY_MATERIAL_IMPORTED = "org.anhonesteffort.flock.sync.key.ACTION_KEY_MATERIAL_IMPORTED"; + private final Context context; private final DavAccount account; public KeySyncWorker(Context context, DavAccount account) { this.context = context; this.account = account; + + Thread.currentThread().setContextClassLoader(context.getClassLoader()); + } + + private void handleDisableCalendarAndContactSync() { + Log.w(TAG, "handleDisableCalendarAndContactSync()"); + + new CalendarsSyncScheduler(context).setSyncEnabled(account.getOsAccount(), false); + new AddressbookSyncScheduler(context).setSyncEnabled(account.getOsAccount(), false); + + new CalendarsSyncScheduler(context).cancelPendingSyncs(account.getOsAccount()); + new AddressbookSyncScheduler(context).cancelPendingSyncs(account.getOsAccount()); + } + + private void handleEnableCalendarAndContactSync() { + Log.w(TAG, "handleEnableCalendarAndContactSync()"); + + new CalendarsSyncScheduler(context).setSyncEnabled(account.getOsAccount(), true); + new AddressbookSyncScheduler(context).setSyncEnabled(account.getOsAccount(), true); + } + + private void handleMigrationComplete(SyncResult result, + String localKeyMaterialSalt, + String localEncryptedKeyMaterial, + DavKeyCollection keyCollection) + { + Log.w(TAG, "handleMigrationComplete()"); + + new KeySyncScheduler(context).restoreSyncIntervalFromSharedPreferences(); + + try { + + if (!KeyHelper.masterPassphraseIsValid(context) && + !DavAccountHelper.isUsingOurServers(context)) + { + KeySyncService.showCipherPassphraseInvalidNotification(context); + return; + } + + } catch (GeneralSecurityException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } catch (IOException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } + + try { + + Optional remoteKeyMaterialSalt = keyCollection.getKeyMaterialSalt(); + Optional remoteEncryptedKeyMaterial = keyCollection.getEncryptedKeyMaterial(); + + if (!remoteKeyMaterialSalt.isPresent()) + keyCollection.setKeyMaterialSalt(localKeyMaterialSalt); + + if (!remoteEncryptedKeyMaterial.isPresent()) + keyCollection.setEncryptedKeyMaterial(localEncryptedKeyMaterial); + + else if (remoteKeyMaterialSalt.isPresent() && + !remoteEncryptedKeyMaterial.get().equals(localEncryptedKeyMaterial)) + { + try { + + KeyHelper.importSaltAndEncryptedKeyMaterial(context, new String[]{ + remoteKeyMaterialSalt.get(), + remoteEncryptedKeyMaterial.get() + }); + + Intent intent = new Intent(); + intent.setPackage(MigrationHelperBroadcastReceiver.class.getPackage().getName()); + intent.setAction(ACTION_KEY_MATERIAL_IMPORTED); + context.sendBroadcast(intent); + + } catch (InvalidMacException e) { + Log.w(TAG, "caught invalid mac exception while importing remote key material, " + + "assuming password change for non-flock sync user."); + KeyStore.saveEncryptedKeyMaterial(context, remoteEncryptedKeyMaterial.get()); + KeySyncService.showCipherPassphraseInvalidNotification(context); + } + } + + } catch (PropertyParseException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } catch (DavException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } catch (IOException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } catch (GeneralSecurityException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } + + handleEnableCalendarAndContactSync(); + } + + private void handleStartOrResumeMigrationService() { + Log.w(TAG, "handleStartOrResumeMigrationService()"); + context.startService(new Intent(context, MigrationService.class)); + } + + private void handleMigrationInProgress(SyncResult result, + DavKeyCollection keyCollection) + { + Log.w(TAG, "handleMigrationInProgress()"); + new KeySyncScheduler(context).setSyncInterval(1); + + try { + + if (!DavKeyCollection.weStartedMigration(context)) { + boolean preconditionSucceeded = keyCollection.setMigrationStarted(context); + if (!preconditionSucceeded) + handleDisableCalendarAndContactSync(); + else + handleStartOrResumeMigrationService(); + } + else + handleStartOrResumeMigrationService(); + + } catch (InvalidComponentException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } catch (DavException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } catch (IOException e) { + AbstractDavSyncAdapter.handleException(context, e, result); + } } public void run(SyncResult result) { @@ -76,60 +204,13 @@ public void run(SyncResult result) { return; } - try { - - if (!KeyHelper.masterPassphraseIsValid(context) && - !DavAccountHelper.isUsingOurServers(context)) - { - KeySyncService.showCipherPassphraseInvalidNotification(context); - return; - } - - } catch (GeneralSecurityException e) { - AbstractDavSyncAdapter.handleException(context, e, result); - } catch (IOException e) { - AbstractDavSyncAdapter.handleException(context, e, result); - } - - try { - - Optional remoteKeyMaterialSalt = keyCollection.get().getKeyMaterialSalt(); - Optional remoteEncryptedKeyMaterial = keyCollection.get().getEncryptedKeyMaterial(); - - if (!remoteKeyMaterialSalt.isPresent()) - keyCollection.get().setKeyMaterialSalt(localKeyMaterialSalt.get()); - - if (!remoteEncryptedKeyMaterial.isPresent()) - keyCollection.get().setEncryptedKeyMaterial(localEncryptedKeyMaterial.get()); - - else if (remoteKeyMaterialSalt.isPresent() && - !remoteEncryptedKeyMaterial.get().equals(localEncryptedKeyMaterial.get())) - { - try { - - KeyHelper.importSaltAndEncryptedKeyMaterial(context, new String[]{ - remoteKeyMaterialSalt.get(), - remoteEncryptedKeyMaterial.get() - }); - - } catch (InvalidMacException e) { - Log.d(TAG, "caught invalid mac exception while importing remote key material, " + - "assuming password change for non-flock sync user."); - KeyStore.saveEncryptedKeyMaterial(context, remoteEncryptedKeyMaterial.get()); - KeySyncService.showCipherPassphraseInvalidNotification(context); - } - } - - } catch (PropertyParseException e) { - AbstractDavSyncAdapter.handleException(context, e, result); - } catch (DavException e) { - AbstractDavSyncAdapter.handleException(context, e, result); - } catch (IOException e) { - AbstractDavSyncAdapter.handleException(context, e, result); - } catch (GeneralSecurityException e) { - AbstractDavSyncAdapter.handleException(context, e, result); - } + if (keyCollection.get().isMigrationComplete()) + handleMigrationComplete(result, localKeyMaterialSalt.get(), localEncryptedKeyMaterial.get(), keyCollection.get()); + else + handleMigrationInProgress(result, keyCollection.get()); + } catch (InvalidComponentException e) { + AbstractDavSyncAdapter.handleException(context, e, result); } catch (PropertyParseException e) { AbstractDavSyncAdapter.handleException(context, e, result); } catch (DavException e) { diff --git a/flock/src/main/res/drawable-xhdpi/migration_in_progress.png b/flock/src/main/res/drawable-xhdpi/migration_in_progress.png new file mode 100644 index 0000000000000000000000000000000000000000..8cfe1f196f52a64d24f0cac4c0626969ab230c1f GIT binary patch literal 974 zcmeAS@N?(olHy`uVBq!ia0vp^n}K)&2OE%d)``jnQfx`y?k)`fL2$v|<&%LToCO|{ z#S9GG!XV7ZFl&wkP>{XE)7O>#9)}o{wYC0zH+}{N=4ekB$B>F!Z|{2hy)h7IeRw13 zMt73vUXQSg-AU(UV>=la?3puV^}hP9mPw!HPkvmtWI8L*AT$s$Ej80kp#6LQ`Spwc zKmJ-{Vw7xE$m#b?h$(!^tTQKB*QuDz?Dm`!nPVhA&0_Y26g|B{wUsA*9+?D9o_Qj3 ziJI}m%N)LGLdykbopJF~HksKu$1Tf9*s}LRidvD>!riKhMPe#IH7Z+D+ES*S=$Lj+ zcXLHGo6ob#*1VZdY^`#X&g*`OUV8iA_y3_kyXRXKU%K}zK5L~-z18+pO3i0f+&mUq zPh4m$EP3$KPGQN&7cOiw8xtC@YR^#tn(CPLpznjy=hh!K9!fJqd7Qk{oK{PvsFkSR z>v>@38GqhAc<;H#n@T>sxPGg;Q~RAumRkdBkmVJgTJaP$!UYNM7d#CWudu?W`FU&o$tEO|7qu7}(ZhJo|{n}9TC}o=449mG! zd7gNut?apZInqcuJ)LdY^vI(QZL2P>=6|q#f91`*Ln+fvU6@&@ZCrP&{|v*-BbuZeqAw z$DhwOvmf5sUa0(Or}1@Q@@Pv7_~^RL?DaFfnVmW_4Z|{CZ>)WGmLZuV_gl)mxC~?a z{Bvgu)=O^L**Cv%f0lAE@29x>@1L{S7GA!x!>%gaFT*Lm_5BpZv(Ii_FPz}?B-BV) z%D7`sRGO3Wt`oCU)J&AR_e26Yt4{1rQ8QiSSrY-|Oghn?3gmRu%sR8=SS%C&!Ht$7fZRW4?3py3A8A&W?Ep!7y85}Sb4q9e0HV3KGynhq literal 0 HcmV?d00001 diff --git a/flock/src/main/res/layout/migration_release_notes.xml b/flock/src/main/res/layout/migration_release_notes.xml new file mode 100644 index 0000000..4eb7428 --- /dev/null +++ b/flock/src/main/res/layout/migration_release_notes.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + +