Support XEP-0425 message moderation (Stephen Paul Weber)

+ fix unhandled exception in DatabaseBackend
This commit is contained in:
Arne 2023-03-18 03:03:01 +01:00
parent bb9ea37925
commit 48f384b117
12 changed files with 164 additions and 35 deletions

View file

@ -452,6 +452,13 @@
<xmpp:version>0.2.0</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0425.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.2.1</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0454.html"/>
@ -476,4 +483,4 @@
</Version>
</release>
</Project>
</rdf:RDF>
</rdf:RDF>

View file

@ -2,6 +2,7 @@ package eu.siacs.conversations.entities;
import static eu.siacs.conversations.entities.Bookmark.printableValue;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.SharedPreferences;
import android.database.Cursor;
@ -128,6 +129,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
}
}
@SuppressLint("Range")
public static Conversation fromCursor(Cursor cursor) {
return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
cursor.getString(cursor.getColumnIndex(NAME)),
@ -488,7 +490,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return null;
}
public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) {
synchronized (this.messages) {
for (int i = this.messages.size() - 1; i >= 0; --i) {
final Message message = messages.get(i);
@ -496,12 +498,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
if (mcp == null) {
continue;
}
if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
&& (carbon == message.isCarbon() || received)) {
final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
return message;
}
if (mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) {
final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId()));
if (idMatch) return message;
}
}
}

View file

