diff --git a/app/src/main/java/ch/dissem/apps/abit/SettingsFragment.kt b/app/src/main/java/ch/dissem/apps/abit/SettingsFragment.kt index 63b090e..b77b5a8 100644 --- a/app/src/main/java/ch/dissem/apps/abit/SettingsFragment.kt +++ b/app/src/main/java/ch/dissem/apps/abit/SettingsFragment.kt @@ -16,9 +16,11 @@ package ch.dissem.apps.abit +import android.app.Activity import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.net.Uri import android.os.Bundle import android.preference.PreferenceManager import android.support.v4.content.FileProvider.getUriForFile @@ -30,21 +32,23 @@ import ch.dissem.apps.abit.synchronization.SyncAdapter import ch.dissem.apps.abit.util.Constants.PREFERENCE_SERVER_POW import ch.dissem.apps.abit.util.Constants.PREFERENCE_TRUSTED_NODE import ch.dissem.apps.abit.util.Preferences +import ch.dissem.bitmessage.entity.valueobject.Label import ch.dissem.bitmessage.exports.ContactExport import ch.dissem.bitmessage.exports.MessageExport import ch.dissem.bitmessage.utils.UnixTime +import com.beust.klaxon.JsonArray +import com.beust.klaxon.Parser import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.LibsBuilder import org.jetbrains.anko.doAsync import org.jetbrains.anko.support.v4.indeterminateProgressDialog import org.jetbrains.anko.uiThread -import org.slf4j.LoggerFactory import java.io.File import java.io.FileOutputStream import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream - /** * @author Christian Basler */ @@ -128,7 +132,7 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP intent.type = "application/zip" intent.putExtra(Intent.EXTRA_SUBJECT, "abit-export.zip") intent.putExtra(Intent.EXTRA_STREAM, contentUri) - startActivityForResult(Intent.createChooser(intent, ""), 0) + startActivityForResult(Intent.createChooser(intent, ""), WRITE_EXPORT_REQUEST_CODE) uiThread { dialog.dismiss() } @@ -136,6 +140,15 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP return@OnPreferenceClickListener true } + findPreference("import")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/zip" + + startActivityForResult(intent, READ_IMPORT_REQUEST_CODE) + return@OnPreferenceClickListener true + } + findPreference("status").onPreferenceClickListener = Preference.OnPreferenceClickListener { val activity = activity as MainActivity if (activity.hasDetailPane()) { @@ -147,8 +160,57 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } } + private fun processEntry(zipFile: Uri, entry: String, processor: (JsonArray<*>) -> Unit) { + ZipInputStream(context.contentResolver.openInputStream(zipFile)).use { zip -> + var nextEntry = zip.nextEntry + while (nextEntry != null) { + if (nextEntry.name == entry) { + processor(Parser().parse(zip) as JsonArray<*>) + } + nextEntry = zip.nextEntry + } + } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Preferences.cleanupExportDirectory(context) + when (requestCode) { + WRITE_EXPORT_REQUEST_CODE -> Preferences.cleanupExportDirectory(context) + READ_IMPORT_REQUEST_CODE -> { + if (resultCode == Activity.RESULT_OK && data?.data != null) { + val dialog = indeterminateProgressDialog(R.string.import_data_summary, R.string.import_data) + doAsync { + val ctx = Singleton.getBitmessageContext(context) + val labels = mutableMapOf() + val zipFile = data.data + + processEntry(zipFile, "contacts.json") { json -> + ContactExport.importContacts(json).forEach { contact -> + ctx.addresses.save(contact) + } + } + ctx.messages.getLabels().forEach { label -> + labels[label.toString()] = label + } + processEntry(zipFile, "labels.json") { json -> + MessageExport.importLabels(json).forEach { label -> + if (!labels.contains(label.toString())) { + ctx.messages.save(label) + labels[label.toString()] = label + } + } + } + processEntry(zipFile, "messages.json") { json -> + MessageExport.importMessages(json, labels).forEach { message -> + ctx.messages.save(message) + } + } + uiThread { + dialog.dismiss() + } + } + } + } + } } override fun onAttach(ctx: Context?) { @@ -182,4 +244,9 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } } } + + companion object { + const val WRITE_EXPORT_REQUEST_CODE = 1 + const val READ_IMPORT_REQUEST_CODE = 2 + } } diff --git a/app/src/main/java/ch/dissem/apps/abit/repository/AndroidMessageRepository.java b/app/src/main/java/ch/dissem/apps/abit/repository/AndroidMessageRepository.java deleted file mode 100644 index 9c52d1c..0000000 --- a/app/src/main/java/ch/dissem/apps/abit/repository/AndroidMessageRepository.java +++ /dev/null @@ -1,428 +0,0 @@ -/* - * Copyright 2015 Christian Basler - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package ch.dissem.apps.abit.repository; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteConstraintException; -import android.database.sqlite.SQLiteDatabase; -import android.support.annotation.NonNull; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.util.Collection; -import java.util.LinkedList; -import java.util.List; -import java.util.UUID; - -import ch.dissem.apps.abit.util.Labels; -import ch.dissem.apps.abit.util.UuidUtils; -import ch.dissem.bitmessage.entity.BitmessageAddress; -import ch.dissem.bitmessage.entity.Plaintext; -import ch.dissem.bitmessage.entity.valueobject.InventoryVector; -import ch.dissem.bitmessage.entity.valueobject.Label; -import ch.dissem.bitmessage.ports.AbstractMessageRepository; -import ch.dissem.bitmessage.ports.MessageRepository; -import ch.dissem.bitmessage.utils.Encode; - -import static ch.dissem.apps.abit.util.UuidUtils.asUuid; -import static ch.dissem.bitmessage.utils.Strings.hex; -import static java.lang.String.valueOf; - -/** - * {@link MessageRepository} implementation using the Android SQL API. - */ -public class AndroidMessageRepository extends AbstractMessageRepository { - private static final Logger LOG = LoggerFactory.getLogger(AndroidMessageRepository.class); - - public static final Label LABEL_ARCHIVE = new Label("archive", null, 0); - - private static final String TABLE_NAME = "Message"; - private static final String COLUMN_ID = "id"; - private static final String COLUMN_IV = "iv"; - private static final String COLUMN_TYPE = "type"; - private static final String COLUMN_SENDER = "sender"; - private static final String COLUMN_RECIPIENT = "recipient"; - private static final String COLUMN_DATA = "data"; - private static final String COLUMN_ACK_DATA = "ack_data"; - private static final String COLUMN_SENT = "sent"; - private static final String COLUMN_RECEIVED = "received"; - private static final String COLUMN_STATUS = "status"; - private static final String COLUMN_TTL = "ttl"; - private static final String COLUMN_RETRIES = "retries"; - private static final String COLUMN_NEXT_TRY = "next_try"; - private static final String COLUMN_INITIAL_HASH = "initial_hash"; - private static final String COLUMN_CONVERSATION = "conversation"; - - private static final String PARENTS_TABLE_NAME = "Message_Parent"; - - private static final String JOIN_TABLE_NAME = "Message_Label"; - private static final String JT_COLUMN_MESSAGE = "message_id"; - private static final String JT_COLUMN_LABEL = "label_id"; - - private static final String LBL_TABLE_NAME = "Label"; - private static final String LBL_COLUMN_ID = "id"; - private static final String LBL_COLUMN_LABEL = "label"; - private static final String LBL_COLUMN_TYPE = "type"; - private static final String LBL_COLUMN_COLOR = "color"; - private static final String LBL_COLUMN_ORDER = "ord"; - private final SqlHelper sql; - private final Context context; - - public AndroidMessageRepository(SqlHelper sql, Context ctx) { - this.sql = sql; - this.context = ctx; - } - - @NonNull - @Override - public List findMessages(Label label) { - if (label == LABEL_ARCHIVE) { - return super.findMessages((Label) null); - } else { - return super.findMessages(label); - } - } - - @NonNull - public List<Long> findMessageIds(Label label) { - if (label == LABEL_ARCHIVE) { - return findIds("id NOT IN (SELECT message_id FROM Message_Label)"); - } else { - return findIds("id IN (SELECT message_id FROM Message_Label WHERE label_id=" + label.getId() + ")"); - } - } - - @NonNull - public List<Label> findLabels(String where) { - List<Label> result = new LinkedList<>(); - - // Define a projection that specifies which columns from the database - // you will actually use after this query. - String[] projection = { - LBL_COLUMN_ID, - LBL_COLUMN_LABEL, - LBL_COLUMN_TYPE, - LBL_COLUMN_COLOR - }; - - SQLiteDatabase db = sql.getReadableDatabase(); - try (Cursor c = db.query( - LBL_TABLE_NAME, projection, - where, - null, null, null, - LBL_COLUMN_ORDER - )) { - while (c.moveToNext()) { - result.add(getLabel(c)); - } - } - return result; - } - - private Label getLabel(Cursor c) { - String typeName = c.getString(c.getColumnIndex(LBL_COLUMN_TYPE)); - Label.Type type = typeName == null ? null : Label.Type.valueOf(typeName); - String text = Labels.getText(type, null, context); - if (text == null) { - text = c.getString(c.getColumnIndex(LBL_COLUMN_LABEL)); - } - Label label = new Label( - text, - type, - c.getInt(c.getColumnIndex(LBL_COLUMN_COLOR))); - label.setId(c.getLong(c.getColumnIndex(LBL_COLUMN_ID))); - return label; - } - - @Override - public int countUnread(Label label) { - if (label == LABEL_ARCHIVE) { - return 0; - } else if (label == null) { - return (int) DatabaseUtils.queryNumEntries( - sql.getReadableDatabase(), - TABLE_NAME, - "id IN (SELECT message_id FROM Message_Label WHERE label_id IN (SELECT id FROM Label WHERE type=?))", - new String[]{ - String.valueOf(label.getId()), - Label.Type.UNREAD.name() - } - ); - } else { - return (int) DatabaseUtils.queryNumEntries( - sql.getReadableDatabase(), - TABLE_NAME, - " id IN (SELECT message_id FROM Message_Label WHERE label_id=?) " + - "AND id IN (SELECT message_id FROM Message_Label WHERE label_id IN (SELECT id FROM Label WHERE type=?))", - new String[]{ - String.valueOf(label.getId()), - Label.Type.UNREAD.name() - } - ); - } - } - - @NonNull - @Override - public List<UUID> findConversations(Label label) { - String[] projection = { - COLUMN_CONVERSATION, - }; - - String where; - if (label == null) { - where = "id NOT IN (SELECT message_id FROM Message_Label)"; - } else { - where = "id IN (SELECT message_id FROM Message_Label WHERE label_id=" + label.getId() + ")"; - } - List<UUID> result = new LinkedList<>(); - SQLiteDatabase db = sql.getReadableDatabase(); - try (Cursor c = db.query( - TABLE_NAME, projection, - where, - null, null, null, null - )) { - while (c.moveToNext()) { - byte[] uuidBytes = c.getBlob(c.getColumnIndex(COLUMN_CONVERSATION)); - result.add(asUuid(uuidBytes)); - } - } - return result; - } - - - private void updateParents(SQLiteDatabase db, Plaintext message) { - if (message.getInventoryVector() == null || message.getParents().isEmpty()) { - // There are no parents to save yet (they are saved in the extended data, that's enough for now) - return; - } - byte[] childIV = message.getInventoryVector().getHash(); - db.delete(PARENTS_TABLE_NAME, "child=?", new String[]{hex(childIV)}); - - // save new parents - int order = 0; - for (InventoryVector parentIV : message.getParents()) { - Plaintext parent = getMessage(parentIV); - if (parent != null) { - mergeConversations(db, parent.getConversationId(), message.getConversationId()); - order++; - ContentValues values = new ContentValues(); - values.put("parent", parentIV.getHash()); - values.put("child", childIV); - values.put("pos", order); - values.put("conversation", UuidUtils.asBytes(message.getConversationId())); - db.insertOrThrow(PARENTS_TABLE_NAME, null, values); - } - } - } - - /** - * Replaces every occurrence of the source conversation ID with the target ID - * - * @param db is used to keep everything within one transaction - * @param source ID of the conversation to be merged - * @param target ID of the merge target - */ - private void mergeConversations(SQLiteDatabase db, UUID source, UUID target) { - ContentValues values = new ContentValues(); - values.put("conversation", UuidUtils.asBytes(target)); - String[] whereArgs = {hex(UuidUtils.asBytes(source))}; - db.update(TABLE_NAME, values, "conversation=?", whereArgs); - db.update(PARENTS_TABLE_NAME, values, "conversation=?", whereArgs); - } - - @NonNull - private List<Long> findIds(String where) { - List<Long> result = new LinkedList<>(); - - // Define a projection that specifies which columns from the database - // you will actually use after this query. - String[] projection = { - COLUMN_ID - }; - - SQLiteDatabase db = sql.getReadableDatabase(); - try (Cursor c = db.query( - TABLE_NAME, projection, - where, - null, null, null, - COLUMN_RECEIVED + " DESC, " + COLUMN_SENT + " DESC" - )) { - while (c.moveToNext()) { - long id = c.getLong(0); - result.add(id); - } - } - return result; - } - - @NonNull - protected List<Plaintext> find(String where) { - List<Plaintext> result = new LinkedList<>(); - - // Define a projection that specifies which columns from the database - // you will actually use after this query. - String[] projection = { - COLUMN_ID, - COLUMN_IV, - COLUMN_TYPE, - COLUMN_SENDER, - COLUMN_RECIPIENT, - COLUMN_DATA, - COLUMN_ACK_DATA, - COLUMN_SENT, - COLUMN_RECEIVED, - COLUMN_STATUS, - COLUMN_TTL, - COLUMN_RETRIES, - COLUMN_NEXT_TRY, - COLUMN_CONVERSATION - }; - - SQLiteDatabase db = sql.getReadableDatabase(); - try (Cursor c = db.query( - TABLE_NAME, projection, - where, - null, null, null, - COLUMN_RECEIVED + " DESC, " + COLUMN_SENT + " DESC" - )) { - while (c.moveToNext()) { - byte[] iv = c.getBlob(c.getColumnIndex(COLUMN_IV)); - byte[] data = c.getBlob(c.getColumnIndex(COLUMN_DATA)); - Plaintext.Type type = Plaintext.Type.valueOf(c.getString(c.getColumnIndex - (COLUMN_TYPE))); - Plaintext.Builder builder = Plaintext.readWithoutSignature(type, - new ByteArrayInputStream(data)); - long id = c.getLong(c.getColumnIndex(COLUMN_ID)); - builder.id(id); - builder.IV(InventoryVector.fromHash(iv)); - String sender = c.getString(c.getColumnIndex(COLUMN_SENDER)); - if (sender != null) { - BitmessageAddress address = ctx.getAddressRepository().getAddress(sender); - if (address != null) { - builder.from(address); - } else { - builder.from(new BitmessageAddress(sender)); - } - } - String recipient = c.getString(c.getColumnIndex(COLUMN_RECIPIENT)); - if (recipient != null) { - BitmessageAddress address = ctx.getAddressRepository().getAddress(recipient); - if (address != null) { - builder.to(address); - } else { - builder.to(new BitmessageAddress(sender)); - } - } - builder.ackData(c.getBlob(c.getColumnIndex(COLUMN_ACK_DATA))); - builder.sent(c.getLong(c.getColumnIndex(COLUMN_SENT))); - builder.received(c.getLong(c.getColumnIndex(COLUMN_RECEIVED))); - builder.status(Plaintext.Status.valueOf(c.getString(c.getColumnIndex - (COLUMN_STATUS)))); - builder.ttl(c.getLong(c.getColumnIndex(COLUMN_TTL))); - builder.retries(c.getInt(c.getColumnIndex(COLUMN_RETRIES))); - int nextTryColumn = c.getColumnIndex(COLUMN_NEXT_TRY); - if (!c.isNull(nextTryColumn)) { - builder.nextTry(c.getLong(nextTryColumn)); - } - builder.conversation(asUuid(c.getBlob(c.getColumnIndex(COLUMN_CONVERSATION)))); - builder.labels(findLabels(id)); - result.add(builder.build()); - } - } - return result; - } - - private Collection<Label> findLabels(long id) { - return findLabels("id IN (SELECT label_id FROM Message_Label WHERE message_id=" + id + ")"); - } - - @Override - public void save(Plaintext message) { - saveContactIfNecessary(message.getFrom()); - saveContactIfNecessary(message.getTo()); - SQLiteDatabase db = sql.getWritableDatabase(); - try { - db.beginTransaction(); - - // save message - if (message.getId() == null) { - insert(db, message); - } else { - update(db, message); - } - - updateParents(db, message); - - // remove existing labels - db.delete(JOIN_TABLE_NAME, "message_id=?", new String[]{valueOf(message.getId())}); - - // save labels - ContentValues values = new ContentValues(); - for (Label label : message.getLabels()) { - values.put(JT_COLUMN_LABEL, (Long) label.getId()); - values.put(JT_COLUMN_MESSAGE, (Long) message.getId()); - db.insertOrThrow(JOIN_TABLE_NAME, null, values); - } - db.setTransactionSuccessful(); - } catch (SQLiteConstraintException e) { - LOG.trace(e.getMessage(), e); - } finally { - db.endTransaction(); - } - } - - private ContentValues getValues(Plaintext message) { - ContentValues values = new ContentValues(); - values.put(COLUMN_IV, message.getInventoryVector() == null ? null : message - .getInventoryVector().getHash()); - values.put(COLUMN_TYPE, message.getType().name()); - values.put(COLUMN_SENDER, message.getFrom().getAddress()); - values.put(COLUMN_RECIPIENT, message.getTo() == null ? null : message.getTo().getAddress()); - values.put(COLUMN_DATA, Encode.bytes(message)); - values.put(COLUMN_ACK_DATA, message.getAckData()); - values.put(COLUMN_SENT, message.getSent()); - values.put(COLUMN_RECEIVED, message.getReceived()); - values.put(COLUMN_STATUS, message.getStatus().name()); - values.put(COLUMN_INITIAL_HASH, message.getInitialHash()); - values.put(COLUMN_TTL, message.getTTL()); - values.put(COLUMN_RETRIES, message.getRetries()); - values.put(COLUMN_NEXT_TRY, message.getNextTry()); - values.put(COLUMN_CONVERSATION, UuidUtils.asBytes(message.getConversationId())); - return values; - } - - private void insert(SQLiteDatabase db, Plaintext message) { - long id = db.insertOrThrow(TABLE_NAME, null, getValues(message)); - message.setId(id); - } - - private void update(SQLiteDatabase db, Plaintext message) { - db.update(TABLE_NAME, getValues(message), "id=?", new String[]{valueOf(message.getId())}); - } - - @Override - public void remove(Plaintext message) { - SQLiteDatabase db = sql.getWritableDatabase(); - db.delete(TABLE_NAME, "id = " + message.getId(), null); - } -} diff --git a/app/src/main/java/ch/dissem/apps/abit/repository/AndroidMessageRepository.kt b/app/src/main/java/ch/dissem/apps/abit/repository/AndroidMessageRepository.kt new file mode 100644 index 0000000..312bbec --- /dev/null +++ b/app/src/main/java/ch/dissem/apps/abit/repository/AndroidMessageRepository.kt @@ -0,0 +1,403 @@ +/* + * Copyright 2015 Christian Basler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ch.dissem.apps.abit.repository + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteDatabase +import ch.dissem.apps.abit.util.Labels +import ch.dissem.apps.abit.util.UuidUtils +import ch.dissem.apps.abit.util.UuidUtils.asUuid +import ch.dissem.bitmessage.entity.BitmessageAddress +import ch.dissem.bitmessage.entity.Plaintext +import ch.dissem.bitmessage.entity.valueobject.InventoryVector +import ch.dissem.bitmessage.entity.valueobject.Label +import ch.dissem.bitmessage.ports.AbstractMessageRepository +import ch.dissem.bitmessage.ports.MessageRepository +import ch.dissem.bitmessage.utils.Encode +import ch.dissem.bitmessage.utils.Strings.hex +import org.slf4j.LoggerFactory +import java.io.ByteArrayInputStream +import java.util.* + +/** + * [MessageRepository] implementation using the Android SQL API. + */ +class AndroidMessageRepository(private val sql: SqlHelper, private val context: Context) : AbstractMessageRepository() { + + override fun findMessages(label: Label?): List<Plaintext> { + if (label === LABEL_ARCHIVE) { + return super.findMessages(null as Label?) + } else { + return super.findMessages(label) + } + } + + fun findMessageIds(label: Label): List<Long> { + if (label === LABEL_ARCHIVE) { + return findIds("id NOT IN (SELECT message_id FROM Message_Label)") + } else { + return findIds("id IN (SELECT message_id FROM Message_Label WHERE label_id=" + label.id + ")") + } + } + + public override fun findLabels(where: String): List<Label> { + val result = LinkedList<Label>() + + // Define a projection that specifies which columns from the database + // you will actually use after this query. + val projection = arrayOf(LBL_COLUMN_ID, LBL_COLUMN_LABEL, LBL_COLUMN_TYPE, LBL_COLUMN_COLOR) + + val db = sql.readableDatabase + db.query( + LBL_TABLE_NAME, projection, + where, null, null, null, + LBL_COLUMN_ORDER + ).use { c -> + while (c.moveToNext()) { + result.add(getLabel(c)) + } + } + return result + } + + private fun getLabel(c: Cursor): Label { + val typeName = c.getString(c.getColumnIndex(LBL_COLUMN_TYPE)) + val type = if (typeName == null) null else Label.Type.valueOf(typeName) + val text: String? = Labels.getText(type, null, context) + val label = Label( + text ?: c.getString(c.getColumnIndex(LBL_COLUMN_LABEL)), + type, + c.getInt(c.getColumnIndex(LBL_COLUMN_COLOR))) + label.id = c.getLong(c.getColumnIndex(LBL_COLUMN_ID)) + return label + } + + override fun save(label: Label) { + val db = sql.writableDatabase + if (label.id != null) { + val values = ContentValues() + values.put(LBL_COLUMN_LABEL, label.toString()) + values.put(LBL_COLUMN_TYPE, label.type?.name) + values.put(LBL_COLUMN_COLOR, label.color) + values.put(LBL_COLUMN_ORDER, label.ord) + db.update(LBL_TABLE_NAME, values, "id=?", arrayOf(label.id.toString())) + } else { + try { + db.beginTransaction() + + val exists = DatabaseUtils.queryNumEntries(db, LBL_TABLE_NAME, "label=?", arrayOf(label.toString())) > 0 + + if (exists) { + val values = ContentValues() + values.put(LBL_COLUMN_TYPE, label.type?.name) + values.put(LBL_COLUMN_COLOR, label.color) + values.put(LBL_COLUMN_ORDER, label.ord) + db.update(LBL_TABLE_NAME, values, "label=?", arrayOf(label.toString())) + } else { + val values = ContentValues() + values.put(LBL_COLUMN_LABEL, label.toString()) + values.put(LBL_COLUMN_TYPE, label.type?.name) + values.put(LBL_COLUMN_COLOR, label.color) + values.put(LBL_COLUMN_ORDER, label.ord) + db.insertOrThrow(LBL_TABLE_NAME, null, values) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + } + + override fun countUnread(label: Label?): Int { + if (label === LABEL_ARCHIVE) { + return 0 + } else if (label == null) { + return DatabaseUtils.queryNumEntries( + sql.readableDatabase, + TABLE_NAME, + "id IN (SELECT message_id FROM Message_Label WHERE label_id IN (SELECT id FROM Label WHERE type=?))", + arrayOf(Label.Type.UNREAD.name) + ).toInt() + } else { + return DatabaseUtils.queryNumEntries( + sql.readableDatabase, + TABLE_NAME, + " id IN (SELECT message_id FROM Message_Label WHERE label_id=?) " + "AND id IN (SELECT message_id FROM Message_Label WHERE label_id IN (SELECT id FROM Label WHERE type=?))", + arrayOf(label.id.toString(), Label.Type.UNREAD.name) + ).toInt() + } + } + + override fun findConversations(label: Label?): List<UUID> { + val projection = arrayOf(COLUMN_CONVERSATION) + + val where: String + if (label == null) { + where = "id NOT IN (SELECT message_id FROM Message_Label)" + } else { + where = "id IN (SELECT message_id FROM Message_Label WHERE label_id=" + label.id + ")" + } + val result = LinkedList<UUID>() + val db = sql.readableDatabase + db.query( + TABLE_NAME, projection, + where, null, null, null, null + ).use { c -> + while (c.moveToNext()) { + val uuidBytes = c.getBlob(c.getColumnIndex(COLUMN_CONVERSATION)) + result.add(asUuid(uuidBytes)) + } + } + return result + } + + + private fun updateParents(db: SQLiteDatabase, message: Plaintext) { + if (message.inventoryVector == null || message.parents.isEmpty()) { + // There are no parents to save yet (they are saved in the extended data, that's enough for now) + return + } + val childIV = message.inventoryVector!!.hash + db.delete(PARENTS_TABLE_NAME, "child=?", arrayOf(hex(childIV))) + + // save new parents + var order = 0 + for (parentIV in message.parents) { + val parent = getMessage(parentIV) + if (parent != null) { + mergeConversations(db, parent.conversationId, message.conversationId) + order++ + val values = ContentValues() + values.put("parent", parentIV.hash) + values.put("child", childIV) + values.put("pos", order) + values.put("conversation", UuidUtils.asBytes(message.conversationId)) + db.insertOrThrow(PARENTS_TABLE_NAME, null, values) + } + } + } + + /** + * Replaces every occurrence of the source conversation ID with the target ID + + * @param db is used to keep everything within one transaction + * * + * @param source ID of the conversation to be merged + * * + * @param target ID of the merge target + */ + private fun mergeConversations(db: SQLiteDatabase, source: UUID, target: UUID) { + val values = ContentValues() + values.put("conversation", UuidUtils.asBytes(target)) + val whereArgs = arrayOf(hex(UuidUtils.asBytes(source))) + db.update(TABLE_NAME, values, "conversation=?", whereArgs) + db.update(PARENTS_TABLE_NAME, values, "conversation=?", whereArgs) + } + + private fun findIds(where: String): List<Long> { + val result = LinkedList<Long>() + + // Define a projection that specifies which columns from the database + // you will actually use after this query. + val projection = arrayOf(COLUMN_ID) + + val db = sql.readableDatabase + db.query( + TABLE_NAME, projection, + where, null, null, null, + "$COLUMN_RECEIVED DESC, $COLUMN_SENT DESC" + ).use { c -> + while (c.moveToNext()) { + val id = c.getLong(0) + result.add(id) + } + } + return result + } + + override fun find(where: String): List<Plaintext> { + val result = LinkedList<Plaintext>() + + // Define a projection that specifies which columns from the database + // you will actually use after this query. + val projection = arrayOf(COLUMN_ID, COLUMN_IV, COLUMN_TYPE, COLUMN_SENDER, COLUMN_RECIPIENT, COLUMN_DATA, COLUMN_ACK_DATA, COLUMN_SENT, COLUMN_RECEIVED, COLUMN_STATUS, COLUMN_TTL, COLUMN_RETRIES, COLUMN_NEXT_TRY, COLUMN_CONVERSATION) + + val db = sql.readableDatabase + db.query( + TABLE_NAME, projection, + where, null, null, null, + "$COLUMN_RECEIVED DESC, $COLUMN_SENT DESC" + ).use { c -> + while (c.moveToNext()) { + val iv = c.getBlob(c.getColumnIndex(COLUMN_IV)) + val data = c.getBlob(c.getColumnIndex(COLUMN_DATA)) + val type = Plaintext.Type.valueOf(c.getString(c.getColumnIndex(COLUMN_TYPE))) + val builder = Plaintext.readWithoutSignature(type, + ByteArrayInputStream(data)) + val id = c.getLong(c.getColumnIndex(COLUMN_ID)) + builder.id(id) + builder.IV(InventoryVector.fromHash(iv)) + val sender = c.getString(c.getColumnIndex(COLUMN_SENDER)) + if (sender != null) { + val address = ctx.addressRepository.getAddress(sender) + if (address != null) { + builder.from(address) + } else { + builder.from(BitmessageAddress(sender)) + } + } + val recipient = c.getString(c.getColumnIndex(COLUMN_RECIPIENT)) + if (recipient != null) { + val address = ctx.addressRepository.getAddress(recipient) + if (address != null) { + builder.to(address) + } else { + builder.to(BitmessageAddress(sender)) + } + } + builder.ackData(c.getBlob(c.getColumnIndex(COLUMN_ACK_DATA))) + builder.sent(c.getLong(c.getColumnIndex(COLUMN_SENT))) + builder.received(c.getLong(c.getColumnIndex(COLUMN_RECEIVED))) + builder.status(Plaintext.Status.valueOf(c.getString(c.getColumnIndex(COLUMN_STATUS)))) + builder.ttl(c.getLong(c.getColumnIndex(COLUMN_TTL))) + builder.retries(c.getInt(c.getColumnIndex(COLUMN_RETRIES))) + val nextTryColumn = c.getColumnIndex(COLUMN_NEXT_TRY) + if (!c.isNull(nextTryColumn)) { + builder.nextTry(c.getLong(nextTryColumn)) + } + builder.conversation(asUuid(c.getBlob(c.getColumnIndex(COLUMN_CONVERSATION)))) + builder.labels(findLabels(id)) + result.add(builder.build()) + } + } + return result + } + + private fun findLabels(id: Long) = findLabels("id IN (SELECT label_id FROM Message_Label WHERE message_id=$id)") + + override fun save(message: Plaintext) { + saveContactIfNecessary(message.from) + saveContactIfNecessary(message.to) + val db = sql.writableDatabase + try { + db.beginTransaction() + + // save message + if (message.id == null) { + insert(db, message) + } else { + update(db, message) + } + + updateParents(db, message) + + // remove existing labels + db.delete(JOIN_TABLE_NAME, "message_id=?", arrayOf(message.id.toString())) + + // save labels + val values = ContentValues() + for (label in message.labels) { + values.put(JT_COLUMN_LABEL, label.id as Long?) + values.put(JT_COLUMN_MESSAGE, message.id as Long?) + db.insertOrThrow(JOIN_TABLE_NAME, null, values) + } + db.setTransactionSuccessful() + } catch (e: SQLiteConstraintException) { + LOG.trace(e.message, e) + } finally { + db.endTransaction() + } + } + + private fun getValues(message: Plaintext): ContentValues { + val values = ContentValues() + values.put(COLUMN_IV, if (message.inventoryVector == null) + null + else + message + .inventoryVector!!.hash) + values.put(COLUMN_TYPE, message.type.name) + values.put(COLUMN_SENDER, message.from.address) + values.put(COLUMN_RECIPIENT, if (message.to == null) null else message.to!!.address) + values.put(COLUMN_DATA, Encode.bytes(message)) + values.put(COLUMN_ACK_DATA, message.ackData) + values.put(COLUMN_SENT, message.sent) + values.put(COLUMN_RECEIVED, message.received) + values.put(COLUMN_STATUS, message.status.name) + values.put(COLUMN_INITIAL_HASH, message.initialHash) + values.put(COLUMN_TTL, message.ttl) + values.put(COLUMN_RETRIES, message.retries) + values.put(COLUMN_NEXT_TRY, message.nextTry) + values.put(COLUMN_CONVERSATION, UuidUtils.asBytes(message.conversationId)) + return values + } + + private fun insert(db: SQLiteDatabase, message: Plaintext) { + val id = db.insertOrThrow(TABLE_NAME, null, getValues(message)) + message.id = id + } + + private fun update(db: SQLiteDatabase, message: Plaintext) { + db.update(TABLE_NAME, getValues(message), "id=?", arrayOf(message.id.toString())) + } + + override fun remove(message: Plaintext) { + val db = sql.writableDatabase + db.delete(TABLE_NAME, "id = " + message.id!!, null) + } + + companion object { + private val LOG = LoggerFactory.getLogger(AndroidMessageRepository::class.java) + + @JvmField + val LABEL_ARCHIVE = Label("archive", null, 0) + + private const val TABLE_NAME = "Message" + private const val COLUMN_ID = "id" + private const val COLUMN_IV = "iv" + private const val COLUMN_TYPE = "type" + private const val COLUMN_SENDER = "sender" + private const val COLUMN_RECIPIENT = "recipient" + private const val COLUMN_DATA = "data" + private const val COLUMN_ACK_DATA = "ack_data" + private const val COLUMN_SENT = "sent" + private const val COLUMN_RECEIVED = "received" + private const val COLUMN_STATUS = "status" + private const val COLUMN_TTL = "ttl" + private const val COLUMN_RETRIES = "retries" + private const val COLUMN_NEXT_TRY = "next_try" + private const val COLUMN_INITIAL_HASH = "initial_hash" + private const val COLUMN_CONVERSATION = "conversation" + + private const val PARENTS_TABLE_NAME = "Message_Parent" + + private const val JOIN_TABLE_NAME = "Message_Label" + private const val JT_COLUMN_MESSAGE = "message_id" + private const val JT_COLUMN_LABEL = "label_id" + + private const val LBL_TABLE_NAME = "Label" + private const val LBL_COLUMN_ID = "id" + private const val LBL_COLUMN_LABEL = "label" + private const val LBL_COLUMN_TYPE = "type" + private const val LBL_COLUMN_COLOR = "color" + private const val LBL_COLUMN_ORDER = "ord" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19b4f4b..26832d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,8 @@ As an alternative you could configure a trusted node in the settings, but as of <string name="error_msg_recipient_missing">Please set a recipient</string> <string name="export_data">Export</string> <string name="export_data_summary">Export all messages and contacts (but not identities)</string> + <string name="import_data">Import</string> + <string name="import_data_summary">Import messages and contacts (but not identities)</string> <string name="request_acknowledgements">Request acknowledgements</string> <string name="request_acknowledgements_summary">Acknowledges allow making sure a message was received, but require additional time to send</string> <string name="got_it">Got it</string> diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 8c56a4c..290ad1c 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -51,6 +51,11 @@ android:title="@string/export_data" android:summary="@string/export_data_summary" /> + <Preference + android:key="import" + android:title="@string/import_data" + android:summary="@string/import_data_summary" + /> <Preference android:key="status" android:summary="@string/status_summary"