diff --git a/app/build.gradle b/app/build.gradle index 43bcd491..f009e076 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,7 +18,7 @@ android { minSdkVersion 16 targetSdkVersion 25 - versionCode 10 + versionCode 11 buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" buildConfigField "boolean", "customCerts", "true" @@ -26,7 +26,7 @@ android { productFlavors { standard { - versionName "0.13.0" + versionName "0.14.0" } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b0600fb8..e7c9dc60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -184,6 +184,7 @@ android:parentActivityName=".ui.AccountsActivity"> + diff --git a/app/src/main/java/com/etesync/syncadapter/AccountSettings.java b/app/src/main/java/com/etesync/syncadapter/AccountSettings.java index 35baa4b1..814b96b8 100644 --- a/app/src/main/java/com/etesync/syncadapter/AccountSettings.java +++ b/app/src/main/java/com/etesync/syncadapter/AccountSettings.java @@ -21,6 +21,9 @@ import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import com.etesync.syncadapter.journalmanager.Crypto; +import com.etesync.syncadapter.utils.Base64; + import java.lang.reflect.Method; import java.net.URI; import java.net.URISyntaxException; @@ -36,6 +39,8 @@ public class AccountSettings { KEY_URI = "uri", KEY_USERNAME = "user_name", KEY_TOKEN = "auth_token", + KEY_ASYMMETRIC_PRIVATE_KEY = "asymmetric_private_key", + KEY_ASYMMETRIC_PUBLIC_KEY = "asymmetric_public_key", KEY_WIFI_ONLY = "wifi_only", // sync on WiFi only (default: false) KEY_WIFI_ONLY_SSID = "wifi_only_ssid"; // restrict sync to specific WiFi SSID @@ -122,6 +127,23 @@ public class AccountSettings { accountManager.setUserData(account, KEY_TOKEN, token); } + + public Crypto.AsymmetricKeyPair getKeyPair() { + if (accountManager.getUserData(account, KEY_ASYMMETRIC_PUBLIC_KEY) != null) { + byte[] pubkey = Base64.decode(accountManager.getUserData(account, KEY_ASYMMETRIC_PUBLIC_KEY), Base64.NO_WRAP); + byte[] privkey = Base64.decode(accountManager.getUserData(account, KEY_ASYMMETRIC_PRIVATE_KEY), Base64.NO_WRAP); + + return new Crypto.AsymmetricKeyPair(privkey, pubkey); + } + + return null; + } + + public void setKeyPair(@NonNull Crypto.AsymmetricKeyPair keyPair) { + accountManager.setUserData(account, KEY_ASYMMETRIC_PUBLIC_KEY, Base64.encodeToString(keyPair.getPublicKey(), Base64.NO_WRAP)); + accountManager.setUserData(account, KEY_ASYMMETRIC_PRIVATE_KEY, Base64.encodeToString(keyPair.getPrivateKey(), Base64.NO_WRAP)); + } + public String username() { return accountManager.getUserData(account, KEY_USERNAME); } diff --git a/app/src/main/java/com/etesync/syncadapter/AccountUpdateService.java b/app/src/main/java/com/etesync/syncadapter/AccountUpdateService.java index af5d6a31..4f6fb65d 100644 --- a/app/src/main/java/com/etesync/syncadapter/AccountUpdateService.java +++ b/app/src/main/java/com/etesync/syncadapter/AccountUpdateService.java @@ -13,17 +13,12 @@ import android.accounts.AccountManager; import android.annotation.SuppressLint; import android.app.Service; import android.content.Intent; -import android.database.Cursor; import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteDatabase; import android.os.Binder; import android.os.IBinder; import android.support.annotation.NonNull; -import android.text.TextUtils; -import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.ServiceDB.OpenHelper; -import com.etesync.syncadapter.model.ServiceDB.Services; +import com.etesync.syncadapter.model.ServiceEntity; import java.lang.ref.WeakReference; import java.util.HashSet; @@ -105,31 +100,17 @@ public class AccountUpdateService extends Service { void cleanupAccounts() { App.log.info("Cleaning up orphaned accounts"); - final OpenHelper dbHelper = new OpenHelper(this); - try { - SQLiteDatabase db = dbHelper.getWritableDatabase(); + List sqlAccountNames = new LinkedList<>(); + AccountManager am = AccountManager.get(this); + for (Account account : am.getAccountsByType(Constants.ACCOUNT_TYPE)) + sqlAccountNames.add(account.name); - List sqlAccountNames = new LinkedList<>(); - AccountManager am = AccountManager.get(this); - for (Account account : am.getAccountsByType(Constants.ACCOUNT_TYPE)) - sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name)); + EntityDataStore data = ((App) getApplication()).getData(); - EntityDataStore data = ((App) getApplication()).getData(); - - if (sqlAccountNames.isEmpty()) { - data.delete(JournalEntity.class).get().value(); - db.delete(Services._TABLE, null, null); - } else { - Cursor cur = db.query(Services._TABLE, new String[]{Services.ID}, Services.ACCOUNT_NAME + " NOT IN (" + TextUtils.join(",", sqlAccountNames) + ")", null, null, null, null); - cur.moveToFirst(); - while(!cur.isAfterLast()) { - data.delete(JournalEntity.class).where(JournalEntity.SERVICE.eq(cur.getLong(0))).get().value(); - cur.moveToNext(); - } - db.delete(Services._TABLE, Services.ACCOUNT_NAME + " NOT IN (" + TextUtils.join(",", sqlAccountNames) + ")", null); - } - } finally { - dbHelper.close(); + if (sqlAccountNames.isEmpty()) { + data.delete(ServiceEntity.class).get().value(); + } else { + data.delete(ServiceEntity.class).where(ServiceEntity.ACCOUNT.notIn(sqlAccountNames)).get().value(); } } } diff --git a/app/src/main/java/com/etesync/syncadapter/App.java b/app/src/main/java/com/etesync/syncadapter/App.java index e567f09c..478de93d 100644 --- a/app/src/main/java/com/etesync/syncadapter/App.java +++ b/app/src/main/java/com/etesync/syncadapter/App.java @@ -42,6 +42,7 @@ import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; import com.etesync.syncadapter.model.Models; import com.etesync.syncadapter.model.ServiceDB; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.model.Settings; import com.etesync.syncadapter.resource.LocalAddressBook; import com.etesync.syncadapter.resource.LocalCalendar; @@ -68,9 +69,9 @@ import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.vcard4android.ContactsStorageException; import io.requery.Persistable; import io.requery.android.sqlite.DatabaseSource; +import io.requery.meta.EntityModel; import io.requery.sql.Configuration; import io.requery.sql.EntityDataStore; -import io.requery.sql.TableCreationMode; import lombok.Cleanup; import lombok.Getter; import okhttp3.internal.tls.OkHostnameVerifier; @@ -227,13 +228,43 @@ public class App extends Application { public EntityDataStore getData() { if (dataStore == null) { // override onUpgrade to handle migrating to a new version - DatabaseSource source = new DatabaseSource(this, Models.DEFAULT, 1); + DatabaseSource source = new MyDatabaseSource(this, Models.DEFAULT, 3); Configuration configuration = source.getConfiguration(); dataStore = new EntityDataStore<>(configuration); } return dataStore; } + private static class MyDatabaseSource extends DatabaseSource { + MyDatabaseSource(Context context, EntityModel entityModel, int version) { + super(context, entityModel, version); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + super.onUpgrade(db, oldVersion, newVersion); + + if (oldVersion < 3) { + db.execSQL("PRAGMA foreign_keys=OFF;"); + + db.execSQL("CREATE TABLE new_Journal (id integer primary key autoincrement not null, deleted boolean not null, encryptedKey varbinary(255), info varchar(255), owner varchar(255), service integer, serviceModel integer, uid varchar(64) not null, foreign key (serviceModel) references Service (id) on delete cascade);"); + db.execSQL("CREATE TABLE new_Entry (id integer primary key autoincrement not null, content varchar(255), journal integer, uid varchar(64) not null, foreign key (journal) references Journal (id) on delete cascade);"); + + db.execSQL("INSERT INTO new_Journal SELECT id, deleted, encryptedKey, info, owner, service, serviceModel, uid from Journal;"); + db.execSQL("INSERT INTO new_Entry SELECT id, content, journal, uid from Entry;"); + + db.execSQL("DROP TABLE Journal;"); + db.execSQL("DROP TABLE Entry;"); + db.execSQL("ALTER TABLE new_Journal RENAME TO Journal;"); + db.execSQL("ALTER TABLE new_Entry RENAME TO Entry;"); + // Add back indexes + db.execSQL("CREATE UNIQUE INDEX journal_unique_together on Journal (serviceModel, uid);"); + db.execSQL("CREATE UNIQUE INDEX entry_unique_together on Entry (journal, uid);"); + db.execSQL("PRAGMA foreign_keys=ON;"); + } + } + } + // update from previous account settings private final static String PREF_VERSION = "version"; @@ -257,7 +288,7 @@ public class App extends Application { List collections = readCollections(dbHelper); for (CollectionInfo info : collections) { - JournalEntity journalEntity = new JournalEntity(info); + JournalEntity journalEntity = new JournalEntity(data, info); data.insert(journalEntity); } @@ -291,6 +322,12 @@ public class App extends Application { if (fromVersion < 10) { HintManager.setHintSeen(this, AccountsActivity.HINT_ACCOUNT_ADD, true); } + + if (fromVersion < 11) { + ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(this); + + migrateServices(dbHelper); + } } public static class AppUpdatedReceiver extends BroadcastReceiver { @@ -321,4 +358,25 @@ public class App extends Application { } return collections; } + + public void migrateServices(ServiceDB.OpenHelper dbHelper) { + @Cleanup SQLiteDatabase db = dbHelper.getReadableDatabase(); + EntityDataStore data = this.getData(); + @Cleanup Cursor cursor = db.query(ServiceDB.Services._TABLE, null, null, null, null, null, null); + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, values); + ServiceEntity service = new ServiceEntity(); + service.setAccount(values.getAsString(ServiceDB.Services.ACCOUNT_NAME)); + service.setType(CollectionInfo.Type.valueOf(values.getAsString(ServiceDB.Services.SERVICE))); + data.insert(service); + + for (JournalEntity journalEntity : data.select(JournalEntity.class).where(JournalEntity.SERVICE.eq(values.getAsLong(ServiceDB.Services.ID))).get()) { + journalEntity.setServiceModel(service); + data.update(journalEntity); + } + } + + db.delete(ServiceDB.Services._TABLE, null, null); + } } diff --git a/app/src/main/java/com/etesync/syncadapter/HttpClient.java b/app/src/main/java/com/etesync/syncadapter/HttpClient.java index a0e277e0..5fd080a8 100644 --- a/app/src/main/java/com/etesync/syncadapter/HttpClient.java +++ b/app/src/main/java/com/etesync/syncadapter/HttpClient.java @@ -74,6 +74,10 @@ public class HttpClient { return create(context, App.log); } + public static OkHttpClient create(@Nullable Context context, String authToken) { + return create(context, App.log, Constants.serviceUrl.getHost(), authToken); + } + private static OkHttpClient.Builder defaultBuilder(@Nullable Context context, @NonNull final Logger logger) { OkHttpClient.Builder builder = client.newBuilder(); diff --git a/app/src/main/java/com/etesync/syncadapter/PackageChangedReceiver.java b/app/src/main/java/com/etesync/syncadapter/PackageChangedReceiver.java index f0ffce08..4eac385d 100644 --- a/app/src/main/java/com/etesync/syncadapter/PackageChangedReceiver.java +++ b/app/src/main/java/com/etesync/syncadapter/PackageChangedReceiver.java @@ -8,22 +8,13 @@ package com.etesync.syncadapter; -import android.accounts.Account; import android.annotation.SuppressLint; import android.content.BroadcastReceiver; -import android.content.ContentResolver; import android.content.Context; import android.content.Intent; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.os.Bundle; import android.support.annotation.NonNull; -import com.etesync.syncadapter.model.ServiceDB; -import com.etesync.syncadapter.model.ServiceDB.Services; import com.etesync.syncadapter.resource.LocalTaskList; -import at.bitfire.ical4android.TaskProvider; -import lombok.Cleanup; public class PackageChangedReceiver extends BroadcastReceiver { @@ -40,24 +31,7 @@ public class PackageChangedReceiver extends BroadcastReceiver { App.log.info("Package (un)installed; OpenTasks provider now available = " + tasksInstalled); // check all accounts and (de)activate OpenTasks if a CalDAV service is defined - @Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(context); - SQLiteDatabase db = dbHelper.getReadableDatabase(); - - @Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME }, - Services.SERVICE + "=?", new String[] { Services.SERVICE_CALDAV }, null, null, null); - while (cursor.moveToNext()) { - Account account = new Account(cursor.getString(0), Constants.ACCOUNT_TYPE); - - if (tasksInstalled) { - if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) { - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1); - ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true); - ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, new Bundle(), Constants.DEFAULT_SYNC_INTERVAL); - } - } else - ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0); - - } + // FIXME: Do something if we ever bring back tasks. } } diff --git a/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.java b/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.java index fa08028f..0ae3f440 100644 --- a/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.java +++ b/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.java @@ -7,11 +7,18 @@ import com.etesync.syncadapter.utils.Base64; import org.apache.commons.codec.Charsets; import org.apache.commons.lang3.ArrayUtils; +import org.spongycastle.asn1.pkcs.PrivateKeyInfo; +import org.spongycastle.asn1.x509.SubjectPublicKeyInfo; +import org.spongycastle.crypto.AsymmetricBlockCipher; +import org.spongycastle.crypto.AsymmetricCipherKeyPair; import org.spongycastle.crypto.BufferedBlockCipher; import org.spongycastle.crypto.CipherParameters; import org.spongycastle.crypto.InvalidCipherTextException; import org.spongycastle.crypto.digests.SHA256Digest; +import org.spongycastle.crypto.encodings.OAEPEncoding; import org.spongycastle.crypto.engines.AESEngine; +import org.spongycastle.crypto.engines.RSAEngine; +import org.spongycastle.crypto.generators.RSAKeyPairGenerator; import org.spongycastle.crypto.generators.SCrypt; import org.spongycastle.crypto.macs.HMac; import org.spongycastle.crypto.modes.CBCBlockCipher; @@ -20,12 +27,21 @@ import org.spongycastle.crypto.paddings.PKCS7Padding; import org.spongycastle.crypto.paddings.PaddedBufferedBlockCipher; import org.spongycastle.crypto.params.KeyParameter; import org.spongycastle.crypto.params.ParametersWithIV; +import org.spongycastle.crypto.params.RSAKeyGenerationParameters; +import org.spongycastle.crypto.util.PrivateKeyFactory; +import org.spongycastle.crypto.util.PrivateKeyInfoFactory; +import org.spongycastle.crypto.util.PublicKeyFactory; +import org.spongycastle.crypto.util.SubjectPublicKeyInfoFactory; import org.spongycastle.util.encoders.Hex; +import java.io.IOException; +import java.math.BigInteger; import java.security.SecureRandom; import java.util.Arrays; +import lombok.AccessLevel; import lombok.Getter; +import lombok.RequiredArgsConstructor; public class Crypto { public static String deriveKey(String salt, String password) { @@ -34,17 +50,103 @@ public class Crypto { return Base64.encodeToString(SCrypt.generate(password.getBytes(Charsets.UTF_8), salt.getBytes(Charsets.UTF_8), 16384, 8, 1, keySize), Base64.NO_WRAP); } + public static AsymmetricKeyPair generateKeyPair() { + RSAKeyPairGenerator keyPairGenerator = new RSAKeyPairGenerator(); + keyPairGenerator.init(new RSAKeyGenerationParameters(BigInteger.valueOf(65537), new SecureRandom(), 2048, 160)); + AsymmetricCipherKeyPair keyPair = keyPairGenerator.generateKeyPair(); + try { + PrivateKeyInfo privateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(keyPair.getPrivate()); + SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(keyPair.getPublic()); + return new AsymmetricKeyPair(privateKeyInfo.getEncoded(), publicKeyInfo.getEncoded()); + } catch (IOException e) { + e.printStackTrace(); + } + + return null; + } + + @RequiredArgsConstructor + public static class AsymmetricKeyPair { + @Getter(AccessLevel.PUBLIC) + private final byte[] privateKey; + @Getter(AccessLevel.PUBLIC) + private final byte[] publicKey; + } + + public static class AsymmetricCryptoManager { + private final AsymmetricKeyPair keyPair; + + public AsymmetricCryptoManager(AsymmetricKeyPair keyPair) { + this.keyPair = keyPair; + } + + public byte[] encrypt(byte[] pubkey, byte[] content) { + AsymmetricBlockCipher cipher = new RSAEngine(); + cipher = new OAEPEncoding(cipher); + try { + cipher.init(true, PublicKeyFactory.createKey(pubkey)); + return cipher.processBlock(content, 0, content.length); + } catch (IOException e) { + e.printStackTrace(); + } catch (InvalidCipherTextException e) { + e.printStackTrace(); + App.log.severe("Invalid ciphertext: " + Base64.encodeToString(content, Base64.NO_WRAP)); + } + + return null; + } + + public byte[] decrypt(byte[] cipherText) { + AsymmetricBlockCipher cipher = new RSAEngine(); + cipher = new OAEPEncoding(cipher); + try { + cipher.init(false, PrivateKeyFactory.createKey(keyPair.getPrivateKey())); + return cipher.processBlock(cipherText, 0, cipherText.length); + } catch (IOException e) { + e.printStackTrace(); + } catch (InvalidCipherTextException e) { + e.printStackTrace(); + App.log.severe("Invalid ciphertext: " + Base64.encodeToString(cipherText, Base64.NO_WRAP)); + } + + return null; + } + + public static byte[] getKeyFingerprint(byte[] pubkey) { + return sha256(pubkey); + } + + public static String getPrettyKeyFingerprint(byte[] pubkey) { + byte[] fingerprint = Crypto.AsymmetricCryptoManager.getKeyFingerprint(pubkey); + String fingerprintString = Hex.toHexString(fingerprint).toLowerCase(); + return fingerprintString.replaceAll("(.{4})", "$1 "); + } + } + public static class CryptoManager { final static int HMAC_SIZE = 256 / 8; // hmac256 in bytes private SecureRandom _random = null; @Getter private final byte version; - private final byte[] cipherKey; - private final byte[] hmacKey; + private byte[] cipherKey; + private byte[] hmacKey; + private byte[] derivedKey; + + private void setDerivedKey(byte[] derivedKey) { + cipherKey = hmac256("aes".getBytes(Charsets.UTF_8), derivedKey); + hmacKey = hmac256("hmac".getBytes(Charsets.UTF_8), derivedKey); + } + + public CryptoManager(int version, AsymmetricKeyPair keyPair, byte[] encryptedKey) { + Crypto.AsymmetricCryptoManager cryptoManager = new Crypto.AsymmetricCryptoManager(keyPair); + derivedKey = cryptoManager.decrypt(encryptedKey); + + this.version = (byte) version; + setDerivedKey(derivedKey); + } public CryptoManager(int version, @NonNull String keyBase64, @NonNull String salt) throws Exceptions.IntegrityException, Exceptions.VersionTooNewException { - byte[] derivedKey; if (version > Byte.MAX_VALUE) { throw new Exceptions.IntegrityException("Version is out of range."); } else if (version > Constants.CURRENT_VERSION) { @@ -56,8 +158,7 @@ public class Crypto { } this.version = (byte) version; - cipherKey = hmac256("aes".getBytes(Charsets.UTF_8), derivedKey); - hmacKey = hmac256("hmac".getBytes(Charsets.UTF_8), derivedKey); + setDerivedKey(derivedKey); } private static final int blockSize = 16; // AES's block size in bytes @@ -143,18 +244,23 @@ public class Crypto { hmac.doFinal(ret, 0); return ret; } + + public byte[] getEncryptedKey(AsymmetricKeyPair keyPair, byte[] publicKey) { + AsymmetricCryptoManager cryptoManager = new AsymmetricCryptoManager(keyPair); + return cryptoManager.encrypt(publicKey, derivedKey); + } } static String sha256(String base) { - return sha256(base.getBytes(Charsets.UTF_8)); + return toHex(sha256(base.getBytes(Charsets.UTF_8))); } - private static String sha256(byte[] base) { + private static byte[] sha256(byte[] base) { SHA256Digest digest = new SHA256Digest(); digest.update(base, 0, base.length); byte[] ret = new byte[digest.getDigestSize()]; digest.doFinal(ret, 0); - return toHex(ret); + return ret; } static String toHex(byte[] bytes) { diff --git a/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalManager.java b/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalManager.java index d542fb7e..41f0c2fb 100644 --- a/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalManager.java +++ b/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalManager.java @@ -1,16 +1,16 @@ package com.etesync.syncadapter.journalmanager; +import com.etesync.syncadapter.App; +import com.etesync.syncadapter.GsonHelper; import com.google.gson.reflect.TypeToken; import org.spongycastle.util.Arrays; +import java.io.IOException; import java.lang.reflect.Type; import java.util.List; import java.util.UUID; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.GsonHelper; - import lombok.Getter; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -26,6 +26,8 @@ import static com.etesync.syncadapter.journalmanager.Crypto.toHex; public class JournalManager extends BaseManager { final static private Type journalType = new TypeToken>() { }.getType(); + final static private Type memberType = new TypeToken>() { + }.getType(); public JournalManager(OkHttpClient httpClient, HttpUrl remote) { @@ -38,7 +40,7 @@ public class JournalManager extends BaseManager { this.client = httpClient; } - public List getJournals(String keyBase64) throws Exceptions.HttpException, Exceptions.IntegrityException, Exceptions.GenericCryptoException { + public List getJournals() throws Exceptions.HttpException { Request request = new Request.Builder() .get() .url(remote) @@ -49,9 +51,7 @@ public class JournalManager extends BaseManager { List ret = GsonHelper.gson.fromJson(body.charStream(), journalType); for (Journal journal : ret) { - Crypto.CryptoManager crypto = new Crypto.CryptoManager(journal.getVersion(), keyBase64, journal.getUid()); journal.processFromJson(); - journal.verify(crypto); } return ret; @@ -90,7 +90,50 @@ public class JournalManager extends BaseManager { newCall(request); } + private HttpUrl getMemberRemote(Journal journal) { + return this.remote.resolve(journal.getUid() + "/members/"); + } + + public List listMembers(Journal journal) throws Exceptions.HttpException, Exceptions.IntegrityException, Exceptions.GenericCryptoException { + Request request = new Request.Builder() + .get() + .url(getMemberRemote(journal)) + .build(); + + Response response = newCall(request); + ResponseBody body = response.body(); + return GsonHelper.gson.fromJson(body.charStream(), memberType); + } + + public void deleteMember(Journal journal, Member member) throws Exceptions.HttpException { + RequestBody body = RequestBody.create(JSON, member.toJson()); + + Request request = new Request.Builder() + .delete(body) + .url(getMemberRemote(journal)) + .build(); + + newCall(request); + } + + public void addMember(Journal journal, Member member) throws Exceptions.HttpException { + RequestBody body = RequestBody.create(JSON, member.toJson()); + + Request request = new Request.Builder() + .post(body) + .url(getMemberRemote(journal)) + .build(); + + newCall(request); + } + public static class Journal extends Base { + @Getter + private String owner; + + @Getter + private byte[] key; + @Getter private int version = -1; @@ -101,6 +144,12 @@ public class JournalManager extends BaseManager { super(); } + public static Journal fakeWithUid(String uid) { + Journal ret = new Journal(); + ret.setUid(uid); + return ret; + } + public Journal(Crypto.CryptoManager crypto, String content, String uid) { super(crypto, content, uid); hmac = calculateHmac(crypto); @@ -112,7 +161,7 @@ public class JournalManager extends BaseManager { setContent(Arrays.copyOfRange(getContent(), HMAC_SIZE, getContent().length)); } - void verify(Crypto.CryptoManager crypto) throws Exceptions.IntegrityException { + public void verify(Crypto.CryptoManager crypto) throws Exceptions.IntegrityException { if (hmac == null) { throw new Exceptions.IntegrityException("HMAC is null!"); } @@ -140,4 +189,24 @@ public class JournalManager extends BaseManager { return ret; } } + + public static class Member { + @Getter + private String user; + @Getter + private byte[] key; + + @SuppressWarnings("unused") + private Member() { + } + + public Member(String user, byte[] encryptedKey) { + this.user = user; + this.key = encryptedKey; + } + + String toJson() { + return GsonHelper.gson.toJson(this, getClass()); + } + } } diff --git a/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.java b/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.java new file mode 100644 index 00000000..f1495165 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.java @@ -0,0 +1,149 @@ +package com.etesync.syncadapter.journalmanager; + +import com.etesync.syncadapter.GsonHelper; + +import org.apache.commons.codec.Charsets; +import org.spongycastle.util.Arrays; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import lombok.Getter; +import lombok.Setter; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import static com.etesync.syncadapter.journalmanager.Crypto.CryptoManager.HMAC_SIZE; +import static com.etesync.syncadapter.journalmanager.Crypto.toHex; + +public class UserInfoManager extends BaseManager { + public UserInfoManager(OkHttpClient httpClient, HttpUrl remote) { + this.remote = remote.newBuilder() + .addPathSegments("api/v1/user") + .addPathSegment("") + .build(); + + this.client = httpClient; + } + + public UserInfo get(String owner) throws Exceptions.HttpException { + HttpUrl remote = this.remote.newBuilder().addPathSegment(owner).addPathSegment("").build(); + Request request = new Request.Builder() + .get() + .url(remote) + .build(); + + Response response; + try { + response = newCall(request); + } catch (Exceptions.HttpException e) { + if (e.status == HttpURLConnection.HTTP_NOT_FOUND) { + return null; + } else { + throw e; + } + } + + ResponseBody body = response.body(); + UserInfo ret = GsonHelper.gson.fromJson(body.charStream(), UserInfo.class); + ret.setOwner(owner); + + return ret; + } + + public void delete(UserInfo userInfo) throws Exceptions.HttpException { + HttpUrl remote = this.remote.newBuilder().addPathSegment(userInfo.getOwner()).addPathSegment("").build(); + Request request = new Request.Builder() + .delete() + .url(remote) + .build(); + + newCall(request); + } + + public void create(UserInfo userInfo) throws Exceptions.HttpException { + RequestBody body = RequestBody.create(JSON, userInfo.toJson()); + + Request request = new Request.Builder() + .post(body) + .url(remote) + .build(); + + newCall(request); + } + + public void update(UserInfo userInfo) throws Exceptions.HttpException { + HttpUrl remote = this.remote.newBuilder().addPathSegment(userInfo.getOwner()).addPathSegment("").build(); + RequestBody body = RequestBody.create(JSON, userInfo.toJson()); + + Request request = new Request.Builder() + .put(body) + .url(remote) + .build(); + + newCall(request); + } + + public static class UserInfo { + @Setter + @Getter + private transient String owner; + @Getter + private byte version; + @Getter + private byte[] pubkey; + private byte[] content; + + public byte[] getContent(Crypto.CryptoManager crypto) { + byte[] content = Arrays.copyOfRange(this.content, HMAC_SIZE, this.content.length); + return crypto.decrypt(content); + } + + void setContent(Crypto.CryptoManager crypto, byte[] rawContent) { + byte[] content = crypto.encrypt(rawContent); + this.content = Arrays.concatenate(calculateHmac(crypto, content), content); + } + + public void verify(Crypto.CryptoManager crypto) throws Exceptions.IntegrityException { + if (this.content == null) { + // Nothing to verify. + return; + } + + byte[] hmac = Arrays.copyOfRange(this.content, 0, HMAC_SIZE); + byte[] content = Arrays.copyOfRange(this.content, HMAC_SIZE, this.content.length); + + byte[] correctHash = calculateHmac(crypto, content); + if (!Arrays.areEqual(hmac, correctHash)) { + throw new Exceptions.IntegrityException("Bad HMAC. " + toHex(hmac) + " != " + toHex(correctHash)); + } + } + + private byte[] calculateHmac(Crypto.CryptoManager crypto, byte[] content) { + return crypto.hmac(Arrays.concatenate(content, pubkey)); + } + + private UserInfo() { + } + + public UserInfo(Crypto.CryptoManager crypto, String owner, byte[] pubkey, byte[] content) { + this.owner = owner; + this.pubkey = pubkey; + version = crypto.getVersion(); + setContent(crypto, content); + } + + public static UserInfo generate(Crypto.CryptoManager cryptoManager, String owner) throws IOException { + Crypto.AsymmetricKeyPair keyPair = Crypto.generateKeyPair(); + return new UserInfo(cryptoManager, owner, keyPair.getPublicKey(), keyPair.getPrivateKey()); + } + + String toJson() { + return GsonHelper.gson.toJson(this, getClass()); + } + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.java b/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.java index 7b3729da..1e7b0abb 100644 --- a/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.java +++ b/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.java @@ -18,6 +18,8 @@ import com.google.gson.annotations.Expose; import java.io.Serializable; +import io.requery.Persistable; +import io.requery.sql.EntityDataStore; import lombok.ToString; @ToString(exclude = {"id"}) @@ -25,7 +27,7 @@ public class CollectionInfo implements Serializable { @Deprecated public long id; - public Long serviceID; + public int serviceID; public enum Type { ADDRESS_BOOK, @@ -80,7 +82,7 @@ public class CollectionInfo implements Serializable { public static CollectionInfo fromDB(ContentValues values) { CollectionInfo info = new CollectionInfo(); info.id = values.getAsLong(Collections.ID); - info.serviceID = values.getAsLong(Collections.SERVICE_ID); + info.serviceID = values.getAsInteger(Collections.SERVICE_ID); info.uid = values.getAsString(Collections.URL); info.readOnly = values.getAsInteger(Collections.READ_ONLY) != 0; @@ -95,6 +97,10 @@ public class CollectionInfo implements Serializable { return info; } + public ServiceEntity getServiceEntity(EntityDataStore data) { + return data.findByKey(ServiceEntity.class, serviceID); + } + public static CollectionInfo fromJson(String json) { return new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create().fromJson(json, CollectionInfo.class); } diff --git a/app/src/main/java/com/etesync/syncadapter/model/JournalModel.java b/app/src/main/java/com/etesync/syncadapter/model/JournalModel.java index f4320bb1..ad383ff1 100644 --- a/app/src/main/java/com/etesync/syncadapter/model/JournalModel.java +++ b/app/src/main/java/com/etesync/syncadapter/model/JournalModel.java @@ -15,28 +15,41 @@ import io.requery.ManyToOne; import io.requery.Persistable; import io.requery.PostLoad; import io.requery.ReferentialAction; +import io.requery.Table; import io.requery.sql.EntityDataStore; public class JournalModel { + // FIXME: Add unique constraint on the uid + service combination. Can't do it at the moment because requery is broken. @Entity + @Table(name = "Journal") public static abstract class Journal { @Key @Generated int id; - @Column(length = 64, unique = true, nullable = false) + @Column(length = 64, nullable = false) String uid; @Convert(CollectionInfoConverter.class) CollectionInfo info; + String owner; + + byte[] encryptedKey; + + + @Deprecated long service; + @ForeignKey(update = ReferentialAction.CASCADE) + @ManyToOne + Service serviceModel; + boolean deleted; @PostLoad void afterLoad() { - this.info.serviceID = service; + this.info.serviceID = this.serviceModel.id; this.info.uid = uid; } @@ -44,17 +57,22 @@ public class JournalModel { this.deleted = false; } - public Journal(CollectionInfo info) { + public Journal(EntityDataStore data, CollectionInfo info) { this(); this.info = info; this.uid = info.uid; - this.service = info.serviceID; + this.serviceModel = info.getServiceEntity(data); } - public static List getCollections(EntityDataStore data, long service) { + public static List getJournals(EntityDataStore data, ServiceEntity serviceEntity) { + return data.select(JournalEntity.class).where(JournalEntity.SERVICE_MODEL.eq(serviceEntity).and(JournalEntity.DELETED.eq(false))).get().toList(); + } + + public static List getCollections(EntityDataStore data, ServiceEntity serviceEntity) { List ret = new LinkedList<>(); - for (JournalEntity journal : data.select(JournalEntity.class).where(JournalEntity.SERVICE.eq(service).and(JournalEntity.DELETED.eq(false))).get()) { + List journals = getJournals(data, serviceEntity); + for (JournalEntity journal : journals) { // FIXME: For some reason this isn't always being called, manually do it here. journal.afterLoad(); ret.add(journal.getInfo()); @@ -63,8 +81,8 @@ public class JournalModel { return ret; } - public static JournalEntity fetch(EntityDataStore data, String url) { - JournalEntity ret = data.select(JournalEntity.class).where(JournalEntity.UID.eq(url)).limit(1).get().firstOrNull(); + public static JournalEntity fetch(EntityDataStore data, ServiceEntity serviceEntity, String uid) { + JournalEntity ret = data.select(JournalEntity.class).where(JournalEntity.SERVICE_MODEL.eq(serviceEntity).and(JournalEntity.UID.eq(uid))).limit(1).get().firstOrNull(); if (ret != null) { // FIXME: For some reason this isn't always being called, manually do it here. ret.afterLoad(); @@ -73,9 +91,9 @@ public class JournalModel { } public static JournalEntity fetchOrCreate(EntityDataStore data, CollectionInfo collection) { - JournalEntity journalEntity = fetch(data, collection.uid); + JournalEntity journalEntity = fetch(data, collection.getServiceEntity(data), collection.uid); if (journalEntity == null) { - journalEntity = new JournalEntity(collection); + journalEntity = new JournalEntity(data, collection); } else { journalEntity.setInfo(collection); } @@ -93,23 +111,45 @@ public class JournalModel { } @Entity + @Table(name = "Entry", uniqueIndexes = "entry_unique_together") public static abstract class Entry { @Key @Generated int id; - @Column(length = 64, unique = true, nullable = false) + @Index("entry_unique_together") + @Column(length = 64, nullable = false) String uid; @Convert(SyncEntryConverter.class) SyncEntry content; - @Index("journal_index") + @Index("entry_unique_together") @ForeignKey(update = ReferentialAction.CASCADE) @ManyToOne Journal journal; } + + @Entity + @Table(name = "Service", uniqueIndexes = "service_unique_together") + public static abstract class Service { + @Key + @Generated + int id; + + @Index(value = "service_unique_together") + @Column(nullable = false) + String account; + + @Index(value = "service_unique_together") + CollectionInfo.Type type; + + public static ServiceEntity fetch(EntityDataStore data, String account, CollectionInfo.Type type) { + return data.select(ServiceEntity.class).where(ServiceEntity.ACCOUNT.eq(account).and(ServiceEntity.TYPE.eq(type))).limit(1).get().firstOrNull(); + } + } + static class CollectionInfoConverter implements Converter { @Override public Class getMappedType() { diff --git a/app/src/main/java/com/etesync/syncadapter/model/ServiceDB.java b/app/src/main/java/com/etesync/syncadapter/model/ServiceDB.java index 83e95b50..196b35ae 100644 --- a/app/src/main/java/com/etesync/syncadapter/model/ServiceDB.java +++ b/app/src/main/java/com/etesync/syncadapter/model/ServiceDB.java @@ -33,17 +33,13 @@ public class ServiceDB { VALUE = "value"; } + @Deprecated public static class Services { public static final String _TABLE = "services", ID = "_id", ACCOUNT_NAME = "accountName", SERVICE = "service"; - - // allowed values for SERVICE column - public static final String - SERVICE_CALDAV = CollectionInfo.Type.CALENDAR.toString(), - SERVICE_CARDDAV = CollectionInfo.Type.ADDRESS_BOOK.toString(); } @Deprecated @@ -90,15 +86,8 @@ public class ServiceDB { db.execSQL("CREATE TABLE " + Settings._TABLE + "(" + Settings.NAME + " TEXT NOT NULL," + Settings.VALUE + " TEXT NOT NULL" + - ")"); + ")"); db.execSQL("CREATE UNIQUE INDEX settings_name ON " + Settings._TABLE + " (" + Settings.NAME + ")"); - - db.execSQL("CREATE TABLE " + Services._TABLE + "(" + - Services.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + - Services.ACCOUNT_NAME + " TEXT NOT NULL," + - Services.SERVICE + " TEXT NOT NULL" + - ")"); - db.execSQL("CREATE UNIQUE INDEX services_account ON " + Services._TABLE + " (" + Services.ACCOUNT_NAME + "," + Services.SERVICE + ")"); } @Override @@ -112,7 +101,7 @@ public class ServiceDB { db.beginTransactionNonExclusive(); // iterate through all tables - @Cleanup Cursor cursorTables = db.query("sqlite_master", new String[] { "name" }, "type='table'", null, null, null, null); + @Cleanup Cursor cursorTables = db.query("sqlite_master", new String[]{"name"}, "type='table'", null, null, null, null); while (cursorTables.moveToNext()) { String table = cursorTables.getString(0); sb.append(table).append("\n"); @@ -153,47 +142,5 @@ public class ServiceDB { } db.endTransaction(); } - - @NonNull - public Account getServiceAccount(SQLiteDatabase db, long service) { - @Cleanup Cursor cursor = db.query(Services._TABLE, new String[]{Services.ACCOUNT_NAME}, Services.ID + "=?", new String[]{String.valueOf(service)}, null, null, null); - if (cursor.moveToNext()) { - return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE); - } else - throw new IllegalArgumentException("Service not found"); - } - - @NonNull - public String getServiceType(SQLiteDatabase db, long service) { - @Cleanup Cursor cursor = db.query(Services._TABLE, new String[]{Services.SERVICE}, Services.ID + "=?", new String[]{String.valueOf(service)}, null, null, null); - if (cursor.moveToNext()) - return cursor.getString(0); - else - throw new IllegalArgumentException("Service not found"); - } - - @Nullable - public Long getService(@NonNull SQLiteDatabase db, @NonNull Account account, String service) { - @Cleanup Cursor c = db.query(Services._TABLE, new String[]{Services.ID}, - Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[]{account.name, service}, null, null, null); - if (c.moveToNext()) - return c.getLong(0); - else - return null; - } - - @Nullable - public Long getService(@NonNull Account account, String service) { - @Cleanup SQLiteDatabase db = getReadableDatabase(); - return getService(db, account, service); - } } - - - public static void onRenameAccount(@NonNull SQLiteDatabase db, @NonNull String oldName, @NonNull String newName) { - ContentValues values = new ContentValues(1); - values.put(Services.ACCOUNT_NAME, newName); - db.update(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", new String[] { oldName }); - } - } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java index 1d9126ed..cd4ba3a7 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java @@ -14,7 +14,6 @@ import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SyncResult; -import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.os.Bundle; import android.provider.CalendarContract; @@ -27,8 +26,8 @@ import com.etesync.syncadapter.R; import com.etesync.syncadapter.journalmanager.Exceptions; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.ServiceDB; -import com.etesync.syncadapter.model.ServiceDB.Services; +import com.etesync.syncadapter.model.JournalModel; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.resource.LocalCalendar; import com.etesync.syncadapter.ui.DebugInfoActivity; @@ -109,47 +108,40 @@ public class CalendarsSyncAdapterService extends SyncAdapterService { } private void updateLocalCalendars(ContentProviderClient provider, Account account, AccountSettings settings) throws CalendarStorageException { - ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); - try { - // enumerate remote and local calendars - SQLiteDatabase db = dbHelper.getReadableDatabase(); - Long service = dbHelper.getService(db, account, Services.SERVICE_CALDAV); + EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); + ServiceEntity service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.CALENDAR); - EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); - Map remote = new HashMap<>(); - List remoteCollections = JournalEntity.getCollections(data, service); - for (CollectionInfo info : remoteCollections) { - remote.put(info.uid, info); - } + Map remote = new HashMap<>(); + List remoteCollections = JournalEntity.getCollections(data, service); + for (CollectionInfo info : remoteCollections) { + remote.put(info.uid, info); + } - LocalCalendar[] local = (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null); + LocalCalendar[] local = (LocalCalendar[]) LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null); - boolean updateColors = settings.getManageCalendarColors(); + boolean updateColors = settings.getManageCalendarColors(); - // delete obsolete local calendar - for (LocalCalendar calendar : local) { - String url = calendar.getName(); - if (!remote.containsKey(url)) { - App.log.fine("Deleting obsolete local calendar " + url); - calendar.delete(); - } else { - // remote CollectionInfo found for this local collection, update data - CollectionInfo info = remote.get(url); - App.log.fine("Updating local calendar " + url + " with " + info); - calendar.update(info, updateColors); - // we already have a local calendar for this remote collection, don't take into consideration anymore - remote.remove(url); - } - } - - // create new local calendars - for (String url : remote.keySet()) { + // delete obsolete local calendar + for (LocalCalendar calendar : local) { + String url = calendar.getName(); + if (!remote.containsKey(url)) { + App.log.fine("Deleting obsolete local calendar " + url); + calendar.delete(); + } else { + // remote CollectionInfo found for this local collection, update data CollectionInfo info = remote.get(url); - App.log.info("Adding local calendar list " + info); - LocalCalendar.create(account, provider, info); + App.log.fine("Updating local calendar " + url + " with " + info); + calendar.update(info, updateColors); + // we already have a local calendar for this remote collection, don't take into consideration anymore + remote.remove(url); } - } finally { - dbHelper.close(); + } + + // create new local calendars + for (String url : remote.keySet()) { + CollectionInfo info = remote.get(url); + App.log.info("Adding local calendar list " + info); + LocalCalendar.create(account, provider, info); } } } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java index fc2f55cf..9c63c8a2 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java @@ -14,7 +14,6 @@ import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SyncResult; -import android.database.sqlite.SQLiteDatabase; import android.os.Bundle; import com.etesync.syncadapter.AccountSettings; @@ -26,7 +25,9 @@ import com.etesync.syncadapter.R; import com.etesync.syncadapter.journalmanager.Exceptions; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; +import com.etesync.syncadapter.model.JournalModel; import com.etesync.syncadapter.model.ServiceDB; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.ui.DebugInfoActivity; import java.util.logging.Level; @@ -57,7 +58,6 @@ public class ContactsSyncAdapterService extends SyncAdapterService { NotificationHelper notificationManager = new NotificationHelper(getContext(), "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC); notificationManager.cancel(); - ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); try { AccountSettings settings = new AccountSettings(getContext(), account); if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) @@ -65,11 +65,12 @@ public class ContactsSyncAdapterService extends SyncAdapterService { new RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run(); - SQLiteDatabase db = dbHelper.getReadableDatabase(); - Long service = dbHelper.getService(db, account, ServiceDB.Services.SERVICE_CARDDAV); + EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); + + ServiceEntity service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.ADDRESS_BOOK); + if (service != null) { HttpUrl principal = HttpUrl.get(settings.getUri()); - EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); CollectionInfo info = JournalEntity.getCollections(data, service).get(0); try { ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, principal, info); @@ -95,8 +96,6 @@ public class ContactsSyncAdapterService extends SyncAdapterService { detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase); } notificationManager.notify(title, getContext().getString(syncPhase)); - } finally { - dbHelper.close(); } App.log.info("Address book sync complete"); diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java index 85269e78..3760ac42 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java @@ -28,6 +28,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.util.Pair; import java.util.HashMap; import java.util.LinkedList; @@ -46,7 +47,9 @@ import com.etesync.syncadapter.journalmanager.Exceptions; import com.etesync.syncadapter.journalmanager.JournalManager; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; +import com.etesync.syncadapter.model.JournalModel; import com.etesync.syncadapter.model.ServiceDB; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.ui.PermissionsActivity; import io.requery.Persistable; @@ -130,7 +133,6 @@ public abstract class SyncAdapterService extends Service { } protected class RefreshCollections { - final private ServiceDB.OpenHelper dbHelper; final private Account account; final private Context context; final private CollectionInfo.Type serviceType; @@ -139,77 +141,75 @@ public abstract class SyncAdapterService extends Service { this.account = account; this.serviceType = serviceType; context = getContext(); - dbHelper = new ServiceDB.OpenHelper(context); } void run() throws Exceptions.HttpException, Exceptions.IntegrityException, InvalidAccountException, Exceptions.GenericCryptoException { - try { - @Cleanup SQLiteDatabase db = dbHelper.getWritableDatabase(); + App.log.info("Refreshing " + serviceType + " collections of service #" + serviceType.toString()); - App.log.info("Refreshing " + serviceType + " collections of service #" + serviceType.toString()); + OkHttpClient httpClient = HttpClient.create(context, account); - OkHttpClient httpClient = HttpClient.create(context, account); + AccountSettings settings = new AccountSettings(context, account); + JournalManager journalsManager = new JournalManager(httpClient, HttpUrl.get(settings.getUri())); - AccountSettings settings = new AccountSettings(context, account); - JournalManager journalsManager = new JournalManager(httpClient, HttpUrl.get(settings.getUri())); + List> journals = new LinkedList<>(); - List collections = new LinkedList<>(); - - for (JournalManager.Journal journal : journalsManager.getJournals(settings.password())) { - Crypto.CryptoManager crypto = new Crypto.CryptoManager(journal.getVersion(), settings.password(), journal.getUid()); - CollectionInfo info = CollectionInfo.fromJson(journal.getContent(crypto)); - info.updateFromJournal(journal); - - if (info.type.equals(serviceType)) { - collections.add(info); - } + for (JournalManager.Journal journal : journalsManager.getJournals()) { + Crypto.CryptoManager crypto; + if (journal.getKey() != null) { + crypto = new Crypto.CryptoManager(journal.getVersion(), settings.getKeyPair(), journal.getKey()); + } else { + crypto = new Crypto.CryptoManager(journal.getVersion(), settings.password(), journal.getUid()); } - if (collections.isEmpty()) { - CollectionInfo info = CollectionInfo.defaultForServiceType(serviceType); - info.uid = JournalManager.Journal.genUid(); - Crypto.CryptoManager crypto = new Crypto.CryptoManager(info.version, settings.password(), info.uid); - JournalManager.Journal journal = new JournalManager.Journal(crypto, info.toJson(), info.uid); - journalsManager.putJournal(journal); - collections.add(info); - } + journal.verify(crypto); - db.beginTransactionNonExclusive(); - try { - saveCollections(db, collections); - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); + CollectionInfo info = CollectionInfo.fromJson(journal.getContent(crypto)); + info.updateFromJournal(journal); + + if (info.type.equals(serviceType)) { + journals.add(new Pair<>(journal, info)); } - } finally { - dbHelper.close(); } + + if (journals.isEmpty()) { + CollectionInfo info = CollectionInfo.defaultForServiceType(serviceType); + info.uid = JournalManager.Journal.genUid(); + Crypto.CryptoManager crypto = new Crypto.CryptoManager(info.version, settings.password(), info.uid); + JournalManager.Journal journal = new JournalManager.Journal(crypto, info.toJson(), info.uid); + journalsManager.putJournal(journal); + journals.add(new Pair<>(journal, info)); + } + + saveCollections(journals); } - private void saveCollections(SQLiteDatabase db, Iterable collections) { - Long service = dbHelper.getService(db, account, serviceType.toString()); - + private void saveCollections(Iterable> journals) { EntityDataStore data = ((App) context.getApplicationContext()).getData(); - Map existing = new HashMap<>(); - List existingList = JournalEntity.getCollections(data, service); - for (CollectionInfo info : existingList) { - existing.put(info.uid, info); + ServiceEntity service = JournalModel.Service.fetch(data, account.name, serviceType); + + Map existing = new HashMap<>(); + for (JournalEntity journalEntity : JournalEntity.getJournals(data, service)) { + existing.put(journalEntity.getUid(), journalEntity); } - for (CollectionInfo collection : collections) { - App.log.log(Level.FINE, "Saving collection", collection.uid); + for (Pair pair : journals) { + JournalManager.Journal journal = pair.first; + CollectionInfo collection = pair.second; + App.log.log(Level.FINE, "Saving collection", journal.getUid()); - collection.serviceID = service; + collection.serviceID = service.getId(); JournalEntity journalEntity = JournalEntity.fetchOrCreate(data, collection); + journalEntity.setOwner(journal.getOwner()); + journalEntity.setEncryptedKey(journal.getKey()); + journalEntity.setDeleted(false); data.upsert(journalEntity); existing.remove(collection.uid); } - for (CollectionInfo collection : existing.values()) { - App.log.log(Level.FINE, "Deleting collection", collection.uid); + for (JournalEntity journalEntity : existing.values()) { + App.log.log(Level.FINE, "Deleting collection", journalEntity.getUid()); - JournalEntity journalEntity = data.select(JournalEntity.class).where(JournalEntity.UID.eq(collection.uid)).limit(1).get().first(); journalEntity.setDeleted(true); data.update(journalEntity); } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java index cf07d4a0..f7481f0c 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java @@ -27,10 +27,13 @@ import com.etesync.syncadapter.journalmanager.JournalEntryManager; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.EntryEntity; import com.etesync.syncadapter.model.JournalEntity; +import com.etesync.syncadapter.model.JournalModel; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.model.SyncEntry; import com.etesync.syncadapter.resource.LocalCollection; import com.etesync.syncadapter.resource.LocalResource; import com.etesync.syncadapter.ui.DebugInfoActivity; +import com.etesync.syncadapter.utils.Base64; import org.apache.commons.collections4.ListUtils; @@ -108,14 +111,20 @@ abstract public class SyncManager { httpClient = HttpClient.create(context, account); data = ((App) context.getApplicationContext()).getData(); - info = JournalEntity.fetch(data, journalUid).getInfo(); + ServiceEntity serviceEntity = JournalModel.Service.fetch(data, account.name, serviceType); + info = JournalEntity.fetch(data, serviceEntity, journalUid).getInfo(); // dismiss previous error notifications notificationManager = new NotificationHelper(context, journalUid, notificationId()); notificationManager.cancel(); App.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version)); - crypto = new Crypto.CryptoManager(info.version, settings.password(), journalUid); + + if (getJournalEntity().getEncryptedKey() != null) { + crypto = new Crypto.CryptoManager(info.version, settings.getKeyPair(), getJournalEntity().getEncryptedKey()); + } else { + crypto = new Crypto.CryptoManager(info.version, settings.password(), info.uid); + } } protected abstract int notificationId(); @@ -232,7 +241,7 @@ abstract public class SyncManager { private JournalEntity getJournalEntity() { if (_journalEntity == null) - _journalEntity = data.select(JournalEntity.class).where(JournalEntity.UID.eq(journal.getUid())).limit(1).get().first(); + _journalEntity = JournalModel.Journal.fetch(data, info.getServiceEntity(data), info.uid); return _journalEntity; } diff --git a/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.java b/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.java index d18fc7c6..12b84ea0 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.java @@ -24,8 +24,6 @@ import android.content.Intent; import android.content.Loader; import android.content.ServiceConnection; import android.content.SyncStatusObserver; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; @@ -51,15 +49,17 @@ import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.TextView; +import com.etesync.syncadapter.AccountSettings; import com.etesync.syncadapter.AccountUpdateService; import com.etesync.syncadapter.App; import com.etesync.syncadapter.Constants; import com.etesync.syncadapter.R; +import com.etesync.syncadapter.journalmanager.Crypto; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.ServiceDB.OpenHelper; -import com.etesync.syncadapter.model.ServiceDB.Services; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.resource.LocalCalendar; +import com.etesync.syncadapter.ui.setup.SetupUserInfoFragment; import com.etesync.syncadapter.utils.HintManager; import com.etesync.syncadapter.utils.ShowcaseBuilder; @@ -71,7 +71,6 @@ import at.bitfire.cert4android.CustomCertManager; import at.bitfire.ical4android.TaskProvider; import io.requery.Persistable; import io.requery.sql.EntityDataStore; -import lombok.Cleanup; import tourguide.tourguide.ToolTip; import static android.content.ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE; @@ -108,7 +107,7 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu tbCalDAV.setOnMenuItemClickListener(this); tbCalDAV.setTitle(R.string.settings_caldav); - // load CardDAV/CalDAV collections + // load CardDAV/CalDAV journals getLoaderManager().initLoader(0, getIntent().getExtras(), this); if (!HintManager.getHintSeen(this, HINT_VIEW_COLLECTION)) { @@ -117,6 +116,10 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu .playOn(tbCardDAV); HintManager.setHintSeen(this, HINT_VIEW_COLLECTION, true); } + + if (!SetupUserInfoFragment.hasUserInfo(this, account)) { + SetupUserInfoFragment.newInstance(account).show(getSupportFragmentManager(), null); + } } @Override @@ -166,6 +169,19 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu }) .show(); break; + case R.id.show_fingerprint: + AlertDialog dialog = new AlertDialog.Builder(AccountActivity.this) + .setIcon(R.drawable.ic_fingerprint_dark) + .setTitle(R.string.show_fingperprint_title) + .setMessage(getFormattedFingerprint()) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).create(); + dialog.show(); + break; default: return super.onOptionsItemSelected(item); } @@ -188,13 +204,25 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu @Override public void onItemClick(AdapterView parent, View view, int position, long id) { final ListView list = (ListView)parent; - final ArrayAdapter adapter = (ArrayAdapter)list.getAdapter(); - final CollectionInfo info = adapter.getItem(position); + final ArrayAdapter adapter = (ArrayAdapter)list.getAdapter(); + final JournalEntity journalEntity = adapter.getItem(position); + final CollectionInfo info = journalEntity.getInfo(); startActivity(ViewCollectionActivity.newIntent(AccountActivity.this, account, info)); } }; + private String getFormattedFingerprint() { + AccountSettings settings = null; + try { + settings = new AccountSettings(this, account); + return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.getKeyPair().getPublicKey()); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + /* LOADERS AND LOADED DATA */ protected static class AccountInfo { @@ -204,7 +232,7 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu long id; boolean refreshing; - List collections; + List journals; } } @@ -231,8 +259,8 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu listCardDAV.setEnabled(!info.carddav.refreshing); listCardDAV.setAlpha(info.carddav.refreshing ? 0.5f : 1); - AddressBookAdapter adapter = new AddressBookAdapter(this); - adapter.addAll(info.carddav.collections); + final CollectionListAdapter adapter = new CollectionListAdapter(this, account); + adapter.addAll(info.carddav.journals); listCardDAV.setAdapter(adapter); listCardDAV.setOnItemClickListener(onItemClickListener); } else @@ -247,8 +275,8 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu listCalDAV.setEnabled(!info.caldav.refreshing); listCalDAV.setAlpha(info.caldav.refreshing ? 0.5f : 1); - final CalendarAdapter adapter = new CalendarAdapter(this); - adapter.addAll(info.caldav.collections); + final CollectionListAdapter adapter = new CollectionListAdapter(this, account); + adapter.addAll(info.caldav.journals); listCalDAV.setAdapter(adapter); listCalDAV.setOnItemClickListener(onItemClickListener); } else @@ -318,31 +346,23 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu public AccountInfo loadInBackground() { AccountInfo info = new AccountInfo(); - @Cleanup OpenHelper dbHelper = new OpenHelper(getContext()); - SQLiteDatabase db = dbHelper.getReadableDatabase(); EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); - @Cleanup Cursor cursor = db.query( - Services._TABLE, - new String[] { Services.ID, Services.SERVICE }, - Services.ACCOUNT_NAME + "=?", new String[] { account.name }, - null, null, null); - - while (cursor.moveToNext()) { - long id = cursor.getLong(0); - String service = cursor.getString(1); - if (Services.SERVICE_CARDDAV.equals(service)) { + for (ServiceEntity serviceEntity : data.select(ServiceEntity.class).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) { + long id = serviceEntity.getId(); + CollectionInfo.Type service = serviceEntity.getType(); + if (service.equals(CollectionInfo.Type.ADDRESS_BOOK)) { info.carddav = new AccountInfo.ServiceInfo(); info.carddav.id = id; info.carddav.refreshing = (davService != null && davService.isRefreshing(id)) || ContentResolver.isSyncActive(account, ContactsContract.AUTHORITY); - info.carddav.collections = JournalEntity.getCollections(data, id); - } else if (Services.SERVICE_CALDAV.equals(service)) { + info.carddav.journals = JournalEntity.getJournals(data, serviceEntity); + } else if (service.equals(CollectionInfo.Type.CALENDAR)) { info.caldav = new AccountInfo.ServiceInfo(); info.caldav.id = id; info.caldav.refreshing = (davService != null && davService.isRefreshing(id)) || ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) || ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority); - info.caldav.collections = JournalEntity.getCollections(data, id); + info.caldav.journals = JournalEntity.getJournals(data, serviceEntity); } } return info; @@ -352,17 +372,21 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu /* LIST ADAPTERS */ - public static class AddressBookAdapter extends ArrayAdapter { - public AddressBookAdapter(Context context) { - super(context, R.layout.account_carddav_item); + public static class CollectionListAdapter extends ArrayAdapter { + private Account account; + + public CollectionListAdapter(Context context, Account account) { + super(context, R.layout.account_collection_item); + this.account = account; } @Override public View getView(int position, View v, ViewGroup parent) { if (v == null) - v = LayoutInflater.from(getContext()).inflate(R.layout.account_carddav_item, parent, false); + v = LayoutInflater.from(getContext()).inflate(R.layout.account_collection_item, parent, false); - final CollectionInfo info = getItem(position); + final JournalEntity journalEntity = getItem(position); + final CollectionInfo info = journalEntity.getInfo(); TextView tv = (TextView)v.findViewById(R.id.title); tv.setText(TextUtils.isEmpty(info.displayName) ? info.uid : info.displayName); @@ -375,45 +399,22 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu tv.setText(info.description); } - tv = (TextView)v.findViewById(R.id.read_only); - tv.setVisibility(info.readOnly ? View.VISIBLE : View.GONE); - - return v; - } - } - - public static class CalendarAdapter extends ArrayAdapter { - public CalendarAdapter(Context context) { - super(context, R.layout.account_caldav_item); - } - - @Override - public View getView(final int position, View v, ViewGroup parent) { - if (v == null) - v = LayoutInflater.from(getContext()).inflate(R.layout.account_caldav_item, parent, false); - - final CollectionInfo info = getItem(position); - - View vColor = v.findViewById(R.id.color); - if (info.color != null) { - vColor.setBackgroundColor(info.color); + final View vColor = v.findViewById(R.id.color); + if (info.type.equals(CollectionInfo.Type.ADDRESS_BOOK)) { + vColor.setVisibility(View.GONE); } else { - vColor.setBackgroundColor(LocalCalendar.defaultColor); + if (info.color != null) { + vColor.setBackgroundColor(info.color); + } else { + vColor.setBackgroundColor(LocalCalendar.defaultColor); + } } - TextView tv = (TextView)v.findViewById(R.id.title); - tv.setText(TextUtils.isEmpty(info.displayName) ? info.uid : info.displayName); + View readOnly = v.findViewById(R.id.read_only); + readOnly.setVisibility(info.readOnly ? View.VISIBLE : View.GONE); - tv = (TextView)v.findViewById(R.id.description); - if (TextUtils.isEmpty(info.description)) - tv.setVisibility(View.GONE); - else { - tv.setVisibility(View.VISIBLE); - tv.setText(info.description); - } - - tv = (TextView)v.findViewById(R.id.read_only); - tv.setVisibility(info.readOnly ? View.VISIBLE : View.GONE); + final View shared = v.findViewById(R.id.shared); + shared.setVisibility(account.name.equals(journalEntity.getOwner()) ? View.GONE : View.VISIBLE); return v; } diff --git a/app/src/main/java/com/etesync/syncadapter/ui/AddMemberFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/AddMemberFragment.java new file mode 100644 index 00000000..9e07848d --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/AddMemberFragment.java @@ -0,0 +1,172 @@ +package com.etesync.syncadapter.ui; + +import android.accounts.Account; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; + +import com.etesync.syncadapter.AccountSettings; +import com.etesync.syncadapter.Constants; +import com.etesync.syncadapter.HttpClient; +import com.etesync.syncadapter.InvalidAccountException; +import com.etesync.syncadapter.R; +import com.etesync.syncadapter.journalmanager.Crypto; +import com.etesync.syncadapter.journalmanager.JournalManager; +import com.etesync.syncadapter.journalmanager.UserInfoManager; +import com.etesync.syncadapter.model.CollectionInfo; + +import lombok.RequiredArgsConstructor; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; + +public class AddMemberFragment extends DialogFragment { + final static private String KEY_MEMBER = "memberEmail"; + private Account account; + private AccountSettings settings; + private OkHttpClient httpClient; + private HttpUrl remote; + private CollectionInfo info; + private String memberEmail; + private byte[] memberPubKey; + + public static AddMemberFragment newInstance(Account account, CollectionInfo info, String email) { + AddMemberFragment frag = new AddMemberFragment(); + Bundle args = new Bundle(1); + args.putParcelable(Constants.KEY_ACCOUNT, account); + args.putSerializable(Constants.KEY_COLLECTION_INFO, info); + args.putString(KEY_MEMBER, email); + frag.setArguments(args); + return frag; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + account = getArguments().getParcelable(Constants.KEY_ACCOUNT); + info = (CollectionInfo) getArguments().getSerializable(Constants.KEY_COLLECTION_INFO); + memberEmail = getArguments().getString(KEY_MEMBER); + try { + settings = new AccountSettings(getContext(), account); + httpClient = HttpClient.create(getContext(), account); + } catch (InvalidAccountException e) { + e.printStackTrace(); + } + remote = HttpUrl.get(settings.getUri()); + + new MemberAdd().execute(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ProgressDialog progress = new ProgressDialog(getContext()); + progress.setTitle(R.string.collection_members_adding); + progress.setMessage(getString(R.string.please_wait)); + progress.setIndeterminate(true); + progress.setCanceledOnTouchOutside(false); + setCancelable(false); + return progress; + } + + private class MemberAdd extends AsyncTask { + @Override + protected AddResult doInBackground(Void... voids) { + try { + UserInfoManager userInfoManager = new UserInfoManager(httpClient, remote); + + memberPubKey = userInfoManager.get(memberEmail).getPubkey(); + return new AddResult(null); + } catch (Exception e) { + return new AddResult(e); + } + } + + @Override + protected void onPostExecute(AddResult result) { + if (result.throwable == null) { + String fingerprint = Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(memberPubKey); + + new AlertDialog.Builder(getActivity()) + .setIcon(R.drawable.ic_info_dark) + .setTitle(R.string.trust_fingerprint_title) + .setMessage(fingerprint) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new MemberAddSecond().execute(); + } + }) + .setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + } + }).show(); + } else { + new AlertDialog.Builder(getActivity()) + .setIcon(R.drawable.ic_error_dark) + .setTitle(R.string.collection_members_add_error) + .setMessage(result.throwable.getMessage()) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).show(); + dismiss(); + } + } + + @RequiredArgsConstructor + class AddResult { + final Throwable throwable; + } + } + + private class MemberAddSecond extends AsyncTask { + @Override + protected AddResultSecond doInBackground(Void... voids) { + try { + JournalManager journalsManager = new JournalManager(httpClient, remote); + + JournalManager.Journal journal = JournalManager.Journal.fakeWithUid(info.uid); + Crypto.CryptoManager crypto = new Crypto.CryptoManager(info.version, settings.password(), info.uid); + + byte[] encryptedKey = crypto.getEncryptedKey(settings.getKeyPair(), memberPubKey); + JournalManager.Member member = new JournalManager.Member(memberEmail, encryptedKey); + journalsManager.addMember(journal, member); + return new AddResultSecond(null); + } catch (Exception e) { + return new AddResultSecond(e); + } + } + + @Override + protected void onPostExecute(AddResultSecond result) { + if (result.throwable == null) { + ((Refreshable) getActivity()).refresh(); + } else { + new AlertDialog.Builder(getActivity()) + .setIcon(R.drawable.ic_error_dark) + .setTitle(R.string.collection_members_add_error) + .setMessage(result.throwable.getMessage()) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).show(); + } + dismiss(); + } + + @RequiredArgsConstructor + class AddResultSecond { + final Throwable throwable; + } + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersActivity.java b/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersActivity.java new file mode 100644 index 00000000..eccc77fe --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersActivity.java @@ -0,0 +1,153 @@ +package com.etesync.syncadapter.ui; + +import android.accounts.Account; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.text.InputType; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import com.etesync.syncadapter.App; +import com.etesync.syncadapter.R; +import com.etesync.syncadapter.model.CollectionInfo; +import com.etesync.syncadapter.model.JournalEntity; +import com.etesync.syncadapter.resource.LocalCalendar; + +import io.requery.Persistable; +import io.requery.sql.EntityDataStore; + +public class CollectionMembersActivity extends AppCompatActivity implements Refreshable { + public final static String EXTRA_ACCOUNT = "account", + EXTRA_COLLECTION_INFO = "collectionInfo"; + + private Account account; + private JournalEntity journalEntity; + private CollectionMembersListFragment listFragment; + protected CollectionInfo info; + + public static Intent newIntent(Context context, Account account, CollectionInfo info) { + Intent intent = new Intent(context, CollectionMembersActivity.class); + intent.putExtra(CollectionMembersActivity.EXTRA_ACCOUNT, account); + intent.putExtra(CollectionMembersActivity.EXTRA_COLLECTION_INFO, info); + return intent; + } + + @Override + public void refresh() { + EntityDataStore data = ((App) getApplicationContext()).getData(); + + journalEntity = JournalEntity.fetch(data, info.getServiceEntity(data), info.uid); + if ((journalEntity == null) || journalEntity.isDeleted()) { + finish(); + return; + } + + info = journalEntity.getInfo(); + + setTitle(R.string.collection_members_title); + + final View colorSquare = findViewById(R.id.color); + if (info.type == CollectionInfo.Type.CALENDAR) { + if (info.color != null) { + colorSquare.setBackgroundColor(info.color); + } else { + colorSquare.setBackgroundColor(LocalCalendar.defaultColor); + } + } else { + colorSquare.setVisibility(View.GONE); + } + findViewById(R.id.progressBar).setVisibility(View.GONE); + + final TextView title = (TextView) findViewById(R.id.display_name); + title.setText(info.displayName); + + final TextView desc = (TextView) findViewById(R.id.description); + desc.setText(info.description); + + if (listFragment != null) { + listFragment.refresh(); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.view_collection_members); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + account = getIntent().getExtras().getParcelable(EXTRA_ACCOUNT); + info = (CollectionInfo) getIntent().getExtras().getSerializable(EXTRA_COLLECTION_INFO); + + refresh(); + + // We refresh before this, so we don't refresh the list before it was fully created. + if (savedInstanceState == null) { + listFragment = CollectionMembersListFragment.newInstance(account, info); + getSupportFragmentManager().beginTransaction() + .add(R.id.list_entries_container, listFragment) + .commit(); + } + } + + public void onAddMemberClicked(View v) { + final EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + AlertDialog.Builder dialog = new AlertDialog.Builder(this) + .setTitle(R.string.collection_members_add) + .setIcon(R.drawable.ic_account_add_dark) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + DialogFragment frag = AddMemberFragment.newInstance(account, info, input.getText().toString()); + frag.show(getSupportFragmentManager(), null); + } + }) + .setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + } + }); + dialog.setView(input); + dialog.show(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + if (!getSupportFragmentManager().popBackStackImmediate()) { + finish(); + } + return true; + } + return false; + } + + @Override + protected void onResume() { + super.onResume(); + + App app = (App) getApplicationContext(); + if (app.getCertManager() != null) + app.getCertManager().appInForeground = true; + + refresh(); + } + + @Override + protected void onPause() { + super.onPause(); + + App app = (App) getApplicationContext(); + if (app.getCertManager() != null) + app.getCertManager().appInForeground = false; + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.java new file mode 100644 index 00000000..73559d7b --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.java @@ -0,0 +1,164 @@ +package com.etesync.syncadapter.ui; + +import android.accounts.Account; +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.ListFragment; +import android.support.v7.app.AlertDialog; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import com.etesync.syncadapter.AccountSettings; +import com.etesync.syncadapter.App; +import com.etesync.syncadapter.Constants; +import com.etesync.syncadapter.HttpClient; +import com.etesync.syncadapter.R; +import com.etesync.syncadapter.journalmanager.JournalManager; +import com.etesync.syncadapter.model.CollectionInfo; +import com.etesync.syncadapter.model.EntryEntity; +import com.etesync.syncadapter.model.JournalEntity; +import com.etesync.syncadapter.model.JournalModel; + +import java.util.List; + +import io.requery.Persistable; +import io.requery.sql.EntityDataStore; +import lombok.RequiredArgsConstructor; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; + +public class CollectionMembersListFragment extends ListFragment implements AdapterView.OnItemClickListener, Refreshable { + private EntityDataStore data; + private Account account; + private CollectionInfo info; + private JournalEntity journalEntity; + + private TextView emptyTextView; + + public static CollectionMembersListFragment newInstance(Account account, CollectionInfo info) { + CollectionMembersListFragment frag = new CollectionMembersListFragment(); + Bundle args = new Bundle(1); + args.putParcelable(Constants.KEY_ACCOUNT, account); + args.putSerializable(Constants.KEY_COLLECTION_INFO, info); + frag.setArguments(args); + return frag; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + data = ((App) getContext().getApplicationContext()).getData(); + account = getArguments().getParcelable(Constants.KEY_ACCOUNT); + info = (CollectionInfo) getArguments().getSerializable(Constants.KEY_COLLECTION_INFO); + journalEntity = JournalModel.Journal.fetch(data, info.getServiceEntity(data), info.uid); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.collection_members_list, container, false); + + //This is instead of setEmptyText() function because of Google bug + //See: https://code.google.com/p/android/issues/detail?id=21742 + emptyTextView = (TextView) view.findViewById(android.R.id.empty); + return view; + } + + @Override + public void refresh() { + new JournalMembersFetch().execute(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + refresh(); + + getListView().setOnItemClickListener(this); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final JournalManager.Member member = (JournalManager.Member) getListAdapter().getItem(position); + + new AlertDialog.Builder(getActivity()) + .setIcon(R.drawable.ic_info_dark) + .setTitle(R.string.collection_members_remove_title) + .setMessage(getString(R.string.collection_members_remove, member.getUser())) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + DialogFragment frag = RemoveMemberFragment.newInstance(account, info, member.getUser()); + frag.show(getFragmentManager(), null); + } + }) + .setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + } + }).show(); + } + + class MembersListAdapter extends ArrayAdapter { + MembersListAdapter(Context context) { + super(context, R.layout.collection_members_list_item); + } + + @Override + @NonNull + public View getView(int position, View v, @NonNull ViewGroup parent) { + if (v == null) + v = LayoutInflater.from(getContext()).inflate(R.layout.collection_members_list_item, parent, false); + + JournalManager.Member member = getItem(position); + + TextView tv = (TextView) v.findViewById(R.id.title); + tv.setText(member.getUser()); + return v; + } + } + + private class JournalMembersFetch extends AsyncTask { + @Override + protected MembersResult doInBackground(Void... voids) { + try { + OkHttpClient httpClient = HttpClient.create(getContext(), account); + AccountSettings settings = new AccountSettings(getContext(), account); + JournalManager journalsManager = new JournalManager(httpClient, HttpUrl.get(settings.getUri())); + + JournalManager.Journal journal = JournalManager.Journal.fakeWithUid(journalEntity.getUid()); + return new MembersResult(journalsManager.listMembers(journal), null); + } catch (Exception e) { + return new MembersResult(null, e); + } + } + + @Override + protected void onPostExecute(MembersResult result) { + if (result.throwable == null) { + MembersListAdapter listAdapter = new MembersListAdapter(getContext()); + setListAdapter(listAdapter); + + listAdapter.addAll(result.members); + + emptyTextView.setText(R.string.collection_members_list_empty); + } else { + emptyTextView.setText(result.throwable.getLocalizedMessage()); + } + } + + @RequiredArgsConstructor + class MembersResult { + final List members; + final Throwable throwable; + } + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/ui/CreateCollectionFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/CreateCollectionFragment.java index 4e86c044..fdaa671e 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/CreateCollectionFragment.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/CreateCollectionFragment.java @@ -35,7 +35,9 @@ import com.etesync.syncadapter.journalmanager.Exceptions; import com.etesync.syncadapter.journalmanager.JournalManager; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; +import com.etesync.syncadapter.model.JournalModel; import com.etesync.syncadapter.model.ServiceDB; +import com.etesync.syncadapter.model.ServiceEntity; import io.requery.Persistable; import io.requery.sql.EntityDataStore; @@ -125,31 +127,21 @@ public class CreateCollectionFragment extends DialogFragment implements LoaderMa @Override public Exception loadInBackground() { - ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); - try { String authority = null; - // now insert collection into database: - SQLiteDatabase db = dbHelper.getWritableDatabase(); + + EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); // 1. find service ID - String serviceType; if (info.type == CollectionInfo.Type.ADDRESS_BOOK) { - serviceType = ServiceDB.Services.SERVICE_CARDDAV; authority = ContactsContract.AUTHORITY; } else if (info.type == CollectionInfo.Type.CALENDAR) { - serviceType = ServiceDB.Services.SERVICE_CALDAV; authority = CalendarContract.AUTHORITY; } else { throw new IllegalArgumentException("Collection must be an address book or calendar"); } - @Cleanup Cursor c = db.query(ServiceDB.Services._TABLE, new String[]{ServiceDB.Services.ID}, - ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", - new String[]{account.name, serviceType}, null, null, null - ); - if (!c.moveToNext()) - throw new IllegalStateException(); - long serviceID = c.getLong(0); + + ServiceEntity serviceEntity = JournalModel.Service.fetch(data, account.name, info.type); AccountSettings settings = new AccountSettings(getContext(), account); HttpUrl principal = HttpUrl.get(settings.getUri()); @@ -167,8 +159,7 @@ public class CreateCollectionFragment extends DialogFragment implements LoaderMa } // 2. add collection to service - EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); - info.serviceID = serviceID; + info.serviceID = serviceEntity.getId(); JournalEntity journalEntity = JournalEntity.fetchOrCreate(data, info); data.upsert(journalEntity); @@ -180,8 +171,6 @@ public class CreateCollectionFragment extends DialogFragment implements LoaderMa return e; } catch (Exceptions.IntegrityException|Exceptions.GenericCryptoException e) { return e; - } finally { - dbHelper.close(); } return null; diff --git a/app/src/main/java/com/etesync/syncadapter/ui/DebugInfoActivity.java b/app/src/main/java/com/etesync/syncadapter/ui/DebugInfoActivity.java index 25178b54..f26eaeba 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/DebugInfoActivity.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/DebugInfoActivity.java @@ -49,6 +49,7 @@ import com.etesync.syncadapter.journalmanager.Exceptions.HttpException; import com.etesync.syncadapter.model.EntryEntity; import com.etesync.syncadapter.model.JournalEntity; import com.etesync.syncadapter.model.ServiceDB; +import com.etesync.syncadapter.model.ServiceEntity; import io.requery.Persistable; import io.requery.sql.EntityDataStore; @@ -246,8 +247,14 @@ public class DebugInfoActivity extends AppCompatActivity implements LoaderManage dbHelper.dump(report); report.append("\n"); - report.append("JOURNALS DUMP\n"); + report.append("SERVICES DUMP\n"); EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); + for (ServiceEntity serviceEntity : data.select(ServiceEntity.class).get()) { + report.append(serviceEntity.toString() + "\n"); + } + report.append("\n"); + + report.append("JOURNALS DUMP\n"); List journals = data.select(JournalEntity.class).where(JournalEntity.DELETED.eq(false)).get().toList(); for (JournalEntity journal : journals) { report.append(journal.toString() + "\n"); diff --git a/app/src/main/java/com/etesync/syncadapter/ui/DeleteCollectionFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/DeleteCollectionFragment.java index fcf93e60..88cc560f 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/DeleteCollectionFragment.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/DeleteCollectionFragment.java @@ -123,7 +123,7 @@ public class DeleteCollectionFragment extends DialogFragment implements LoaderMa Crypto.CryptoManager crypto = new Crypto.CryptoManager(collectionInfo.version, settings.password(), collectionInfo.uid); journalManager.deleteJournal(new JournalManager.Journal(crypto, collectionInfo.toJson(), collectionInfo.uid)); - JournalEntity journalEntity = JournalEntity.fetch(data, collectionInfo.uid); + JournalEntity journalEntity = JournalEntity.fetch(data, collectionInfo.getServiceEntity(data), collectionInfo.uid); journalEntity.setDeleted(true); data.update(journalEntity); diff --git a/app/src/main/java/com/etesync/syncadapter/ui/EditCollectionActivity.java b/app/src/main/java/com/etesync/syncadapter/ui/EditCollectionActivity.java index ac96e87e..20908bb7 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/EditCollectionActivity.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/EditCollectionActivity.java @@ -78,7 +78,7 @@ public class EditCollectionActivity extends CreateCollectionActivity { public void onDeleteCollection(MenuItem item) { EntityDataStore data = ((App) getApplication()).getData(); - int journalCount = data.count(JournalEntity.class).where(JournalEntity.SERVICE.eq(info.serviceID)).get().value(); + int journalCount = data.count(JournalEntity.class).where(JournalEntity.SERVICE_MODEL.eq(info.getServiceEntity(data))).get().value(); if (journalCount < 2) { new AlertDialog.Builder(this) diff --git a/app/src/main/java/com/etesync/syncadapter/ui/RemoveMemberFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/RemoveMemberFragment.java new file mode 100644 index 00000000..a8191238 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/RemoveMemberFragment.java @@ -0,0 +1,116 @@ +package com.etesync.syncadapter.ui; + +import android.accounts.Account; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; + +import com.etesync.syncadapter.AccountSettings; +import com.etesync.syncadapter.Constants; +import com.etesync.syncadapter.HttpClient; +import com.etesync.syncadapter.InvalidAccountException; +import com.etesync.syncadapter.R; +import com.etesync.syncadapter.journalmanager.Crypto; +import com.etesync.syncadapter.journalmanager.JournalManager; +import com.etesync.syncadapter.journalmanager.UserInfoManager; +import com.etesync.syncadapter.model.CollectionInfo; + +import org.apache.commons.codec.Charsets; + +import lombok.RequiredArgsConstructor; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; + +public class RemoveMemberFragment extends DialogFragment { + final static private String KEY_MEMBER = "memberEmail"; + private AccountSettings settings; + private OkHttpClient httpClient; + private HttpUrl remote; + private CollectionInfo info; + private String memberEmail; + + public static RemoveMemberFragment newInstance(Account account, CollectionInfo info, String email) { + RemoveMemberFragment frag = new RemoveMemberFragment(); + Bundle args = new Bundle(1); + args.putParcelable(Constants.KEY_ACCOUNT, account); + args.putSerializable(Constants.KEY_COLLECTION_INFO, info); + args.putString(KEY_MEMBER, email); + frag.setArguments(args); + return frag; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Account account = getArguments().getParcelable(Constants.KEY_ACCOUNT); + info = (CollectionInfo) getArguments().getSerializable(Constants.KEY_COLLECTION_INFO); + memberEmail = getArguments().getString(KEY_MEMBER); + try { + settings = new AccountSettings(getContext(), account); + httpClient = HttpClient.create(getContext(), account); + } catch (InvalidAccountException e) { + e.printStackTrace(); + } + remote = HttpUrl.get(settings.getUri()); + + new MemberRemove().execute(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ProgressDialog progress = new ProgressDialog(getContext()); + progress.setTitle(R.string.collection_members_removing); + progress.setMessage(getString(R.string.please_wait)); + progress.setIndeterminate(true); + progress.setCanceledOnTouchOutside(false); + setCancelable(false); + return progress; + } + + private class MemberRemove extends AsyncTask { + @Override + protected RemoveResult doInBackground(Void... voids) { + try { + JournalManager journalsManager = new JournalManager(httpClient, remote); + JournalManager.Journal journal = JournalManager.Journal.fakeWithUid(info.uid); + + JournalManager.Member member = new JournalManager.Member(memberEmail, "placeholder".getBytes(Charsets.UTF_8)); + journalsManager.deleteMember(journal, member); + + return new RemoveResult(null); + } catch (Exception e) { + return new RemoveResult(e); + } + } + + @Override + protected void onPostExecute(RemoveResult result) { + if (result.throwable == null) { + ((Refreshable) getActivity()).refresh(); + } else { + new AlertDialog.Builder(getActivity()) + .setIcon(R.drawable.ic_error_dark) + .setTitle(R.string.collection_members_remove_error) + .setMessage(result.throwable.getMessage()) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).show(); + } + dismiss(); + } + + @RequiredArgsConstructor + class RemoveResult { + final Throwable throwable; + } + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java b/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java index 29c9206f..02febe81 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java @@ -10,11 +10,13 @@ package com.etesync.syncadapter.ui; import android.accounts.Account; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; import android.provider.CalendarContract; import android.provider.ContactsContract; +import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.view.Gravity; import android.view.Menu; @@ -50,7 +52,9 @@ public class ViewCollectionActivity extends AppCompatActivity implements Refresh EXTRA_COLLECTION_INFO = "collectionInfo"; private Account account; + private JournalEntity journalEntity; protected CollectionInfo info; + private boolean isOwner; public static Intent newIntent(Context context, Account account, CollectionInfo info) { Intent intent = new Intent(context, ViewCollectionActivity.class); @@ -63,13 +67,14 @@ public class ViewCollectionActivity extends AppCompatActivity implements Refresh public void refresh() { EntityDataStore data = ((App) getApplicationContext()).getData(); - final JournalEntity journalEntity = JournalEntity.fetch(data, info.uid); + journalEntity = JournalEntity.fetch(data, info.getServiceEntity(data), info.uid); if ((journalEntity == null) || journalEntity.isDeleted()) { finish(); return; } info = journalEntity.getInfo(); + isOwner = account.name.equals(journalEntity.getOwner()); final View colorSquare = findViewById(R.id.color); if (info.type == CollectionInfo.Type.CALENDAR) { @@ -89,6 +94,13 @@ public class ViewCollectionActivity extends AppCompatActivity implements Refresh final TextView desc = (TextView) findViewById(R.id.description); desc.setText(info.description); + + final TextView owner = (TextView) findViewById(R.id.owner); + if (account.name.equals(journalEntity.getOwner())) { + owner.setVisibility(View.GONE); + } else { + owner.setText(getString(R.string.account_owner, journalEntity.getOwner())); + } } @Override @@ -159,13 +171,69 @@ public class ViewCollectionActivity extends AppCompatActivity implements Refresh } public void onEditCollection(MenuItem item) { - startActivity(EditCollectionActivity.newIntent(this, account, info)); + if (isOwner) { + startActivity(EditCollectionActivity.newIntent(this, account, info)); + } else { + AlertDialog dialog = new AlertDialog.Builder(this) + .setIcon(R.drawable.ic_info_dark) + .setTitle(R.string.not_allowed_title) + .setMessage(getString(R.string.edit_owner_only, journalEntity.getOwner())) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).create(); + dialog.show(); + } } public void onImport(MenuItem item) { startActivity(ImportActivity.newIntent(ViewCollectionActivity.this, account, info)); } + public void onManageMembers(MenuItem item) { + if (info.type.equals(CollectionInfo.Type.ADDRESS_BOOK)) { + AlertDialog dialog = new AlertDialog.Builder(this) + .setIcon(R.drawable.ic_info_dark) + .setTitle(R.string.not_allowed_title) + .setMessage(R.string.members_address_book_not_allowed) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).create(); + dialog.show(); + } else if (info.version < 2) { + AlertDialog dialog = new AlertDialog.Builder(this) + .setIcon(R.drawable.ic_info_dark) + .setTitle(R.string.not_allowed_title) + .setMessage(R.string.members_old_journals_not_allowed) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).create(); + dialog.show(); + } else if (isOwner) { + startActivity(CollectionMembersActivity.newIntent(this, account, info)); + } else { + AlertDialog dialog = new AlertDialog.Builder(this) + .setIcon(R.drawable.ic_info_dark) + .setTitle(R.string.not_allowed_title) + .setMessage(getString(R.string.members_owner_only, journalEntity.getOwner())) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + } + }).create(); + dialog.show(); + } + } + private class LoadCountTask extends AsyncTask { private int entryCount; @@ -173,7 +241,7 @@ public class ViewCollectionActivity extends AppCompatActivity implements Refresh protected Long doInBackground(Void... aVoids) { EntityDataStore data = ((App) getApplicationContext()).getData(); - final JournalEntity journalEntity = JournalEntity.fetch(data, info.uid); + final JournalEntity journalEntity = JournalEntity.fetch(data, info.getServiceEntity(data), info.uid); entryCount = data.count(EntryEntity.class).where(EntryEntity.JOURNAL.eq(journalEntity)).get().value(); long count; diff --git a/app/src/main/java/com/etesync/syncadapter/ui/journalviewer/ListEntriesFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/journalviewer/ListEntriesFragment.java index c525245a..87feec28 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/journalviewer/ListEntriesFragment.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/journalviewer/ListEntriesFragment.java @@ -137,7 +137,7 @@ public class ListEntriesFragment extends ListFragment implements AdapterView.OnI @Override protected List doInBackground(Void... voids) { - journalEntity = JournalModel.Journal.fetch(data, info.uid); + journalEntity = JournalModel.Journal.fetch(data, info.getServiceEntity(data), info.uid); return data.select(EntryEntity.class).where(EntryEntity.JOURNAL.eq(journalEntity)).orderBy(EntryEntity.ID.desc()).get().toList(); } diff --git a/app/src/main/java/com/etesync/syncadapter/ui/setup/BaseConfigurationFinder.java b/app/src/main/java/com/etesync/syncadapter/ui/setup/BaseConfigurationFinder.java index 33200efe..23110052 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/setup/BaseConfigurationFinder.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/setup/BaseConfigurationFinder.java @@ -19,6 +19,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import com.etesync.syncadapter.HttpClient; +import com.etesync.syncadapter.journalmanager.Crypto; import com.etesync.syncadapter.journalmanager.Exceptions; import com.etesync.syncadapter.journalmanager.JournalAuthenticator; import com.etesync.syncadapter.log.StringHandler; @@ -99,6 +100,7 @@ public class BaseConfigurationFinder { public final String userName, authtoken; public String rawPassword; public String password; + public Crypto.AsymmetricKeyPair keyPair; public final ServiceInfo cardDAV; public final ServiceInfo calDAV; diff --git a/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupEncryptionFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupEncryptionFragment.java index cf1cafa8..163b0682 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupEncryptionFragment.java +++ b/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupEncryptionFragment.java @@ -14,7 +14,6 @@ import android.app.Activity; import android.app.Dialog; import android.app.ProgressDialog; import android.content.ContentResolver; -import android.content.ContentValues; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.os.Bundle; @@ -26,23 +25,30 @@ import android.support.v4.app.LoaderManager; import android.support.v4.content.AsyncTaskLoader; import android.support.v4.content.Loader; -import java.util.logging.Level; - import com.etesync.syncadapter.AccountSettings; import com.etesync.syncadapter.App; import com.etesync.syncadapter.Constants; +import com.etesync.syncadapter.HttpClient; import com.etesync.syncadapter.InvalidAccountException; import com.etesync.syncadapter.R; import com.etesync.syncadapter.journalmanager.Crypto; +import com.etesync.syncadapter.journalmanager.Exceptions; +import com.etesync.syncadapter.journalmanager.UserInfoManager; import com.etesync.syncadapter.model.CollectionInfo; import com.etesync.syncadapter.model.JournalEntity; import com.etesync.syncadapter.model.ServiceDB; +import com.etesync.syncadapter.model.ServiceEntity; import com.etesync.syncadapter.resource.LocalTaskList; import com.etesync.syncadapter.ui.setup.BaseConfigurationFinder.Configuration; + +import java.util.logging.Level; + import at.bitfire.ical4android.TaskProvider; import io.requery.Persistable; import io.requery.sql.EntityDataStore; import lombok.Cleanup; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; public class SetupEncryptionFragment extends DialogFragment implements LoaderManager.LoaderCallbacks { private static final String KEY_CONFIG = "config"; @@ -113,6 +119,27 @@ public class SetupEncryptionFragment extends DialogFragment implements LoaderMan @Override public Configuration loadInBackground() { config.password = Crypto.deriveKey(config.userName, config.rawPassword); + + try { + Crypto.CryptoManager cryptoManager; + OkHttpClient httpClient = HttpClient.create(getContext(), config.authtoken); + + UserInfoManager userInfoManager = new UserInfoManager(httpClient, HttpUrl.get(config.url)); + UserInfoManager.UserInfo userInfo = userInfoManager.get(config.userName); + if (userInfo != null) { + App.log.info("Fetched userInfo for " + config.userName); + cryptoManager = new Crypto.CryptoManager(userInfo.getVersion(), config.password, "userInfo"); + userInfo.verify(cryptoManager); + config.keyPair = new Crypto.AsymmetricKeyPair(userInfo.getContent(cryptoManager), userInfo.getPubkey()); + } + } catch (Exceptions.HttpException e) { + e.printStackTrace(); + } catch (Exceptions.IntegrityException e) { + e.printStackTrace(); + } catch (Exceptions.VersionTooNewException e) { + e.printStackTrace(); + } + return config; } } @@ -131,16 +158,17 @@ public class SetupEncryptionFragment extends DialogFragment implements LoaderMan // add entries for account to service DB App.log.log(Level.INFO, "Writing account configuration to database", config); - @Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); - SQLiteDatabase db = dbHelper.getWritableDatabase(); try { AccountSettings settings = new AccountSettings(getContext(), account); settings.setAuthToken(config.authtoken); + if (config.keyPair != null) { + settings.setKeyPair(config.keyPair); + } if (config.cardDAV != null) { // insert CardDAV service - insertService(db, accountName, ServiceDB.Services.SERVICE_CARDDAV, config.cardDAV); + insertService(accountName, CollectionInfo.Type.ADDRESS_BOOK, config.cardDAV); // contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml settings.setSyncInterval(ContactsContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL); @@ -150,7 +178,7 @@ public class SetupEncryptionFragment extends DialogFragment implements LoaderMan if (config.calDAV != null) { // insert CalDAV service - insertService(db, accountName, ServiceDB.Services.SERVICE_CALDAV, config.calDAV); + insertService(accountName, CollectionInfo.Type.CALENDAR, config.calDAV); // calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml settings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL); @@ -172,22 +200,20 @@ public class SetupEncryptionFragment extends DialogFragment implements LoaderMan return true; } - protected long insertService(SQLiteDatabase db, String accountName, String service, BaseConfigurationFinder.Configuration.ServiceInfo info) { - ContentValues values = new ContentValues(); + protected void insertService(String accountName, CollectionInfo.Type serviceType, BaseConfigurationFinder.Configuration.ServiceInfo info) { + EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); // insert service - values.put(ServiceDB.Services.ACCOUNT_NAME, accountName); - values.put(ServiceDB.Services.SERVICE, service); - long serviceID = db.insertWithOnConflict(ServiceDB.Services._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE); - EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); + ServiceEntity serviceEntity = new ServiceEntity(); + serviceEntity.setAccount(accountName); + serviceEntity.setType(serviceType); + data.upsert(serviceEntity); // insert collections for (CollectionInfo collection : info.collections.values()) { - collection.serviceID = serviceID; - JournalEntity journalEntity = new JournalEntity(collection); + collection.serviceID = serviceEntity.getId(); + JournalEntity journalEntity = new JournalEntity(data, collection); data.insert(journalEntity); } - - return serviceID; } } diff --git a/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupUserInfoFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupUserInfoFragment.java new file mode 100644 index 00000000..997f810c --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/setup/SetupUserInfoFragment.java @@ -0,0 +1,146 @@ +package com.etesync.syncadapter.ui.setup; + +import android.accounts.Account; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.Fragment; +import android.support.v7.app.AlertDialog; +import android.widget.TextView; + +import com.etesync.syncadapter.AccountSettings; +import com.etesync.syncadapter.App; +import com.etesync.syncadapter.HttpClient; +import com.etesync.syncadapter.InvalidAccountException; +import com.etesync.syncadapter.R; +import com.etesync.syncadapter.journalmanager.Constants; +import com.etesync.syncadapter.journalmanager.Crypto; +import com.etesync.syncadapter.journalmanager.UserInfoManager; + +import lombok.RequiredArgsConstructor; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; + +import static com.etesync.syncadapter.Constants.KEY_ACCOUNT; + +public class SetupUserInfoFragment extends DialogFragment { + private Account account; + private AccountSettings settings; + + public static SetupUserInfoFragment newInstance(Account account) { + SetupUserInfoFragment frag = new SetupUserInfoFragment(); + Bundle args = new Bundle(1); + args.putParcelable(KEY_ACCOUNT, account); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ProgressDialog progress = new ProgressDialog(getActivity()); + progress.setTitle(R.string.login_encryption_setup_title); + progress.setMessage(getString(R.string.login_encryption_setup)); + progress.setIndeterminate(true); + progress.setCanceledOnTouchOutside(false); + setCancelable(false); + return progress; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + account = getArguments().getParcelable(KEY_ACCOUNT); + + try { + settings = new AccountSettings(getContext(), account); + } catch (Exception e) { + e.printStackTrace(); + } + + new SetupUserInfo().execute(account); + } + + public static boolean hasUserInfo(Context context, Account account) { + AccountSettings settings; + try { + settings = new AccountSettings(context, account); + } catch (InvalidAccountException e) { + e.printStackTrace(); + return false; + } + return settings.getKeyPair() != null; + } + + protected class SetupUserInfo extends AsyncTask { + ProgressDialog progressDialog; + + @Override + protected void onPreExecute() { + progressDialog = (ProgressDialog) getDialog(); + } + + @Override + protected SetupUserInfo.SetupUserInfoResult doInBackground(Account... accounts) { + try { + Crypto.CryptoManager cryptoManager; + OkHttpClient httpClient = HttpClient.create(getContext(), account); + + UserInfoManager userInfoManager = new UserInfoManager(httpClient, HttpUrl.get(settings.getUri())); + UserInfoManager.UserInfo userInfo = userInfoManager.get(account.name); + + if (userInfo == null) { + App.log.info("Creating userInfo for " + account.name); + cryptoManager = new Crypto.CryptoManager(Constants.CURRENT_VERSION, settings.password(), "userInfo"); + userInfo = UserInfoManager.UserInfo.generate(cryptoManager, account.name); + userInfoManager.create(userInfo); + } else { + App.log.info("Fetched userInfo for " + account.name); + cryptoManager = new Crypto.CryptoManager(userInfo.getVersion(), settings.password(), "userInfo"); + userInfo.verify(cryptoManager); + } + + Crypto.AsymmetricKeyPair keyPair = new Crypto.AsymmetricKeyPair(userInfo.getContent(cryptoManager), userInfo.getPubkey()); + + return new SetupUserInfoResult(keyPair, null); + } catch (Exception e) { + e.printStackTrace(); + return new SetupUserInfoResult(null, e); + } + } + + @Override + protected void onPostExecute(SetupUserInfoResult result) { + if (result.exception == null) { + settings.setKeyPair(result.keyPair); + } else { + Dialog dialog = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.login_encryption_error_title) + .setIcon(R.drawable.ic_error_dark) + .setMessage(result.exception.getLocalizedMessage()) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // dismiss + } + }) + .create(); + dialog.show(); + } + + dismissAllowingStateLoss(); + } + + @RequiredArgsConstructor + class SetupUserInfoResult { + final Crypto.AsymmetricKeyPair keyPair; + final Exception exception; + } + } +} diff --git a/app/src/main/res/drawable/ic_account_add_dark.xml b/app/src/main/res/drawable/ic_account_add_dark.xml new file mode 100644 index 00000000..d12a0502 --- /dev/null +++ b/app/src/main/res/drawable/ic_account_add_dark.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_fingerprint_dark.xml b/app/src/main/res/drawable/ic_fingerprint_dark.xml new file mode 100644 index 00000000..ec952639 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_members_dark.xml b/app/src/main/res/drawable/ic_members_dark.xml new file mode 100644 index 00000000..d5e14c6f --- /dev/null +++ b/app/src/main/res/drawable/ic_members_dark.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/account_carddav_item.xml b/app/src/main/res/layout/account_carddav_item.xml deleted file mode 100644 index ee0ff6d3..00000000 --- a/app/src/main/res/layout/account_carddav_item.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/account_caldav_item.xml b/app/src/main/res/layout/account_collection_item.xml similarity index 59% rename from app/src/main/res/layout/account_caldav_item.xml rename to app/src/main/res/layout/account_collection_item.xml index 8dc9a303..e832040f 100644 --- a/app/src/main/res/layout/account_caldav_item.xml +++ b/app/src/main/res/layout/account_collection_item.xml @@ -1,5 +1,4 @@ - - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingTop="8dp" + android:paddingBottom="8dp" + android:paddingLeft="12dp" + android:paddingRight="12dp"> + tools:text="Title" /> + tools:text="Description" /> - + + + android:layout_marginRight="8dp" + android:src="@drawable/ic_remove_circle_dark" /> + tools:background="@color/green700" /> \ No newline at end of file diff --git a/app/src/main/res/layout/collection_header.xml b/app/src/main/res/layout/collection_header.xml new file mode 100644 index 00000000..6c55f861 --- /dev/null +++ b/app/src/main/res/layout/collection_header.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/collection_members_list.xml b/app/src/main/res/layout/collection_members_list.xml new file mode 100644 index 00000000..7d1950d7 --- /dev/null +++ b/app/src/main/res/layout/collection_members_list.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/layout/collection_members_list_item.xml b/app/src/main/res/layout/collection_members_list_item.xml new file mode 100644 index 00000000..ab8fd805 --- /dev/null +++ b/app/src/main/res/layout/collection_members_list_item.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_collection_activity.xml b/app/src/main/res/layout/view_collection_activity.xml index 329badc0..652666f8 100644 --- a/app/src/main/res/layout/view_collection_activity.xml +++ b/app/src/main/res/layout/view_collection_activity.xml @@ -5,42 +5,11 @@ android:layout_height="match_parent" android:orientation="vertical"> - - - - - - - - - - - - - + android:layout_margin="@dimen/activity_margin" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/activity_account.xml b/app/src/main/res/menu/activity_account.xml index d591ff6f..96193221 100644 --- a/app/src/main/res/menu/activity_account.xml +++ b/app/src/main/res/menu/activity_account.xml @@ -20,6 +20,11 @@ android:title="@string/account_settings" app:showAsAction="ifRoom"/> + + diff --git a/app/src/main/res/menu/activity_view_collection.xml b/app/src/main/res/menu/activity_view_collection.xml index 2a83d62a..1eb4dd3d 100644 --- a/app/src/main/res/menu/activity_view_collection.xml +++ b/app/src/main/res/menu/activity_view_collection.xml @@ -15,6 +15,11 @@ android:onClick="onEditCollection" app:showAsAction="always" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc2f23b4..bff92d20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,7 @@ Synchronizing now Account settings Delete account + Show Fingerprint Really delete account? All local copies of address books, calendars and task lists will be deleted. Create new calendar @@ -97,9 +98,30 @@ Deleting the last collection is not allowed, please create a new one if you\'d like to delete this one. You can click on an item to view the collection. From there you can view the journal, import, and much more... + My Fingerprint + Change Journal In order to import contacts and calendars into EteSync, you need to click on the menu, and choose \"Import\". + Owner: %s + Only the owner of this collection (%s) is allowed to view its members. + Not Allowed + Only the owner of this collection (%s) is allowed to edit it. + Sharing of address books is currently not supported. + Sharing of old-style journals is not allowed. In order to share this journal, create a new one, and copy its contents over using the \"import\" dialog. If you are experiencing any issues, please contact support. + + + Members + Loading members... + No members + Add member + Error adding member + Adding member + Trust fingerprint? + Removing member + Error removing member + Remove member + Would you like to revoke %s\'s access?\nPlease be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information. EteSync permissions @@ -140,6 +162,10 @@ Setting up encryption Please wait, setting up encryption… + + Encryption Error + + Import Import Failed @@ -208,6 +234,7 @@ Description (optional): Edit Import + Manage Members Save Delete Are you sure? diff --git a/app/src/test/java/com/etesync/syncadapter/journalmanager/EncryptionTest.java b/app/src/test/java/com/etesync/syncadapter/journalmanager/EncryptionTest.java index 01883101..b0d65764 100644 --- a/app/src/test/java/com/etesync/syncadapter/journalmanager/EncryptionTest.java +++ b/app/src/test/java/com/etesync/syncadapter/journalmanager/EncryptionTest.java @@ -14,9 +14,11 @@ import org.apache.commons.codec.Charsets; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.spongycastle.util.encoders.Hex; import java.io.IOException; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; public class EncryptionTest { @@ -67,4 +69,19 @@ public class EncryptionTest { public void testCryptoVersionOutOfRange() throws Exceptions.IntegrityException, Exceptions.VersionTooNewException { new Crypto.CryptoManager(999, Helpers.keyBase64, "TestSaltShouldBeJournalId"); } + + @Test + public void testAsymCrypto() throws Exceptions.IntegrityException, Exceptions.GenericCryptoException { + Crypto.AsymmetricKeyPair keyPair = Crypto.generateKeyPair(); + Crypto.AsymmetricCryptoManager cryptoManager = new Crypto.AsymmetricCryptoManager(keyPair); + + byte[] clearText = "This Is Some Test Cleartext.".getBytes(Charsets.UTF_8); + byte[] cipher = cryptoManager.encrypt(keyPair.getPublicKey(), clearText); + byte[] clearText2 = cryptoManager.decrypt(cipher); + assertArrayEquals(clearText, clearText2); + + // Mostly for coverage. Make sure it's the expected sha256 value. + assertEquals("ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + Hex.toHexString(Crypto.AsymmetricCryptoManager.getKeyFingerprint("a".getBytes(Charsets.UTF_8))).toLowerCase()); + } } diff --git a/app/src/test/java/com/etesync/syncadapter/journalmanager/ServiceTest.java b/app/src/test/java/com/etesync/syncadapter/journalmanager/ServiceTest.java index 626bbf9a..9d9dc2ae 100644 --- a/app/src/test/java/com/etesync/syncadapter/journalmanager/ServiceTest.java +++ b/app/src/test/java/com/etesync/syncadapter/journalmanager/ServiceTest.java @@ -12,6 +12,7 @@ import com.etesync.syncadapter.App; import com.etesync.syncadapter.HttpClient; import com.etesync.syncadapter.model.CollectionInfo; +import org.apache.commons.codec.Charsets; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -31,6 +32,7 @@ import okio.BufferedSink; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; public class ServiceTest { private OkHttpClient httpClient; @@ -91,7 +93,7 @@ public class ServiceTest { } assertNotNull(caught); - List journals = journalManager.getJournals(Helpers.keyBase64); + List journals = journalManager.getJournals(); assertEquals(journals.size(), 1); CollectionInfo info2 = CollectionInfo.fromJson(journals.get(0).getContent(crypto)); assertEquals(info2.displayName, info.displayName); @@ -101,7 +103,7 @@ public class ServiceTest { journal = new JournalManager.Journal(crypto, info.toJson(), info.uid); journalManager.updateJournal(journal); - journals = journalManager.getJournals(Helpers.keyBase64); + journals = journalManager.getJournals(); assertEquals(journals.size(), 1); info2 = CollectionInfo.fromJson(journals.get(0).getContent(crypto)); assertEquals(info2.displayName, info.displayName); @@ -109,7 +111,7 @@ public class ServiceTest { // Delete journal journalManager.deleteJournal(journal); - journals = journalManager.getJournals(Helpers.keyBase64); + journals = journalManager.getJournals(); assertEquals(journals.size(), 0); // Bad HMAC @@ -122,7 +124,10 @@ public class ServiceTest { try { caught = null; - journalManager.getJournals(Helpers.keyBase64); + for (JournalManager.Journal journal1 : journalManager.getJournals()) { + Crypto.CryptoManager crypto1 = new Crypto.CryptoManager(info.version, Helpers.keyBase64, journal1.getUid()); + journal1.verify(crypto1); + } } catch (Exceptions.IntegrityException e) { caught = e; } @@ -196,4 +201,62 @@ public class ServiceTest { } assertNotNull(caught); } + + + @Test + public void testUserInfo() throws IOException, Exceptions.HttpException, Exceptions.GenericCryptoException, Exceptions.IntegrityException { + Crypto.CryptoManager cryptoManager = new Crypto.CryptoManager(Constants.CURRENT_VERSION, Helpers.keyBase64, "userInfo"); + UserInfoManager.UserInfo userInfo, userInfo2; + UserInfoManager manager = new UserInfoManager(httpClient, remote); + + // Get when there's nothing + userInfo = manager.get(Helpers.USER); + assertNull(userInfo); + + // Create + userInfo = UserInfoManager.UserInfo.generate(cryptoManager, Helpers.USER); + manager.create(userInfo); + + // Get + userInfo2 = manager.get(Helpers.USER); + assertNotNull(userInfo2); + assertArrayEquals(userInfo.getContent(cryptoManager), userInfo2.getContent(cryptoManager)); + + // Update + userInfo.setContent(cryptoManager, "test".getBytes(Charsets.UTF_8)); + manager.update(userInfo); + userInfo2 = manager.get(Helpers.USER); + assertNotNull(userInfo2); + assertArrayEquals(userInfo.getContent(cryptoManager), userInfo2.getContent(cryptoManager)); + + // Delete + manager.delete(userInfo); + userInfo = manager.get(Helpers.USER); + assertNull(userInfo); + } + + + @Test + public void testJournalMember() throws IOException, Exceptions.HttpException, Exceptions.GenericCryptoException, Exceptions.IntegrityException { + JournalManager journalManager = new JournalManager(httpClient, remote); + CollectionInfo info = CollectionInfo.defaultForServiceType(CollectionInfo.Type.ADDRESS_BOOK); + info.uid = JournalManager.Journal.genUid(); + info.displayName = "Test"; + Crypto.CryptoManager crypto = new Crypto.CryptoManager(info.version, Helpers.keyBase64, info.uid); + JournalManager.Journal journal = new JournalManager.Journal(crypto, info.toJson(), info.uid); + journalManager.putJournal(journal); + + assertEquals(journalManager.listMembers(journal).size(), 0); + + // Test inviting ourselves + JournalManager.Member member = new JournalManager.Member(Helpers.USER, "test".getBytes(Charsets.UTF_8)); + journalManager.addMember(journal, member); + + assertEquals(journalManager.listMembers(journal).size(), 1); + + // Uninviting ourselves + journalManager.deleteMember(journal, member); + + assertEquals(journalManager.listMembers(journal).size(), 0); + } }