@ -28,6 +28,7 @@
*/
package eu.siacs.conversations.entities;
import android.annotation.SuppressLint;
import android.database.Cursor;
import java.util.Set;
@ -42,7 +43,7 @@ public class IndividualMessage extends Message {
}
private IndividualMessage(Conversational conversation, String uuid, String conversationUUid, Jid counterpart, Jid trueCounterpart, String body, long timeSent, int encryption, int status, int type, boolean carbon, String remoteMsgId, String relativeFilePath, String serverMsgId, String fingerprint, boolean read, boolean deleted, String edited, boolean oob, String errorMessage, Set<ReadByMarker> readByMarkers, boolean markable, boolean file_deleted, String bodyLanguage, String retractId) {
super(conversation, uuid, conversationUUid, counterpart, trueCounterpart, body, timeSent, encryption, status, type, carbon, remoteMsgId, relativeFilePath, serverMsgId, fingerprint, read, deleted, edited, oob, errorMessage, readByMarkers, markable, file_deleted, bodyLanguage,retractId);
super(conversation, uuid, conversationUUid, counterpart, trueCounterpart, body, timeSent, encryption, status, type, carbon, remoteMsgId, relativeFilePath, serverMsgId, fingerprint, read, deleted, edited, oob, errorMessage, readByMarkers, markable, file_deleted, bodyLanguage,retractId,null);
}
public static Message createDateSeparator(Message message) {
@ -53,10 +54,11 @@ public class IndividualMessage extends Message {
return separator;
}
@SuppressLint("Range")
public static Message fromCursor(Cursor cursor, Conversational conversation) {
Jid jid;
try {
String value = cursor.getString(cursor.getColumnIndex(COUNTERPART));
@SuppressLint("Range") String value = cursor.getString(cursor.getColumnIndex(COUNTERPART));
if (value != null) {
jid = Jid.of(value);
} else {
@ -69,7 +71,7 @@ public class IndividualMessage extends Message {
}
Jid trueCounterpart;
try {
String value = cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART));
@SuppressLint("Range") String value = cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART));
if (value != null) {
trueCounterpart = Jid.of(value);
} else {

View file

@ -1,5 +1,6 @@
package eu.siacs.conversations.entities;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.database.Cursor;
import android.graphics.Color;
@ -8,10 +9,12 @@ import android.util.Log;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteSource;
import com.google.common.primitives.Longs;
import org.json.JSONException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Iterator;
@ -35,6 +38,9 @@ import eu.siacs.conversations.utils.MimeUtils;
import eu.siacs.conversations.utils.Patterns;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.utils.XmppUri;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Tag;
import eu.siacs.conversations.xml.XmlReader;
import eu.siacs.conversations.xmpp.Jid;
public class Message extends AbstractEntity implements AvatarService.Avatarable {
@ -108,6 +114,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
protected boolean file_deleted = false;
protected boolean carbon = false;
protected boolean oob = false;
protected List<Element> payloads = new ArrayList<>();
protected List<Edit> edits = new ArrayList<>();
protected String relativeFilePath;
protected boolean read = true;
@ -168,6 +176,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
false,
false,
null,
null,
null);
}
@ -195,6 +204,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
false,
false,
null,
null,
null);
}
@ -204,7 +214,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
final String remoteMsgId, final String relativeFilePath,
final String serverMsgId, final String fingerprint, final boolean read, final boolean deleted,
final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
final boolean markable, final boolean file_deleted, final String bodyLanguage, final String retractId) {
final boolean markable, final boolean file_deleted, final String bodyLanguage, final String retractId, final List<Element> payloads) {
this.conversation = conversation;
this.uuid = uuid;
this.conversationUuid = conversationUUid;
@ -230,9 +240,22 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
this.file_deleted = file_deleted;
this.bodyLanguage = bodyLanguage;
this.retractId = retractId;
if (payloads != null) this.payloads = payloads;
}
public static Message fromCursor(Cursor cursor, Conversation conversation) {
@SuppressLint("Range")
public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
@SuppressLint("Range") String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
List<Element> payloads = new ArrayList<>();
if (payloadsStr != null) {
final XmlReader xmlReader = new XmlReader();
xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
Tag tag;
while ((tag = xmlReader.readTag()) != null) {
payloads.add(xmlReader.readElement(tag));
}
}
return new Message(conversation,
cursor.getString(cursor.getColumnIndex(UUID)),
cursor.getString(cursor.getColumnIndex(CONVERSATION)),
@ -257,7 +280,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0,
cursor.getInt(cursor.getColumnIndex(FILE_DELETED)) > 0,
cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
cursor.getString(cursor.getColumnIndex(RETRACT_ID))
cursor.getString(cursor.getColumnIndex(RETRACT_ID)),
payloads
);
}
@ -459,7 +483,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
public void setFileDeleted(boolean file_deleted) {
this.file_deleted = file_deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public void markRead() {
this.read = true;
}
@ -852,6 +878,18 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
public void setOob(boolean isOob) {
this.oob = isOob;
}
public void clearPayloads() {
this.payloads.clear();
}
public void addPayload(Element el) {
if (el == null) return;
this.payloads.add(el);
}
public List<Element> getPayloads() {
return new ArrayList<>(this.payloads);
}
public String getMimeType() {
String extension;
@ -922,9 +960,17 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
}
public synchronized void resetFileParams() {
this.fileParams = null;
if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
fileParams.sims = this.fileParams.sims;
}
this.fileParams = fileParams;
}
public synchronized void setFileParams(FileParams fileParams) {
if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
fileParams.sims = this.fileParams.sims;
}
this.fileParams = fileParams;
}
public synchronized FileParams getFileParams() {
if (fileParams == null) {
fileParams = new FileParams();
@ -1022,6 +1068,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
public int height = 0;
public int runtime = 0;
public String subject = "";
public Element sims = null;
public long getSize() {
return size == null ? 0 : size;
}

View file

@ -37,6 +37,8 @@ import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.forms.Data;
import eu.siacs.conversations.xmpp.pep.Avatar;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import eu.siacs.conversations.entities.Message;
public class IqGenerator extends AbstractGenerator {
@ -407,7 +409,18 @@ public class IqGenerator extends AbstractGenerator {
item.setAttribute("role", role);
return packet;
}
public IqPacket moderateMessage(Account account, Message m, String reason) {
IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
packet.setTo(m.getConversation().getJid().asBareJid());
packet.setFrom(account.getJid());
Element moderate =
packet.addChild("apply-to", "urn:xmpp:fasten:0")
.setAttribute("id", m.getServerMsgId())
.addChild("moderate", "urn:xmpp:message-moderate:0");
moderate.addChild("retract", "urn:xmpp:message-retract:0");
moderate.addChild("reason", "urn:xmpp:message-moderate:0").setContent(reason);
return packet;
}
public IqPacket destroyRoom(Conversation conference) {
IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
packet.setTo(conference.getJid().asBareJid());

View file

@ -496,8 +496,16 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final Element replaceElement = packet.findChild("replace", "urn:xmpp:message-correct:0");
final Element oob = packet.findChild("x", Namespace.OOB);
final String oobUrl = oob != null ? oob.findChildContent("url") : null;
final String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id");
String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id");
boolean replaceAsRetraction = false;
if (replacementId == null) {
Element fasten = packet.findChild("apply-to", "urn:xmpp:fasten:0");
if (fasten != null && (fasten.findChild("retract", "urn:xmpp:message-retract:0") != null || fasten.findChild("moderated", "urn:xmpp:message-moderate:0") != null)) {
replacementId = fasten.getAttribute("id");
packet.setBody("");
replaceAsRetraction = true;
}
}
final Element applyToElement = packet.findChild("apply-to", "urn:xmpp:fasten:0");
final String retractId = applyToElement != null && applyToElement.findChild("retract", "urn:xmpp:message-retract:0") != null ? applyToElement.getAttribute("id") : null;
@ -726,9 +734,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
if (replacementId != null && mXmppConnectionService.allowMessageCorrection()) {
Message replacedMessage = conversation.findMessageWithRemoteIdAndCounterpart(replacementId,
counterpart,
message.getStatus() == Message.STATUS_RECEIVED,
message.isCarbon());
counterpart
);
if (replacedMessage == null) {
replacedMessage = conversation.findSentMessageWithUuidOrRemoteId(replacementId, true, true);
@ -741,7 +748,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
&& replacedMessage.getTrueCounterpart().asBareJid().equals(message.getTrueCounterpart().asBareJid());
final boolean mucUserMatches = query == null && replacedMessage.sameMucUser(message); //can not be checked when using mam
final boolean duplicate = conversation.hasDuplicateMessage(message);
if (fingerprintsMatch && (trueCountersMatch || !conversationMultiMode || mucUserMatches) && !duplicate) {
if (fingerprintsMatch && (trueCountersMatch || !conversationMultiMode || mucUserMatches || counterpart.isBareJid()) && !duplicate) {
Log.d(Config.LOGTAG, "replaced message '" + replacedMessage.getBody() + "' with '" + message.getBody() + "'");
synchronized (replacedMessage) {
final String uuid = replacedMessage.getUuid();
@ -751,6 +758,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
replacedMessage.setUuid(UUID.randomUUID().toString());
replacedMessage.setBody(message.getBody());
replacedMessage.setRemoteMsgId(remoteMsgId);
if (replaceAsRetraction) {
mXmppConnectionService.getFileBackend().deleteFile(replacedMessage);
mXmppConnectionService.evictPreview(message.getUuid());
replacedMessage.clearPayloads();
replacedMessage.setFileParams(null);
replacedMessage.setDeleted(true);
}
if (replacedMessage.getServerMsgId() == null || message.getServerMsgId() != null) {
replacedMessage.setServerMsgId(message.getServerMsgId());
}

View file

@ -2,6 +2,7 @@ package eu.siacs.conversations.persistance;
import static eu.siacs.conversations.ui.util.UpdateHelper.moveData_PAM_monocles;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@ -702,6 +703,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return isExist;
}
@SuppressLint("Range")
private void canonicalizeJids(SQLiteDatabase db) {
// migrate db to new, canonicalized JID domainpart representation
@ -956,7 +958,12 @@ public class DatabaseBackend extends SQLiteOpenHelper {
@Override
public Message next() {
Message message = Message.fromCursor(cursor, conversation);
Message message = null;
try {
message = Message.fromCursor(cursor, conversation);
} catch (IOException e) {
throw new RuntimeException(e);
}
cursor.moveToNext();
return message;
}
@ -1345,6 +1352,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
null, null, null);
}
@SuppressLint("Range")
public SessionRecord loadSession(Account account, SignalProtocolAddress contact) {
SessionRecord session = null;
Cursor cursor = getCursorForSession(account, contact);
@ -1366,6 +1374,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return getSubDeviceSessions(db, account, contact);
}
@SuppressLint("Range")
private List<Integer> getSubDeviceSessions(SQLiteDatabase db, Account account, SignalProtocolAddress contact) {
List<Integer> devices = new ArrayList<>();
String[] columns = {SQLiteAxolotlStore.DEVICE_ID};
@ -1460,6 +1469,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return cursor;
}
@SuppressLint("Range")
public PreKeyRecord loadPreKey(Account account, int preKeyId) {
PreKeyRecord record = null;
Cursor cursor = getCursorForPreKey(account, preKeyId);
@ -1513,6 +1523,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return cursor;
}
@SuppressLint("Range")
public SignedPreKeyRecord loadSignedPreKey(Account account, int signedPreKeyId) {
SignedPreKeyRecord record = null;
Cursor cursor = getCursorForSignedPreKey(account, signedPreKeyId);
@ -1528,6 +1539,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return record;
}
@SuppressLint("Range")
public List<SignedPreKeyRecord> loadSignedPreKeys(Account account) {
List<SignedPreKeyRecord> prekeys = new ArrayList<>();
SQLiteDatabase db = this.getReadableDatabase();
@ -1645,6 +1657,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return loadOwnIdentityKeyPair(db, account);
}
@SuppressLint("Range")
private IdentityKeyPair loadOwnIdentityKeyPair(SQLiteDatabase db, Account account) {
String name = account.getJid().asBareJid().toString();
IdentityKeyPair identityKeyPair = null;
@ -1675,7 +1688,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
continue;
}
try {
String key = cursor.getString(cursor.getColumnIndex(SQLiteAxolotlStore.KEY));
@SuppressLint("Range") String key = cursor.getString(cursor.getColumnIndex(SQLiteAxolotlStore.KEY));
if (key != null) {
identityKeys.add(new IdentityKey(Base64.decode(key, Base64.DEFAULT), 0));
} else {
@ -1798,7 +1811,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return null;
} else {
cursor.moveToFirst();
byte[] certificate = cursor.getBlob(cursor.getColumnIndex(SQLiteAxolotlStore.CERTIFICATE));
@SuppressLint("Range") byte[] certificate = cursor.getBlob(cursor.getColumnIndex(SQLiteAxolotlStore.CERTIFICATE));
cursor.close();
if (certificate == null || certificate.length == 0) {
return null;

View file

@ -3933,6 +3933,17 @@ public class XmppConnectionService extends Service {
}
});
}
public void moderateMessage(final Account account, final Message m, final String reason) {
IqPacket request = this.mIqGenerator.moderateMessage(account, m, reason);
Log.d(Config.LOGTAG, "moderate: " + request);
sendIqPacket(account, request, (a, packet) -> {
Log.d(Config.LOGTAG, "moderate1: " + packet);
if (packet.getType() != IqPacket.TYPE.RESULT) {
showErrorToastInUi(R.string.unable_to_moderate);
Log.d(Config.LOGTAG, a.getJid().asBareJid() + " unable to moderate: " + packet);
}
});
}
public void destroyRoom(final Conversation conversation, final OnRoomDestroy callback) {
try {

View file

@ -1482,6 +1482,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
MenuItem quoteMessage = menu.findItem(R.id.quote_message);
MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
MenuItem correctMessage = menu.findItem(R.id.correct_message);
MenuItem moderateMessage = menu.findItem(R.id.moderate_message);
MenuItem deleteMessage = menu.findItem(R.id.delete_message);
MenuItem shareWith = menu.findItem(R.id.share_with);
MenuItem sendAgain = menu.findItem(R.id.send_again);
@ -1512,6 +1513,9 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
&& m.getConversation() instanceof Conversation) {
correctMessage.setVisible(true);
}
if (conversation.getMode() == Conversation.MODE_MULTI && conversation.getMucOptions().getSelf().getRole().ranks(MucOptions.Role.MODERATOR) && conversation.getMucOptions().hasFeature("urn:xmpp:message-moderate:0")) {
moderateMessage.setVisible(true);
}
if ((m.isFileOrImage() && !fileDeleted && !receiving) || (m.getType() == Message.TYPE_TEXT && !m.treatAsDownloadable()) && !unInitiatedButKnownSize && t == null && !messageDeleted) {
shareWith.setVisible(true);
@ -1586,6 +1590,12 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
case R.id.correct_message:
correctMessage(selectedMessage);
return true;
case R.id.moderate_message:
activity.quickEdit("Spam", (reason) -> {
activity.xmppConnectionService.moderateMessage(conversation.getAccount(), selectedMessage, reason);
return null;
}, R.string.moderate_reason, false, false, true);
return true;
case R.id.copy_message:
ShareUtil.copyToClipboard(activity, selectedMessage);
return true;
@ -3004,6 +3014,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
return true;
}
@SuppressLint("StringFormatInvalid")
private void updateSnackBar(final Conversation conversation) {
if (conversation == null) {
return;

View file

@ -298,6 +298,7 @@ public abstract class XmppActivity extends ActionBarActivity {
return xmppConnectionService.getPgpEngine() != null;
}
@SuppressLint("StringFormatInvalid")
public void showInstallPgpDialog() {
final AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(getString(R.string.openkeychain_required));
@ -887,13 +888,16 @@ public abstract class XmppActivity extends ActionBarActivity {
protected void quickPasswordEdit(String previousValue, OnValueEdited callback) {
quickEdit(previousValue, callback, R.string.password, true, false);
}
protected void quickEdit(final String previousValue, final OnValueEdited callback, final @StringRes int hint, boolean password, boolean permitEmpty) {
quickEdit(previousValue, callback, hint, password, permitEmpty, false);
}
@SuppressLint("InflateParams")
private void quickEdit(final String previousValue,
final OnValueEdited callback,
final @StringRes int hint,
boolean password,
boolean permitEmpty) {
void quickEdit(final String previousValue,
final OnValueEdited callback,
final @StringRes int hint,
boolean password,
boolean permitEmpty,
boolean alwaysCallback) {
final AlertDialog.Builder builder = new AlertDialog.Builder(this);
DialogQuickeditBinding binding = DataBindingUtil.inflate(getLayoutInflater(), R.layout.dialog_quickedit, null, false);
if (password) {
@ -914,7 +918,7 @@ public abstract class XmppActivity extends ActionBarActivity {
dialog.show();
View.OnClickListener clickListener = v -> {
String value = binding.inputEditText.getText().toString();
if (!value.equals(previousValue) && (!value.trim().isEmpty() || permitEmpty)) {
if ((alwaysCallback || !value.equals(previousValue)) && (!value.trim().isEmpty() || permitEmpty)) {
String error = callback.onValueEdited(value);
if (error != null) {
binding.inputLayout.setError(error);
@ -1057,7 +1061,7 @@ public abstract class XmppActivity extends ActionBarActivity {
inviteURL = Config.inviteUserURL + user + "/" + domain;
}
Log.d(Config.LOGTAG, "Invite uri = " + inviteURL);
final String inviteText = getString(R.string.InviteText, user);
@SuppressLint({"StringFormatInvalid", "LocalSuppress"}) final String inviteText = getString(R.string.InviteText, user);
final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_SUBJECT, user + " " + getString(R.string.inviteUser_Subject) + " " + getString(R.string.app_name));

View file

@ -29,6 +29,10 @@
android:id="@+id/delete_message"
android:title="@string/delete_message"
android:visible="false" />
<item
android:id="@+id/moderate_message"
android:title="@string/moderate_message"
android:visible="false" />
<item
android:id="@+id/copy_url"
android:title="@string/copy_original_url"

View file

@ -1252,4 +1252,7 @@
<string name="pref_up_push_server_summary">A user-chosen push server to relay push messages via XMPP to your device.</string>
<string name="no_account_deactivated">None (deactivated)</string>
<string name="pref_theme_custom">Custom</string>
<string name="moderate_message">Moderate</string>
<string name="moderate_reason">Moderation Resaon</string>
<string name="unable_to_moderate">Unable to Moderate</string>
</resources>