Imports basically work now, although there may be some issues if the user switches language between export and import.
This commit is contained in:
		| @@ -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<String, Label>() | ||||
|                         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 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<Plaintext> 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); | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
| } | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user