mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-14 08:07:35 +00:00
Merge remote-tracking branch 'upstream/dev' into feature/unread-mention-indicator
# Conflicts: # app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt # libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt
This commit is contained in:
@@ -47,6 +47,7 @@ import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.WindowDebouncer;
|
||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
|
||||
import org.session.libsignal.utilities.HTTP;
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.ThreadUtils;
|
||||
@@ -67,6 +68,7 @@ import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
|
||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.jobs.FastJobStorage;
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
|
||||
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
||||
@@ -237,6 +239,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
resubmitProfilePictureIfNeeded();
|
||||
loadEmojiSearchIndexIfNeeded();
|
||||
EmojiSource.refresh();
|
||||
|
||||
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
|
||||
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@@ -176,6 +176,11 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
return messageDB.getMessageID(serverId, threadId)
|
||||
}
|
||||
|
||||
override fun getMessageIDs(serverIds: List<Long>, threadId: Long): Pair<List<Long>, List<Long>> {
|
||||
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase()
|
||||
return messageDB.getMessageIDs(serverIds, threadId)
|
||||
}
|
||||
|
||||
override fun deleteMessage(messageID: Long, isSms: Boolean) {
|
||||
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||
else DatabaseComponent.get(context).mmsDatabase()
|
||||
@@ -184,6 +189,15 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
|
||||
}
|
||||
|
||||
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
|
||||
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||
else DatabaseComponent.get(context).mmsDatabase()
|
||||
|
||||
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs)
|
||||
}
|
||||
|
||||
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val address = Address.fromSerialized(author)
|
||||
|
@@ -13,6 +13,11 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
super.onChange(selfChange, uri)
|
||||
uri ?: return
|
||||
|
||||
// There is an odd bug where we can get notified for changes to 'content://media/external'
|
||||
// directly which is a protected folder, this code is to prevent that crash
|
||||
if (uri.scheme == "content" && uri.host == "media" && uri.path == "/external") { return }
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
queryRelativeDataColumn(uri)
|
||||
} else {
|
||||
|
@@ -315,11 +315,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
restoreDraftIfNeeded()
|
||||
setUpUiStateObserver()
|
||||
binding!!.scrollToBottomButton.setOnClickListener {
|
||||
val layoutManager = binding?.conversationRecyclerView?.layoutManager ?: return@setOnClickListener
|
||||
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
|
||||
|
||||
if (layoutManager.isSmoothScrolling) {
|
||||
binding?.conversationRecyclerView?.scrollToPosition(0)
|
||||
} else {
|
||||
binding?.conversationRecyclerView?.smoothScrollToPosition(0)
|
||||
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in
|
||||
// order to do the scroll, this can be very slow if there are a lot of messages so
|
||||
// instead we check the current position and if there are more than 10 items to scroll
|
||||
// we jump instantly to the 10th item and scroll from there (this should happen quick
|
||||
// enough to give a similar scroll effect without having to load everything)
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
if (position > 10) {
|
||||
binding?.conversationRecyclerView?.scrollToPosition(10)
|
||||
}
|
||||
|
||||
binding?.conversationRecyclerView?.post {
|
||||
binding?.conversationRecyclerView?.smoothScrollToPosition(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
|
||||
|
@@ -35,6 +35,7 @@ import com.bumptech.glide.Glide;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
|
||||
@@ -318,6 +319,28 @@ public class AttachmentDatabase extends Database {
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
void deleteAttachmentsForMessages(long[] mmsIds) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
Cursor cursor = null;
|
||||
String mmsIdString = StringUtils.join(mmsIds, ',');
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " IN (?)",
|
||||
new String[] {mmsIdString}, null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
database.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {mmsIdString});
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
public void deleteAttachment(@NonNull AttachmentId id) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
|
@@ -318,6 +318,19 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public boolean hasDownloadedProfilePicture(String groupId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?",
|
||||
new String[] {groupId},
|
||||
null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return !cursor.isNull(0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void updateMembers(String groupId, List<Address> members) {
|
||||
Collections.sort(members);
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
@@ -109,6 +110,11 @@ public class GroupReceiptDatabase extends Database {
|
||||
db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)});
|
||||
}
|
||||
|
||||
void deleteRowsForMessages(long[] mmsIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {StringUtils.join(mmsIds, ',')});
|
||||
}
|
||||
|
||||
void deleteAllRows() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
|
@@ -77,6 +77,25 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
fun deleteMessages(messageIDs: List<Long>) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.beginTransaction()
|
||||
|
||||
database.delete(
|
||||
messageIDTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
database.delete(
|
||||
messageThreadMappingTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
|
||||
database.setTransactionSuccessful()
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return pair of sms or mms table-specific ID and whether it is in SMS table
|
||||
*/
|
||||
@@ -96,6 +115,37 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
|
||||
// Retrieve the message ids
|
||||
val messageIdCursor = database
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT ${messageThreadMappingTable}.${messageID}, ${messageIDTable}.${messageType}
|
||||
FROM ${messageThreadMappingTable}
|
||||
JOIN ${messageIDTable} ON ${messageIDTable}.message_id = ${messageThreadMappingTable}.${messageID}
|
||||
WHERE (
|
||||
${messageThreadMappingTable}.${Companion.threadID} = $threadID AND
|
||||
${messageThreadMappingTable}.${Companion.serverID} IN (${serverIDs.joinToString(",")})
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
val smsMessageIds: MutableList<Long> = mutableListOf()
|
||||
val mmsMessageIds: MutableList<Long> = mutableListOf()
|
||||
while (messageIdCursor.moveToNext()) {
|
||||
if (messageIdCursor.getInt(1) == SMS_TYPE) {
|
||||
smsMessageIds.add(messageIdCursor.getLong(0))
|
||||
}
|
||||
else {
|
||||
mmsMessageIds.add(messageIdCursor.getLong(0))
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(smsMessageIds, mmsMessageIds)
|
||||
}
|
||||
|
||||
override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(3)
|
||||
@@ -183,6 +233,15 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
|
||||
}
|
||||
|
||||
fun deleteMessageServerHashes(messageIDs: List<Long>) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(
|
||||
messageHashTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(1)
|
||||
|
@@ -42,6 +42,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
|
||||
|
||||
public abstract boolean deleteMessage(long messageId);
|
||||
public abstract boolean deleteMessages(long[] messageId, long threadId);
|
||||
|
||||
public abstract void updateThreadId(long fromId, long toId);
|
||||
|
||||
|
@@ -999,6 +999,23 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
return threadDeleted
|
||||
}
|
||||
|
||||
override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean {
|
||||
val attachmentDatabase = get(context).attachmentDatabase()
|
||||
val groupReceiptDatabase = get(context).groupReceiptDatabase()
|
||||
|
||||
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
|
||||
groupReceiptDatabase.deleteRowsForMessages(messageIds)
|
||||
|
||||
val database = databaseHelper.writableDatabase
|
||||
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
|
||||
|
||||
val threadDeleted = get(context).threadDatabase().update(threadId, false)
|
||||
notifyConversationListeners(threadId)
|
||||
notifyStickerListeners()
|
||||
notifyStickerPackListeners()
|
||||
return threadDeleted
|
||||
}
|
||||
|
||||
override fun updateThreadId(fromId: Long, toId: Long) {
|
||||
val contentValues = ContentValues(1)
|
||||
contentValues.put(THREAD_ID, toId)
|
||||
|
@@ -31,6 +31,7 @@ import com.annimon.stream.Stream;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteStatement;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.session.libsession.messaging.calls.CallMessageType;
|
||||
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage;
|
||||
import org.session.libsession.messaging.messages.signal.IncomingTextMessage;
|
||||
@@ -52,6 +53,7 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -603,6 +605,30 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteMessages(long[] messageIds, long threadId) {
|
||||
String[] argsArray = new String[messageIds.length];
|
||||
String[] argValues = new String[messageIds.length];
|
||||
Arrays.fill(argsArray, "?");
|
||||
|
||||
for (int i = 0; i < messageIds.length; i++) {
|
||||
argValues[i] = (messageIds[i] + "");
|
||||
}
|
||||
|
||||
String combinedMessageIdArgss = StringUtils.join(messageIds, ',');
|
||||
String combinedMessageIds = StringUtils.join(messageIds, ',');
|
||||
Log.i("MessageDatabase", "Deleting: " + combinedMessageIds);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(
|
||||
TABLE_NAME,
|
||||
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
|
||||
argValues
|
||||
);
|
||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateThreadId(long fromId, long toId) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
|
@@ -321,6 +321,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
||||
DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue)
|
||||
}
|
||||
|
||||
override fun hasDownloadedProfilePicture(groupID: String): Boolean {
|
||||
return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID)
|
||||
}
|
||||
|
||||
override fun getReceivedMessageTimestamps(): Set<Long> {
|
||||
return SessionMetaProtocol.getTimestamps()
|
||||
}
|
||||
|
@@ -1,7 +1,9 @@
|
||||
package org.thoughtcrime.securesms.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.telephony.TelephonyManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -176,8 +178,22 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
|
||||
_callStateEvents.value = newState
|
||||
}
|
||||
|
||||
fun isBusy(context: Context, callId: UUID) = callId != this.callId && (currentConnectionState != CallState.Idle
|
||||
|| context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE)
|
||||
fun isBusy(context: Context, callId: UUID): Boolean {
|
||||
// Make sure we have the permission before accessing the callState
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
|
||||
return (
|
||||
callId != this.callId && (
|
||||
currentConnectionState != CallState.Idle ||
|
||||
context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
callId != this.callId &&
|
||||
currentConnectionState != CallState.Idle
|
||||
)
|
||||
}
|
||||
|
||||
fun isPreOffer() = currentConnectionState == CallState.RemotePreOffer
|
||||
|
||||
|
@@ -12,6 +12,7 @@ import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.calls.CallMessageType
|
||||
import org.session.libsession.messaging.messages.control.CallMessage
|
||||
import org.session.libsession.messaging.utilities.WebRtcUtils
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
@@ -29,6 +30,10 @@ import org.webrtc.IceCandidate
|
||||
|
||||
class CallMessageProcessor(private val context: Context, private val textSecurePreferences: TextSecurePreferences, lifecycle: Lifecycle, private val storage: StorageProtocol) {
|
||||
|
||||
companion object {
|
||||
private const val VERY_EXPIRED_TIME = 15 * 60 * 1000L
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycle.coroutineScope.launch(IO) {
|
||||
while (isActive) {
|
||||
@@ -53,6 +58,13 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < SnodeAPI.nowWithOffset
|
||||
if (isVeryExpired) {
|
||||
Log.e("Loki", "Dropping very expired call message")
|
||||
continue
|
||||
}
|
||||
|
||||
when (nextMessage.type) {
|
||||
OFFER -> incomingCall(nextMessage)
|
||||
ANSWER -> incomingAnswer(nextMessage)
|
||||
@@ -78,7 +90,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
|
||||
private fun incomingHangup(callMessage: CallMessage) {
|
||||
val callId = callMessage.callId ?: return
|
||||
val hangupIntent = WebRtcCallService.remoteHangupIntent(context, callId)
|
||||
ContextCompat.startForegroundService(context, hangupIntent)
|
||||
context.startService(hangupIntent)
|
||||
}
|
||||
|
||||
private fun incomingAnswer(callMessage: CallMessage) {
|
||||
@@ -91,7 +103,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
|
||||
sdp = sdp,
|
||||
callId = callId
|
||||
)
|
||||
ContextCompat.startForegroundService(context, answerIntent)
|
||||
context.startService(answerIntent)
|
||||
}
|
||||
|
||||
private fun handleIceCandidates(callMessage: CallMessage) {
|
||||
@@ -120,7 +132,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
|
||||
callId = callId,
|
||||
callTime = callMessage.sentTimestamp!!
|
||||
)
|
||||
ContextCompat.startForegroundService(context, incomingIntent)
|
||||
context.startService(incomingIntent)
|
||||
}
|
||||
|
||||
private fun incomingCall(callMessage: CallMessage) {
|
||||
@@ -134,8 +146,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP
|
||||
callId = callId,
|
||||
callTime = callMessage.sentTimestamp!!
|
||||
)
|
||||
ContextCompat.startForegroundService(context, incomingIntent)
|
||||
|
||||
context.startService(incomingIntent)
|
||||
}
|
||||
|
||||
private fun CallMessage.iceCandidates(): List<IceCandidate> {
|
||||
|
@@ -865,5 +865,5 @@
|
||||
<string name="new_conversation_dialog_back_button_content_description">Navigate Back</string>
|
||||
<string name="new_conversation_dialog_close_button_content_description">Close Dialog</string>
|
||||
<string name="ErrorNotifier_migration">Database Upgrade Failed</string>
|
||||
<string name="ErrorNotifier_migration_downgrade">Please contact support to report the error and then install an older version to continue using Session.</string>
|
||||
<string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string>
|
||||
</resources>
|
||||
|
Reference in New Issue
Block a user