mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-23 10:05:15 +00:00
Merge branch 'dev' into on
This commit is contained in:
commit
8ef8107101
@ -31,8 +31,8 @@ configurations.all {
|
||||
exclude module: "commons-logging"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 369
|
||||
def canonicalVersionName = "1.18.1"
|
||||
def canonicalVersionCode = 371
|
||||
def canonicalVersionName = "1.18.2"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['armeabi-v7a' : 1,
|
||||
@ -41,6 +41,17 @@ def abiPostFix = ['armeabi-v7a' : 1,
|
||||
'x86_64' : 4,
|
||||
'universal' : 5]
|
||||
|
||||
// Function to get the current git commit hash so we can embed it along w/ the build version.
|
||||
// Note: This is visible in the SettingsActivity, right at the bottom (R.id.versionTextView).
|
||||
def getGitHash = { ->
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine "git", "rev-parse", "--short", "HEAD"
|
||||
standardOutput = stdout
|
||||
}
|
||||
return stdout.toString().trim()
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion androidCompileSdkVersion
|
||||
namespace 'network.loki.messenger'
|
||||
@ -94,6 +105,7 @@ android {
|
||||
project.ext.set("archivesBaseName", "session")
|
||||
|
||||
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
||||
buildConfigField "String", "GIT_HASH", "\"$getGitHash\""
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String", "USER_AGENT", "\"OWA\""
|
||||
|
@ -41,6 +41,7 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
@ -39,15 +39,20 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
|
||||
public int getDesiredTheme() {
|
||||
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
|
||||
int userSelectedTheme = themeState.getTheme();
|
||||
|
||||
// If the user has configured Session to follow the system light/dark theme mode then do so..
|
||||
if (themeState.getFollowSystem()) {
|
||||
// do light or dark based on the selected theme
|
||||
|
||||
// Use light or dark versions of the user's theme based on light-mode / dark-mode settings
|
||||
boolean isDayUi = UiModeUtilities.isDayUiMode(this);
|
||||
if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) {
|
||||
return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark;
|
||||
} else {
|
||||
return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else // ..otherwise just return their selected theme.
|
||||
{
|
||||
return userSelectedTheme;
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.LinearLayout.VERTICAL
|
||||
import android.widget.Space
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.LayoutRes
|
||||
@ -16,7 +17,6 @@ import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.core.view.updateMargins
|
||||
import androidx.fragment.app.Fragment
|
||||
import network.loki.messenger.R
|
||||
@ -31,13 +31,16 @@ class SessionDialogBuilder(val context: Context) {
|
||||
|
||||
private val dp20 = toPx(20, context.resources)
|
||||
private val dp40 = toPx(40, context.resources)
|
||||
private val dp60 = toPx(60, context.resources)
|
||||
|
||||
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
|
||||
|
||||
private var dialog: AlertDialog? = null
|
||||
private fun dismiss() = dialog?.dismiss()
|
||||
|
||||
private val topView = LinearLayout(context).apply { orientation = VERTICAL }
|
||||
private val topView = LinearLayout(context)
|
||||
.apply { setPadding(0, dp20, 0, 0) }
|
||||
.apply { orientation = VERTICAL }
|
||||
.also(dialogBuilder::setCustomTitle)
|
||||
private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
|
||||
private val buttonLayout = LinearLayout(context)
|
||||
@ -53,14 +56,14 @@ class SessionDialogBuilder(val context: Context) {
|
||||
|
||||
fun title(text: CharSequence?) = title(text?.toString())
|
||||
fun title(text: String?) {
|
||||
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) }
|
||||
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20, 0, dp20, 0) }
|
||||
}
|
||||
|
||||
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
|
||||
fun text(text: CharSequence?, @StyleRes style: Int = 0) {
|
||||
text(text, style) {
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
.apply { updateMargins(dp40, 0, dp40, dp20) }
|
||||
.apply { updateMargins(dp40, 0, dp40, 0) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,6 +75,10 @@ class SessionDialogBuilder(val context: Context) {
|
||||
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
modify()
|
||||
}.let(topView::addView)
|
||||
|
||||
Space(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp20)
|
||||
}.let(topView::addView)
|
||||
}
|
||||
|
||||
fun htmlText(@StringRes id: Int, @StyleRes style: Int = 0, modify: TextView.() -> Unit = {}) { text(context.resources.getText(id)) }
|
||||
@ -126,8 +133,7 @@ class SessionDialogBuilder(val context: Context) {
|
||||
) = Button(context, null, 0, style).apply {
|
||||
setText(text)
|
||||
contentDescription = resources.getString(contentDescriptionRes)
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f)
|
||||
.apply { setMargins(toPx(20, resources)) }
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, dp60, 1f)
|
||||
setOnClickListener {
|
||||
listener.invoke()
|
||||
if (dismiss) dismiss()
|
||||
|
@ -93,6 +93,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
super.onNewIntent(intent)
|
||||
if (intent?.action == ACTION_ANSWER) {
|
||||
val answerIntent = WebRtcCallService.acceptCallIntent(this)
|
||||
answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||
ContextCompat.startForegroundService(this, answerIntent)
|
||||
}
|
||||
}
|
||||
@ -106,6 +107,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
}
|
||||
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
|
||||
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
|
||||
|
@ -17,6 +17,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
@ -84,7 +85,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
||||
context.theme.resolveAttribute(item.iconRes, typedValue, true)
|
||||
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
|
||||
|
||||
icon.imageTintList = color?.let(ColorStateList::valueOf)
|
||||
icon.imageTintList = ColorStateList.valueOf(color ?: context.getColorFromAttr(android.R.attr.textColor))
|
||||
}
|
||||
item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
|
||||
title.setText(item.title)
|
||||
|
@ -68,7 +68,7 @@ enum class ExpiryType(
|
||||
AFTER_SEND(
|
||||
ExpiryMode::AfterSend,
|
||||
R.string.expiration_type_disappear_after_send,
|
||||
R.string.expiration_type_disappear_after_read_description,
|
||||
R.string.expiration_type_disappear_after_send_description,
|
||||
R.string.AccessibilityId_disappear_after_send_option
|
||||
);
|
||||
|
||||
|
@ -35,11 +35,28 @@ class ContactListAdapter(
|
||||
binding.profilePictureView.update(contact.recipient)
|
||||
binding.nameTextView.text = contact.displayName
|
||||
binding.root.setOnClickListener { listener(contact.recipient) }
|
||||
|
||||
// TODO: When we implement deleting contacts (hide might be safest for now) then probably set a long-click listener here w/ something like:
|
||||
/*
|
||||
binding.root.setOnLongClickListener {
|
||||
Log.w("[ACL]", "Long clicked on contact ${contact.recipient.name}")
|
||||
binding.contentView.context.showSessionDialog {
|
||||
title("Delete Contact")
|
||||
text("Are you sure you want to delete this contact?")
|
||||
button(R.string.delete) {
|
||||
val contacts = configFactory.contacts ?: return
|
||||
contacts.upsertContact(contact.recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
endActionMode()
|
||||
}
|
||||
cancelButton(::endActionMode)
|
||||
}
|
||||
true
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
binding.profilePictureView.recycle()
|
||||
}
|
||||
fun unbind() { binding.profilePictureView.recycle() }
|
||||
}
|
||||
|
||||
class HeaderViewHolder(
|
||||
@ -52,15 +69,11 @@ class ContactListAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
override fun getItemCount(): Int { return items.size }
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
if (holder is ContactViewHolder) {
|
||||
holder.unbind()
|
||||
}
|
||||
if (holder is ContactViewHolder) { holder.unbind() }
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
@ -72,13 +85,9 @@ class ContactListAdapter(
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == ViewType.Contact) {
|
||||
ContactViewHolder(
|
||||
ViewContactBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
)
|
||||
ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false))
|
||||
} else {
|
||||
HeaderViewHolder(
|
||||
ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
)
|
||||
HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -252,7 +252,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
||||
}
|
||||
private var actionMode: ActionMode? = null
|
||||
private var unreadCount = 0
|
||||
private var unreadCount = Int.MAX_VALUE
|
||||
// Attachments
|
||||
private val audioRecorder = AudioRecorder(this)
|
||||
private val stopAudioHandler = Handler(Looper.getMainLooper())
|
||||
@ -572,10 +572,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding!!.conversationRecyclerView.layoutManager = layoutManager
|
||||
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this)
|
||||
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
if (recyclerScrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
// The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation
|
||||
if (recyclerScrollState == RecyclerView.SCROLL_STATE_IDLE && unreadCount != Int.MAX_VALUE) {
|
||||
scrollToMostRecentMessageIfWeShould()
|
||||
}
|
||||
handleRecyclerViewScrolled()
|
||||
@ -1251,6 +1252,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
// `position` is the adapter position; not the visual position
|
||||
private fun handleSwipeToReply(message: MessageRecord) {
|
||||
if (message.isOpenGroupInvitation) return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
binding?.inputBar?.draftQuote(recipient, message, glide)
|
||||
}
|
||||
|
@ -532,7 +532,7 @@ class ConversationReactionOverlay : FrameLayout {
|
||||
items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
|
||||
// Reply
|
||||
val canWrite = openGroup == null || openGroup.canWrite
|
||||
if (canWrite && !message.isPending && !message.isFailed) {
|
||||
if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation) {
|
||||
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
|
||||
}
|
||||
// Copy message text
|
||||
|
@ -123,7 +123,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||
AppTheme {
|
||||
MessageDetails(
|
||||
state = state,
|
||||
onReply = { setResultAndFinish(ON_REPLY) },
|
||||
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
|
||||
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
|
||||
onDelete = { setResultAndFinish(ON_DELETE) },
|
||||
onClickImage = { viewModel.onClickImage(it) },
|
||||
@ -145,7 +145,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||
@Composable
|
||||
fun MessageDetails(
|
||||
state: MessageDetailsState,
|
||||
onReply: () -> Unit = {},
|
||||
onReply: (() -> Unit)? = null,
|
||||
onResend: (() -> Unit)? = null,
|
||||
onDelete: () -> Unit = {},
|
||||
onClickImage: (Int) -> Unit = {},
|
||||
@ -214,18 +214,20 @@ fun CellMetadata(
|
||||
|
||||
@Composable
|
||||
fun CellButtons(
|
||||
onReply: () -> Unit = {},
|
||||
onReply: (() -> Unit)? = null,
|
||||
onResend: (() -> Unit)? = null,
|
||||
onDelete: () -> Unit = {},
|
||||
) {
|
||||
Cell {
|
||||
Column {
|
||||
ItemButton(
|
||||
stringResource(R.string.reply),
|
||||
R.drawable.ic_message_details__reply,
|
||||
onClick = onReply
|
||||
)
|
||||
Divider()
|
||||
onReply?.let {
|
||||
ItemButton(
|
||||
stringResource(R.string.reply),
|
||||
R.drawable.ic_message_details__reply,
|
||||
onClick = it
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
onResend?.let {
|
||||
ItemButton(
|
||||
stringResource(R.string.resend),
|
||||
|
@ -117,7 +117,7 @@ class MessageDetailsViewModel @Inject constructor(
|
||||
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
|
||||
|
||||
fun onClickImage(index: Int) {
|
||||
val state = state.value ?: return
|
||||
val state = state.value
|
||||
val mmsRecord = state.mmsRecord ?: return
|
||||
val slide = mmsRecord.slideDeck.slides[index] ?: return
|
||||
// only open to downloaded images
|
||||
@ -158,6 +158,7 @@ data class MessageDetailsState(
|
||||
val thread: Recipient? = null,
|
||||
) {
|
||||
val fromTitle = GetString(R.string.message_details_header__from)
|
||||
val canReply = record?.isOpenGroupInvitation != true
|
||||
}
|
||||
|
||||
data class Attachment(
|
||||
|
@ -140,6 +140,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
||||
}
|
||||
|
||||
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
|
||||
quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
|
||||
|
||||
quote = message
|
||||
|
||||
// If we already have a link preview View then clear the 'additional content' layout so that
|
||||
@ -178,7 +180,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
||||
// message we'll bail early if a link preview View already exists and just let
|
||||
// `updateLinkPreview` get called to update the existing View.
|
||||
if (linkPreview != null && linkPreviewDraftView != null) return
|
||||
|
||||
linkPreviewDraftView?.let(binding.inputBarAdditionalContentContainer::removeView)
|
||||
linkPreviewDraftView = LinkPreviewDraftView(context).also { it.delegate = this }
|
||||
|
||||
// Add the link preview View. Note: If there's already a quote View in the 'additional
|
||||
|
@ -77,7 +77,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
||||
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
|
||||
// Reply
|
||||
menu.findItem(R.id.menu_context_reply).isVisible =
|
||||
(selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed)
|
||||
(selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed && !firstMessage.isOpenGroupInvitation)
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean {
|
||||
|
@ -37,6 +37,7 @@ import org.session.libsession.utilities.ViewUtil
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.session.libsession.utilities.modifyLayoutParams
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
@ -65,6 +66,7 @@ private const val TAG = "VisibleMessageView"
|
||||
|
||||
@AndroidEntryPoint
|
||||
class VisibleMessageView : LinearLayout {
|
||||
private var replyDisabled: Boolean = false
|
||||
@Inject lateinit var threadDb: ThreadDatabase
|
||||
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
|
||||
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
||||
@ -135,6 +137,7 @@ class VisibleMessageView : LinearLayout {
|
||||
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
||||
lastSentMessageId: Long
|
||||
) {
|
||||
replyDisabled = message.isOpenGroupInvitation
|
||||
val threadID = message.threadId
|
||||
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
|
||||
val isGroupThread = thread.isGroupRecipient
|
||||
@ -206,7 +209,7 @@ class VisibleMessageView : LinearLayout {
|
||||
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
|
||||
binding.dateBreakTextView.isVisible = showDateBreak
|
||||
|
||||
// Message status indicator
|
||||
// Update message status indicator
|
||||
showStatusMessage(message)
|
||||
|
||||
// Emoji Reactions
|
||||
@ -243,44 +246,101 @@ class VisibleMessageView : LinearLayout {
|
||||
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
|
||||
}
|
||||
|
||||
// Method to display or hide the status of a message.
|
||||
// Note: Although most commonly used to display the delivery status of a message, we also use the
|
||||
// message status area to display the disappearing messages state - so in this latter case we'll
|
||||
// be displaying the "Sent" and the animating clock icon for outgoing messages or "Read" and the
|
||||
// animated clock icon for incoming messages.
|
||||
private fun showStatusMessage(message: MessageRecord) {
|
||||
// We'll start by hiding everything and then only make visible what we need
|
||||
binding.messageStatusTextView.isVisible = false
|
||||
binding.messageStatusImageView.isVisible = false
|
||||
binding.expirationTimerView.isVisible = false
|
||||
|
||||
val scheduledToDisappear = message.expiresIn > 0
|
||||
// Get details regarding how we should display the message (it's delivery icon, icon tint colour, and
|
||||
// the resource string for what text to display (R.string.delivery_status_sent etc.).
|
||||
val (iconID, iconColor, textId) = getMessageStatusInfo(message)
|
||||
|
||||
// If we get any nulls then a message isn't one with a state that we care about (i.e., control messages
|
||||
// etc.) - so bail. See: `DisplayRecord.is<WHATEVER>` for the full suite of message state methods.
|
||||
// Also: We set all delivery status elements visibility to false just to make sure we don't display any
|
||||
// stale data.
|
||||
if (textId == null) return
|
||||
|
||||
binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> {
|
||||
gravity = if (message.isOutgoing) Gravity.END else Gravity.START
|
||||
}
|
||||
|
||||
binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
}
|
||||
|
||||
binding.expirationTimerView.isGone = true
|
||||
// If the message is incoming AND it is not scheduled to disappear then don't show any status or timer details
|
||||
val scheduledToDisappear = message.expiresIn > 0
|
||||
if (message.isIncoming && !scheduledToDisappear) return
|
||||
|
||||
if (message.isOutgoing || scheduledToDisappear) {
|
||||
val (iconID, iconColor, textId) = getMessageStatusImage(message)
|
||||
textId?.let(binding.messageStatusTextView::setText)
|
||||
iconColor?.let(binding.messageStatusTextView::setTextColor)
|
||||
iconID?.let { ContextCompat.getDrawable(context, it) }
|
||||
?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this }
|
||||
?.let(binding.messageStatusImageView::setImageDrawable)
|
||||
// Set text & icons as appropriate for the message state. Note: Possible message states we care
|
||||
// about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent.
|
||||
textId.let(binding.messageStatusTextView::setText)
|
||||
iconColor?.let(binding.messageStatusTextView::setTextColor)
|
||||
iconID?.let { ContextCompat.getDrawable(context, it) }
|
||||
?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this }
|
||||
?.let(binding.messageStatusImageView::setImageDrawable)
|
||||
|
||||
// Always show the delivery status of the last sent message
|
||||
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
|
||||
val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId)
|
||||
val isLastSentMessage = lastSentMessageId == message.id
|
||||
// Potential options at this point are that the message is:
|
||||
// i.) incoming AND scheduled to disappear.
|
||||
// ii.) outgoing but NOT scheduled to disappear, or
|
||||
// iii.) outgoing AND scheduled to disappear.
|
||||
|
||||
binding.messageStatusTextView.isVisible = textId != null && (isLastSentMessage || scheduledToDisappear)
|
||||
val showTimer = scheduledToDisappear && !message.isPending
|
||||
binding.messageStatusImageView.isVisible = iconID != null && !showTimer && (!message.isSent || isLastSentMessage)
|
||||
|
||||
binding.messageStatusImageView.bringToFront()
|
||||
// ----- Case i..) Message is incoming and scheduled to disappear -----
|
||||
if (message.isIncoming && scheduledToDisappear) {
|
||||
// Display the status ('Read') and the show the timer only (no delivery icon)
|
||||
binding.messageStatusTextView.isVisible = true
|
||||
binding.expirationTimerView.isVisible = true
|
||||
binding.expirationTimerView.bringToFront()
|
||||
binding.expirationTimerView.isVisible = showTimer
|
||||
if (showTimer) updateExpirationTimer(message)
|
||||
} else {
|
||||
binding.messageStatusTextView.isVisible = false
|
||||
binding.messageStatusImageView.isVisible = false
|
||||
updateExpirationTimer(message)
|
||||
return
|
||||
}
|
||||
|
||||
// --- If we got here then we know the message is outgoing ---
|
||||
|
||||
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
|
||||
val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId)
|
||||
val isLastSentMessage = lastSentMessageId == message.id
|
||||
|
||||
// ----- Case ii.) Message is outgoing but NOT scheduled to disappear -----
|
||||
if (!scheduledToDisappear) {
|
||||
// If this isn't a disappearing message then we never show the timer
|
||||
|
||||
// If the message has NOT been successfully sent then always show the delivery status text and icon..
|
||||
val neitherSentNorRead = !(message.isSent || message.isRead)
|
||||
if (neitherSentNorRead) {
|
||||
binding.messageStatusTextView.isVisible = true
|
||||
binding.messageStatusImageView.isVisible = true
|
||||
} else {
|
||||
// ..but if the message HAS been successfully sent or read then only display the delivery status
|
||||
// text and image if this is the last sent message.
|
||||
binding.messageStatusTextView.isVisible = isLastSentMessage
|
||||
binding.messageStatusImageView.isVisible = isLastSentMessage
|
||||
if (isLastSentMessage) { binding.messageStatusImageView.bringToFront() }
|
||||
}
|
||||
}
|
||||
else // ----- Case iii.) Message is outgoing AND scheduled to disappear -----
|
||||
{
|
||||
// Always display the delivery status text on all outgoing disappearing messages
|
||||
binding.messageStatusTextView.isVisible = true
|
||||
|
||||
// If the message is sent or has been read..
|
||||
val sentOrRead = message.isSent || message.isRead
|
||||
if (sentOrRead) {
|
||||
// ..then display the timer icon for this disappearing message (but keep the message status icon hidden)
|
||||
binding.expirationTimerView.isVisible = true
|
||||
binding.expirationTimerView.bringToFront()
|
||||
updateExpirationTimer(message)
|
||||
} else {
|
||||
// If the message has NOT been sent or read (or it has failed) then show the delivery status icon rather than the timer icon
|
||||
binding.messageStatusImageView.isVisible = true
|
||||
binding.messageStatusImageView.bringToFront()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -302,10 +362,9 @@ class VisibleMessageView : LinearLayout {
|
||||
@ColorInt val iconTint: Int?,
|
||||
@StringRes val messageText: Int?)
|
||||
|
||||
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
|
||||
private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when {
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
MessageStatusInfo(R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed
|
||||
)
|
||||
@ -318,24 +377,32 @@ class VisibleMessageView : LinearLayout {
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sending
|
||||
)
|
||||
message.isResyncing ->
|
||||
message.isSyncing || message.isResyncing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sending // We COULD tell the user that we're `syncing` (R.string.delivery_status_syncing) but it will likely make more sense to them if we say "Sending"
|
||||
)
|
||||
message.isRead || !message.isOutgoing ->
|
||||
message.isRead || message.isIncoming ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_read
|
||||
)
|
||||
else ->
|
||||
message.isSent ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sent,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sent
|
||||
)
|
||||
else -> {
|
||||
// The message isn't one we care about for message statuses we display to the user (i.e.,
|
||||
// control messages etc. - see the `DisplayRecord.is<WHATEVER>` suite of methods for options).
|
||||
MessageStatusInfo(null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateExpirationTimer(message: MessageRecord) {
|
||||
@ -409,6 +476,7 @@ class VisibleMessageView : LinearLayout {
|
||||
} else {
|
||||
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
|
||||
}
|
||||
if (replyDisabled) return
|
||||
if (translationX > 0) { return } // Only allow swipes to the left
|
||||
// The idea here is to asymptotically approach a maximum drag distance
|
||||
val damping = 50.0f
|
||||
|
@ -241,7 +241,21 @@ public class AttachmentManager {
|
||||
}
|
||||
|
||||
public static void selectDocument(Activity activity, int requestCode) {
|
||||
selectMediaType(activity, "*/*", null, requestCode);
|
||||
Permissions.PermissionsBuilder builder = Permissions.with(activity);
|
||||
|
||||
// The READ_EXTERNAL_STORAGE permission is deprecated (and will AUTO-FAIL if requested!) on
|
||||
// Android 13 and above (API 33 - 'Tiramisu') we must ask for READ_MEDIA_VIDEO/IMAGES/AUDIO instead.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
.request(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
.request(Manifest.permission.READ_MEDIA_AUDIO);
|
||||
} else {
|
||||
builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
|
||||
}
|
||||
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
|
||||
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
|
||||
.onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) // Note: We can use startActivityForResult w/ the ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE intent if we need to modernise this.
|
||||
.execute();
|
||||
}
|
||||
|
||||
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
|
||||
|
@ -1,21 +1,28 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.style.BackgroundColorSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.util.Range
|
||||
import androidx.appcompat.widget.ThemeUtils
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import network.loki.messenger.R
|
||||
import nl.komponents.kovenant.combine.Tuple2
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.ThemeUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.UiModeUtilities
|
||||
import org.thoughtcrime.securesms.util.getAccentColor
|
||||
import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr
|
||||
import org.thoughtcrime.securesms.util.getMessageTextColourAttr
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object MentionUtilities {
|
||||
@ -58,15 +65,37 @@ object MentionUtilities {
|
||||
}
|
||||
}
|
||||
val result = SpannableString(text)
|
||||
val isLightMode = UiModeUtilities.isDayUiMode(context)
|
||||
val color = if (isOutgoingMessage) {
|
||||
ResourcesCompat.getColor(context.resources, if (isLightMode) R.color.white else R.color.black, context.theme)
|
||||
} else {
|
||||
context.getAccentColor()
|
||||
|
||||
var mentionTextColour: Int? = null
|
||||
// In dark themes..
|
||||
if (ThemeUtil.isDarkTheme(context)) {
|
||||
// ..we use the standard outgoing message colour for outgoing messages..
|
||||
if (isOutgoingMessage) {
|
||||
val mentionTextColourAttributeId = getMessageTextColourAttr(true)
|
||||
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
|
||||
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
|
||||
}
|
||||
else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
|
||||
{
|
||||
mentionTextColour = context.getAccentColor()
|
||||
}
|
||||
}
|
||||
else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
|
||||
{
|
||||
val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
|
||||
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
|
||||
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
|
||||
}
|
||||
|
||||
for (mention in mentions) {
|
||||
result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
|
||||
// If we're using a light theme then we change the background colour of the mention to be the accent colour
|
||||
if (ThemeUtil.isLightTheme(context)) {
|
||||
val backgroundColour = context.getAccentColor();
|
||||
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
@ -320,6 +320,19 @@ public class MmsSmsDatabase extends Database {
|
||||
return -1;
|
||||
}
|
||||
|
||||
public long getLastMessageTimestamp(long threadId) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) {
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public Cursor getUnread() {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC";
|
||||
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";
|
||||
|
@ -22,15 +22,11 @@ import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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;
|
||||
@ -51,7 +47,6 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
@ -634,7 +629,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
|
||||
notifyConversationListeners(threadId);
|
||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
|
||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false);
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
@ -701,12 +696,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/*package */void deleteThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
|
||||
}
|
||||
|
||||
/*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
||||
void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
|
||||
|
||||
@ -719,7 +709,12 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
|
||||
}
|
||||
|
||||
/*package*/ void deleteThreads(Set<Long> threadIds) {
|
||||
void deleteThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
|
||||
}
|
||||
|
||||
void deleteThreads(Set<Long> threadIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = "";
|
||||
|
||||
@ -727,23 +722,23 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
where += THREAD_ID + " = '" + threadId + "' OR ";
|
||||
}
|
||||
|
||||
where = where.substring(0, where.length() - 4);
|
||||
where = where.substring(0, where.length() - 4); // Remove the final: "' OR "
|
||||
|
||||
db.delete(TABLE_NAME, where, null);
|
||||
}
|
||||
|
||||
/*package */ void deleteAllThreads() {
|
||||
void deleteAllThreads() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
}
|
||||
|
||||
/*package*/ SQLiteDatabase beginTransaction() {
|
||||
SQLiteDatabase beginTransaction() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
return database;
|
||||
}
|
||||
|
||||
/*package*/ void endTransaction(SQLiteDatabase database) {
|
||||
void endTransaction(SQLiteDatabase database) {
|
||||
database.setTransactionSuccessful();
|
||||
database.endTransaction();
|
||||
}
|
||||
|
@ -99,7 +99,7 @@ private const val TAG = "Storage"
|
||||
open class Storage(
|
||||
context: Context,
|
||||
helper: SQLCipherOpenHelper,
|
||||
private val configFactory: ConfigFactory
|
||||
val configFactory: ConfigFactory
|
||||
) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
|
||||
|
||||
override fun threadCreated(address: Address, threadId: Long) {
|
||||
@ -1371,29 +1371,29 @@ open class Storage(
|
||||
val threadDB = DatabaseComponent.get(context).threadDatabase()
|
||||
val groupDB = DatabaseComponent.get(context).groupDatabase()
|
||||
threadDB.deleteConversation(threadID)
|
||||
val recipient = getRecipientForThread(threadID) ?: return
|
||||
when {
|
||||
recipient.isContactRecipient -> {
|
||||
if (recipient.isLocalNumber) return
|
||||
val contacts = configFactory.contacts ?: return
|
||||
contacts.upsertContact(recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
// TODO: handle closed group
|
||||
val volatile = configFactory.convoVolatile ?: return
|
||||
val groups = configFactory.userGroups ?: return
|
||||
val groupID = recipient.address.toGroupString()
|
||||
val closedGroup = getGroup(groupID)
|
||||
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
|
||||
if (closedGroup != null) {
|
||||
groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it)
|
||||
volatile.eraseLegacyClosedGroup(groupPublicKey)
|
||||
groups.eraseLegacyGroup(groupPublicKey)
|
||||
} else {
|
||||
Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
|
||||
}
|
||||
}
|
||||
|
||||
val recipient = getRecipientForThread(threadID)
|
||||
if (recipient == null) {
|
||||
Log.w(TAG, "Got null recipient when deleting conversation - aborting.");
|
||||
return
|
||||
}
|
||||
|
||||
// There is nothing further we need to do if this is a 1-on-1 conversation, and it's not
|
||||
// possible to delete communities in this manner so bail.
|
||||
if (recipient.isContactRecipient || recipient.isCommunityRecipient) return
|
||||
|
||||
// If we get here then this is a closed group conversation (i.e., recipient.isClosedGroupRecipient)
|
||||
val volatile = configFactory.convoVolatile ?: return
|
||||
val groups = configFactory.userGroups ?: return
|
||||
val groupID = recipient.address.toGroupString()
|
||||
val closedGroup = getGroup(groupID)
|
||||
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
|
||||
if (closedGroup != null) {
|
||||
groupDB.delete(groupID)
|
||||
volatile.eraseLegacyClosedGroup(groupPublicKey)
|
||||
groups.eraseLegacyGroup(groupPublicKey)
|
||||
} else {
|
||||
Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,14 +26,10 @@ import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MergeCursor;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
@ -61,7 +57,6 @@ import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@ -83,7 +78,7 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
public static final String TABLE_NAME = "thread";
|
||||
public static final String ID = "_id";
|
||||
public static final String DATE = "date";
|
||||
public static final String THREAD_CREATION_DATE = "date";
|
||||
public static final String MESSAGE_COUNT = "message_count";
|
||||
public static final String ADDRESS = "recipient_ids";
|
||||
public static final String SNIPPET = "snippet";
|
||||
@ -91,7 +86,7 @@ public class ThreadDatabase extends Database {
|
||||
public static final String READ = "read";
|
||||
public static final String UNREAD_COUNT = "unread_count";
|
||||
public static final String UNREAD_MENTION_COUNT = "unread_mention_count";
|
||||
public static final String TYPE = "type";
|
||||
public static final String DISTRIBUTION_TYPE = "type"; // See: DistributionTypes.kt
|
||||
private static final String ERROR = "error";
|
||||
public static final String SNIPPET_TYPE = "snippet_type";
|
||||
public static final String SNIPPET_URI = "snippet_uri";
|
||||
@ -101,27 +96,27 @@ public class ThreadDatabase extends Database {
|
||||
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
|
||||
public static final String EXPIRES_IN = "expires_in";
|
||||
public static final String LAST_SEEN = "last_seen";
|
||||
public static final String HAS_SENT = "has_sent";
|
||||
public static final String HAS_SENT = "has_sent";
|
||||
public static final String IS_PINNED = "is_pinned";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
|
||||
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +
|
||||
ID + " INTEGER PRIMARY KEY, " + THREAD_CREATION_DATE + " INTEGER DEFAULT 0, " +
|
||||
MESSAGE_COUNT + " INTEGER DEFAULT 0, " + ADDRESS + " TEXT, " + SNIPPET + " TEXT, " +
|
||||
SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " +
|
||||
TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
|
||||
DISTRIBUTION_TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
|
||||
SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " +
|
||||
ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " +
|
||||
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " +
|
||||
LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " +
|
||||
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
public static final String[] CREATE_INDEXES = {
|
||||
"CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + ADDRESS + ");",
|
||||
"CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");",
|
||||
};
|
||||
|
||||
private static final String[] THREAD_PROJECTION = {
|
||||
ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, TYPE, ERROR, SNIPPET_TYPE,
|
||||
ID, THREAD_CREATION_DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, DISTRIBUTION_TYPE, ERROR, SNIPPET_TYPE,
|
||||
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED
|
||||
};
|
||||
|
||||
@ -131,8 +126,8 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION),
|
||||
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
|
||||
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
|
||||
.toList();
|
||||
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
|
||||
.toList();
|
||||
|
||||
public static String getCreatePinnedCommand() {
|
||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
@ -158,11 +153,10 @@ public class ThreadDatabase extends Database {
|
||||
ContentValues contentValues = new ContentValues(4);
|
||||
long date = SnodeAPI.getNowWithOffset();
|
||||
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
|
||||
if (group)
|
||||
contentValues.put(TYPE, distributionType);
|
||||
if (group) contentValues.put(DISTRIBUTION_TYPE, distributionType);
|
||||
|
||||
contentValues.put(MESSAGE_COUNT, 0);
|
||||
|
||||
@ -175,7 +169,7 @@ public class ThreadDatabase extends Database {
|
||||
long expiresIn, int readReceiptCount)
|
||||
{
|
||||
ContentValues contentValues = new ContentValues(7);
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
|
||||
contentValues.put(MESSAGE_COUNT, count);
|
||||
if (!body.isEmpty()) {
|
||||
contentValues.put(SNIPPET, body);
|
||||
@ -187,9 +181,7 @@ public class ThreadDatabase extends Database {
|
||||
contentValues.put(READ_RECEIPT_COUNT, readReceiptCount);
|
||||
contentValues.put(EXPIRES_IN, expiresIn);
|
||||
|
||||
if (unarchive) {
|
||||
contentValues.put(ARCHIVED, 0);
|
||||
}
|
||||
if (unarchive) { contentValues.put(ARCHIVED, 0); }
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""});
|
||||
@ -199,7 +191,7 @@ public class ThreadDatabase extends Database {
|
||||
public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) {
|
||||
ContentValues contentValues = new ContentValues(4);
|
||||
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
|
||||
if (!snippet.isEmpty()) {
|
||||
contentValues.put(SNIPPET, snippet);
|
||||
}
|
||||
@ -230,9 +222,7 @@ public class ThreadDatabase extends Database {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = "";
|
||||
|
||||
for (long threadId : threadIds) {
|
||||
where += ID + " = '" + threadId + "' OR ";
|
||||
}
|
||||
for (long threadId : threadIds) { where += ID + " = '" + threadId + "' OR "; }
|
||||
|
||||
where = where.substring(0, where.length() - 4);
|
||||
|
||||
@ -358,7 +348,7 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
public void setDistributionType(long threadId, int distributionType) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(TYPE, distributionType);
|
||||
contentValues.put(DISTRIBUTION_TYPE, distributionType);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
|
||||
@ -367,7 +357,7 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
public void setDate(long threadId, long date) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(DATE, date);
|
||||
contentValues.put(THREAD_CREATION_DATE, date);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
|
||||
if (updated > 0) notifyConversationListListeners();
|
||||
@ -375,11 +365,11 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
public int getDistributionType(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{DISTRIBUTION_TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return cursor.getInt(cursor.getColumnIndexOrThrow(TYPE));
|
||||
return cursor.getInt(cursor.getColumnIndexOrThrow(DISTRIBUTION_TYPE));
|
||||
}
|
||||
|
||||
return DistributionTypes.DEFAULT;
|
||||
@ -469,7 +459,7 @@ public class ThreadDatabase extends Database {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
String where = "SELECT " + DATE + " FROM " + TABLE_NAME +
|
||||
String where = "SELECT " + THREAD_CREATION_DATE + " FROM " + TABLE_NAME +
|
||||
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
|
||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
|
||||
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
||||
@ -477,7 +467,7 @@ public class ThreadDatabase extends Database {
|
||||
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
|
||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
|
||||
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
|
||||
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL ORDER BY " + DATE + " DESC LIMIT 1";
|
||||
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL ORDER BY " + THREAD_CREATION_DATE + " DESC LIMIT 1";
|
||||
cursor = db.rawQuery(where, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
@ -595,7 +585,7 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
public Long getLastUpdated(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{THREAD_CREATION_DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
@ -736,7 +726,7 @@ public class ThreadDatabase extends Database {
|
||||
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
|
||||
long count = mmsSmsDatabase.getConversationCount(threadId);
|
||||
|
||||
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId);
|
||||
boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId);
|
||||
|
||||
if (count == 0 && shouldDeleteEmptyThread) {
|
||||
deleteThread(threadId);
|
||||
@ -810,7 +800,7 @@ public class ThreadDatabase extends Database {
|
||||
return setLastSeen(threadId, lastSeenTime);
|
||||
}
|
||||
|
||||
private boolean deleteThreadOnEmpty(long threadId) {
|
||||
private boolean possibleToDeleteThreadOnEmpty(long threadId) {
|
||||
Recipient threadRecipient = getRecipientForThreadId(threadId);
|
||||
return threadRecipient != null && !threadRecipient.isCommunityRecipient();
|
||||
}
|
||||
@ -855,7 +845,7 @@ public class ThreadDatabase extends Database {
|
||||
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
|
||||
" WHERE " + where +
|
||||
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + DATE + " DESC";
|
||||
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
|
||||
|
||||
if (limit > 0) {
|
||||
query += " LIMIT " + limit;
|
||||
@ -900,7 +890,7 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
public ThreadRecord getCurrent() {
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID));
|
||||
int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE));
|
||||
int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DISTRIBUTION_TYPE));
|
||||
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.ADDRESS)));
|
||||
|
||||
Optional<RecipientSettings> settings;
|
||||
@ -916,7 +906,7 @@ public class ThreadDatabase extends Database {
|
||||
|
||||
Recipient recipient = Recipient.from(context, address, settings, groupRecord, true);
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET));
|
||||
long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE));
|
||||
long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.THREAD_CREATION_DATE));
|
||||
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
|
||||
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
|
||||
int unreadMentionCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_MENTION_COUNT));
|
||||
@ -934,7 +924,17 @@ public class ThreadDatabase extends Database {
|
||||
readReceiptCount = 0;
|
||||
}
|
||||
|
||||
return new ThreadRecord(body, snippetUri, recipient, date, count,
|
||||
MessageRecord lastMessage = null;
|
||||
|
||||
if (count > 0) {
|
||||
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
|
||||
long messageTimestamp = mmsSmsDatabase.getLastMessageTimestamp(threadId);
|
||||
if (messageTimestamp > 0) {
|
||||
lastMessage = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
|
||||
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
|
||||
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
|
||||
}
|
||||
|
@ -357,7 +357,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, MmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, AttachmentDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, ThreadDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, ThreadDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, DraftDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
|
||||
|
@ -78,8 +78,8 @@ public abstract class DisplayRecord {
|
||||
public int getReadReceiptCount() { return readReceiptCount; }
|
||||
|
||||
public boolean isDelivered() {
|
||||
return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE
|
||||
&& deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
|
||||
return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE &&
|
||||
deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
|
||||
}
|
||||
|
||||
public boolean isSent() { return MmsSmsColumns.Types.isSentType(type); }
|
||||
@ -114,6 +114,11 @@ public abstract class DisplayRecord {
|
||||
public boolean isOutgoing() {
|
||||
return MmsSmsColumns.Types.isOutgoingMessageType(type);
|
||||
}
|
||||
|
||||
public boolean isIncoming() {
|
||||
return !MmsSmsColumns.Types.isOutgoingMessageType(type);
|
||||
}
|
||||
|
||||
public boolean isGroupUpdateMessage() {
|
||||
return SmsDatabase.Types.isGroupUpdateMessage(type);
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import org.session.libsession.messaging.utilities.UpdateMessageData;
|
||||
import org.session.libsession.utilities.IdentityKeyMismatch;
|
||||
import org.session.libsession.utilities.NetworkFailure;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@ -120,7 +121,8 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
|
||||
} else if (isExpirationTimerUpdate()) {
|
||||
int seconds = (int) (getExpiresIn() / 1000);
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getRecipient(), getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted));
|
||||
boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, isGroup, getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted));
|
||||
} else if (isDataExtractionNotification()) {
|
||||
if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize())));
|
||||
else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize())));
|
||||
|
@ -43,6 +43,7 @@ import network.loki.messenger.R;
|
||||
public class ThreadRecord extends DisplayRecord {
|
||||
|
||||
private @Nullable final Uri snippetUri;
|
||||
public @Nullable final MessageRecord lastMessage;
|
||||
private final long count;
|
||||
private final int unreadCount;
|
||||
private final int unreadMentionCount;
|
||||
@ -54,13 +55,14 @@ public class ThreadRecord extends DisplayRecord {
|
||||
private final int initialRecipientHash;
|
||||
|
||||
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
|
||||
@NonNull Recipient recipient, long date, long count, int unreadCount,
|
||||
@Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
|
||||
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
|
||||
long snippetType, int distributionType, boolean archived, long expiresIn,
|
||||
long lastSeen, int readReceiptCount, boolean pinned)
|
||||
{
|
||||
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
||||
this.snippetUri = snippetUri;
|
||||
this.lastMessage = lastMessage;
|
||||
this.count = count;
|
||||
this.unreadCount = unreadCount;
|
||||
this.unreadMentionCount = unreadMentionCount;
|
||||
|
@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.text.SpannableString
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
@ -89,7 +91,7 @@ class ConversationView : LinearLayout {
|
||||
|| (configFactory.convoVolatile?.getConversationUnread(thread) == true)
|
||||
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
|
||||
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
|
||||
val senderDisplayName = getUserDisplayName(thread.recipient)
|
||||
val senderDisplayName = getTitle(thread.recipient)
|
||||
?: thread.recipient.address.toString()
|
||||
binding.conversationViewDisplayNameTextView.text = senderDisplayName
|
||||
binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) }
|
||||
@ -101,9 +103,7 @@ class ConversationView : LinearLayout {
|
||||
R.drawable.ic_notifications_mentions
|
||||
}
|
||||
binding.muteIndicatorImageView.setImageResource(drawableRes)
|
||||
val rawSnippet = thread.getDisplayBody(context)
|
||||
val snippet = highlightMentions(rawSnippet, thread.threadId, context)
|
||||
binding.snippetTextView.text = snippet
|
||||
binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context)
|
||||
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
|
||||
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
|
||||
if (isTyping) {
|
||||
@ -131,12 +131,21 @@ class ConversationView : LinearLayout {
|
||||
binding.profilePictureView.recycle()
|
||||
}
|
||||
|
||||
private fun getUserDisplayName(recipient: Recipient): String? {
|
||||
return if (recipient.isLocalNumber) {
|
||||
context.getString(R.string.note_to_self)
|
||||
} else {
|
||||
recipient.toShortString() // Internally uses the Contact API
|
||||
}
|
||||
private fun getTitle(recipient: Recipient): String? = when {
|
||||
recipient.isLocalNumber -> context.getString(R.string.note_to_self)
|
||||
else -> recipient.toShortString() // Internally uses the Contact API
|
||||
}
|
||||
|
||||
private fun ThreadRecord.getSnippet(): CharSequence =
|
||||
concatSnippet(getSnippetPrefix(), getDisplayBody(context))
|
||||
|
||||
private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence =
|
||||
prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
|
||||
|
||||
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
|
||||
recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null
|
||||
lastMessage?.isOutgoing == true -> resources.getString(R.string.MessageRecord_you)
|
||||
else -> lastMessage?.individualRecipient?.toShortString()
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.home.search
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Editable
|
||||
import android.text.InputFilter
|
||||
import android.text.InputFilter.LengthFilter
|
||||
import android.text.TextWatcher
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
@ -34,6 +36,7 @@ class GlobalSearchInputLayout @JvmOverloads constructor(
|
||||
binding.searchInput.onFocusChangeListener = this
|
||||
binding.searchInput.addTextChangedListener(this)
|
||||
binding.searchInput.setOnEditorActionListener(this)
|
||||
binding.searchInput.setFilters( arrayOf<InputFilter>(LengthFilter(100)) ) // 100 char search limit
|
||||
binding.searchCancel.setOnClickListener(this)
|
||||
binding.searchClear.setOnClickListener(this)
|
||||
}
|
||||
|
@ -24,8 +24,7 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
|
||||
|
||||
private val executor = viewModelScope + SupervisorJob()
|
||||
|
||||
private val _result: MutableStateFlow<GlobalSearchResult> =
|
||||
MutableStateFlow(GlobalSearchResult.EMPTY)
|
||||
private val _result: MutableStateFlow<GlobalSearchResult> = MutableStateFlow(GlobalSearchResult.EMPTY)
|
||||
|
||||
val result: StateFlow<GlobalSearchResult> = _result
|
||||
|
||||
@ -41,13 +40,14 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
|
||||
_queryText
|
||||
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
.mapLatest { query ->
|
||||
if (query.trim().length < 2) {
|
||||
// Early exit on empty search query
|
||||
if (query.trim().isEmpty()) {
|
||||
SearchResult.EMPTY
|
||||
} else {
|
||||
// user input delay here in case we get a new query within a few hundred ms
|
||||
// this coroutine will be cancelled and expensive query will not be run if typing quickly
|
||||
// first query of 2 characters will be instant however
|
||||
// User input delay in case we get a new query within a few hundred ms this
|
||||
// coroutine will be cancelled and the expensive query will not be run.
|
||||
delay(300)
|
||||
|
||||
val settableFuture = SettableFuture<SearchResult>()
|
||||
searchRepository.query(query.toString(), settableFuture::set)
|
||||
try {
|
||||
@ -64,6 +64,4 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
|
||||
}
|
||||
.launchIn(executor)
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -349,11 +349,17 @@ public class DefaultMessageNotifier implements MessageNotifier {
|
||||
builder.setThread(notifications.get(0).getRecipient());
|
||||
builder.setMessageCount(notificationState.getMessageCount());
|
||||
MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context);
|
||||
|
||||
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
|
||||
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
|
||||
// TODO: attributes to perform the colour lookup. Also, it makes little sense to highlight the mentions using
|
||||
// TODO: the app theme as it may result in insufficient contrast with the notification background which will
|
||||
// TODO: be using the SYSTEM theme.
|
||||
builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(),
|
||||
MentionUtilities.highlightMentions(text == null ? "" : text,
|
||||
notifications.get(0).getThreadId(),
|
||||
context),
|
||||
//MentionUtilities.highlightMentions(text == null ? "" : text, notifications.get(0).getThreadId(), context), // Removing hightlighting mentions -ACL
|
||||
text == null ? "" : text,
|
||||
notifications.get(0).getSlideDeck());
|
||||
|
||||
builder.setContentIntent(notifications.get(0).getPendingIntent(context));
|
||||
builder.setDeleteIntent(notificationState.getDeleteIntent(context));
|
||||
builder.setOnlyAlertOnce(!signal);
|
||||
|
@ -61,11 +61,15 @@ class MarkReadReceiver : BroadcastReceiver() {
|
||||
|
||||
val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
|
||||
val threadDb = DatabaseComponent.get(context).threadDatabase()
|
||||
|
||||
// start disappear after read messages except TimerUpdates in groups.
|
||||
markedReadMessages
|
||||
.filter { it.expiryType == ExpiryType.AFTER_READ }
|
||||
.map { it.syncMessageId }
|
||||
.filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { isExpirationTimerUpdate && recipient.isClosedGroupRecipient } == false }
|
||||
.filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run {
|
||||
isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupRecipient == true } == false
|
||||
}
|
||||
.forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) }
|
||||
|
||||
hashToDisappearAfterReadMessage(context, markedReadMessages)?.let {
|
||||
|
@ -38,6 +38,7 @@ import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.*
|
||||
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.getProperty
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.avatar.AvatarSelection
|
||||
import org.thoughtcrime.securesms.components.ProfilePictureView
|
||||
@ -119,7 +120,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
|
||||
passwordButton.isGone = prefs.getHidePassword()
|
||||
passwordButton.setOnClickListener { showPassword() }
|
||||
clearAllDataButton.setOnClickListener { clearAllData() }
|
||||
versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
|
||||
val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6)
|
||||
versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,19 +4,14 @@ import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
|
||||
import app.cash.copper.Query
|
||||
import app.cash.copper.flow.observeQuery
|
||||
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.messaging.messages.control.MessageRequestResponse
|
||||
@ -32,9 +27,7 @@ import org.session.libsession.utilities.GroupUtil
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.Log
|
||||
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase
|
||||
@ -51,7 +44,6 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
interface ConversationRepository {
|
||||
@ -239,7 +231,7 @@ class DefaultConversationRepository @Inject constructor(
|
||||
.success {
|
||||
continuation.resume(ResultOf.Success(Unit))
|
||||
}.fail { error ->
|
||||
Log.w("[onversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..")
|
||||
Log.w("ConversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..")
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
@ -330,9 +322,7 @@ class DefaultConversationRepository @Inject constructor(
|
||||
while (reader.next != null) {
|
||||
deleteMessageRequest(reader.current)
|
||||
val recipient = reader.current.recipient
|
||||
if (block) {
|
||||
setBlocked(recipient, true)
|
||||
}
|
||||
if (block) { setBlocked(recipient, true) }
|
||||
}
|
||||
}
|
||||
return ResultOf.Success(Unit)
|
||||
@ -359,9 +349,7 @@ class DefaultConversationRepository @Inject constructor(
|
||||
val cursor = mmsSmsDb.getConversation(threadId, true)
|
||||
mmsSmsDb.readerFor(cursor).use { reader ->
|
||||
while (reader.next != null) {
|
||||
if (!reader.current.isOutgoing) {
|
||||
return true
|
||||
}
|
||||
if (!reader.current.isOutgoing) { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
@ -4,12 +4,8 @@ import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.DatabaseUtils;
|
||||
import android.database.MergeCursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsession.messaging.contacts.Contact;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.GroupRecord;
|
||||
@ -27,37 +23,25 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.search.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import kotlin.Pair;
|
||||
|
||||
/**
|
||||
* Manages data retrieval for search.
|
||||
*/
|
||||
// Class to manage data retrieval for search
|
||||
public class SearchRepository {
|
||||
|
||||
private static final String TAG = SearchRepository.class.getSimpleName();
|
||||
|
||||
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
|
||||
static {
|
||||
// Several ranges of invalid ASCII characters
|
||||
for (int i = 33; i <= 47; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 58; i <= 64; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 91; i <= 96; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
for (int i = 123; i <= 126; i++) {
|
||||
BANNED_CHARACTERS.add((char) i);
|
||||
}
|
||||
// Construct a list containing several ranges of invalid ASCII characters
|
||||
// See: https://www.ascii-code.com/
|
||||
for (int i = 33; i <= 47; i++) { BANNED_CHARACTERS.add((char) i); } // !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
|
||||
for (int i = 58; i <= 64; i++) { BANNED_CHARACTERS.add((char) i); } // :, ;, <, =, >, ?, @
|
||||
for (int i = 91; i <= 96; i++) { BANNED_CHARACTERS.add((char) i); } // [, \, ], ^, _, `
|
||||
for (int i = 123; i <= 126; i++) { BANNED_CHARACTERS.add((char) i); } // {, |, }, ~
|
||||
}
|
||||
|
||||
private final Context context;
|
||||
@ -86,35 +70,25 @@ public class SearchRepository {
|
||||
}
|
||||
|
||||
public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
|
||||
if (TextUtils.isEmpty(query)) {
|
||||
// If the sanitized search is empty then abort without search
|
||||
String cleanQuery = sanitizeQuery(query).trim();
|
||||
if (cleanQuery.isEmpty()) {
|
||||
callback.onResult(SearchResult.EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
Stopwatch timer = new Stopwatch("FtsQuery");
|
||||
|
||||
String cleanQuery = sanitizeQuery(query);
|
||||
|
||||
// If the search is for a single character and it was stripped by `sanitizeQuery` then abort
|
||||
// the search for an empty string to avoid SQLite error.
|
||||
if (cleanQuery.length() == 0)
|
||||
{
|
||||
Log.d(TAG, "Aborting empty search query.");
|
||||
timer.stop(TAG);
|
||||
return;
|
||||
}
|
||||
|
||||
timer.split("clean");
|
||||
|
||||
Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery);
|
||||
timer.split("contacts");
|
||||
timer.split("Contacts");
|
||||
|
||||
CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond());
|
||||
timer.split("conversations");
|
||||
timer.split("Conversations");
|
||||
|
||||
CursorList<MessageResult> messages = queryMessages(cleanQuery);
|
||||
timer.split("messages");
|
||||
timer.split("Messages");
|
||||
|
||||
timer.stop(TAG);
|
||||
|
||||
@ -123,23 +97,20 @@ public class SearchRepository {
|
||||
}
|
||||
|
||||
public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) {
|
||||
if (TextUtils.isEmpty(query)) {
|
||||
// If the sanitized search query is empty then abort the search
|
||||
String cleanQuery = sanitizeQuery(query).trim();
|
||||
if (cleanQuery.isEmpty()) {
|
||||
callback.onResult(CursorList.emptyList());
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
// If the sanitized search query is empty then abort the search to prevent SQLite errors.
|
||||
String cleanQuery = sanitizeQuery(query).trim();
|
||||
if (cleanQuery.isEmpty()) { return; }
|
||||
|
||||
CursorList<MessageResult> messages = queryMessages(cleanQuery, threadId);
|
||||
callback.onResult(messages);
|
||||
});
|
||||
}
|
||||
|
||||
private Pair<CursorList<Contact>, List<String>> queryContacts(String query) {
|
||||
|
||||
Cursor contacts = contactDatabase.queryContactsByName(query);
|
||||
List<Address> contactList = new ArrayList<>();
|
||||
List<String> contactStrings = new ArrayList<>();
|
||||
@ -166,11 +137,10 @@ public class SearchRepository {
|
||||
MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients});
|
||||
|
||||
return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings);
|
||||
|
||||
}
|
||||
|
||||
private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) {
|
||||
List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
|
||||
List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
|
||||
String localUserNumber = TextSecurePreferences.getLocalNumber(context);
|
||||
if (localUserNumber != null) {
|
||||
matchingAddresses.remove(localUserNumber);
|
||||
@ -189,9 +159,7 @@ public class SearchRepository {
|
||||
membersGroupList.close();
|
||||
}
|
||||
|
||||
|
||||
Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses));
|
||||
|
||||
return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase))
|
||||
: CursorList.emptyList();
|
||||
}
|
||||
@ -256,9 +224,7 @@ public class SearchRepository {
|
||||
|
||||
private final Context context;
|
||||
|
||||
RecipientModelBuilder(@NonNull Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
RecipientModelBuilder(@NonNull Context context) { this.context = context; }
|
||||
|
||||
@Override
|
||||
public Recipient build(@NonNull Cursor cursor) {
|
||||
@ -301,9 +267,7 @@ public class SearchRepository {
|
||||
|
||||
private final Context context;
|
||||
|
||||
MessageModelBuilder(@NonNull Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
MessageModelBuilder(@NonNull Context context) { this.context = context; }
|
||||
|
||||
@Override
|
||||
public MessageResult build(@NonNull Cursor cursor) {
|
||||
|
@ -151,8 +151,8 @@ class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtoco
|
||||
|
||||
val userPublicKey = getLocalNumber(context)
|
||||
val senderPublicKey = message.sender
|
||||
val sentTimestamp = if (message.sentTimestamp == null) 0 else message.sentTimestamp!!
|
||||
val expireStartedAt = if (expiryMode is AfterSend || message.isSenderSelf) sentTimestamp else 0
|
||||
val sentTimestamp = message.sentTimestamp ?: 0
|
||||
val expireStartedAt = if ((expiryMode is AfterSend || message.isSenderSelf) && !message.isGroup) sentTimestamp else 0
|
||||
|
||||
// Notify the user
|
||||
if (senderPublicKey == null || userPublicKey == senderPublicKey) {
|
||||
|
@ -182,9 +182,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
}
|
||||
|
||||
fun broadcastWantsToAnswer(context: Context, wantsToAnswer: Boolean) {
|
||||
val intent = Intent(ACTION_WANTS_TO_ANSWER)
|
||||
.putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer)
|
||||
|
||||
val intent = Intent(ACTION_WANTS_TO_ANSWER).putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
@ -506,9 +504,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
}
|
||||
|
||||
private fun handleAnswerCall(intent: Intent) {
|
||||
val recipient = callManager.recipient ?: return
|
||||
val pending = callManager.pendingOffer ?: return
|
||||
val callId = callManager.callId ?: return
|
||||
val recipient = callManager.recipient ?: return Log.e(TAG, "No recipient to answer in handleAnswerCall")
|
||||
val pending = callManager.pendingOffer ?: return Log.e(TAG, "No pending offer in handleAnswerCall")
|
||||
val callId = callManager.callId ?: return Log.e(TAG, "No callId in handleAnswerCall")
|
||||
val timestamp = callManager.pendingOfferTime
|
||||
|
||||
if (callManager.currentConnectionState != CallState.RemoteRing) {
|
||||
@ -526,9 +524,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
insertMissedCall(recipient, true)
|
||||
terminate()
|
||||
}
|
||||
if (didHangup) {
|
||||
return
|
||||
}
|
||||
if (didHangup) { return }
|
||||
}
|
||||
|
||||
callManager.postConnectionEvent(Event.SendAnswer) {
|
||||
@ -686,7 +682,6 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
private fun registerPowerButtonReceiver() {
|
||||
if (powerButtonReceiver == null) {
|
||||
powerButtonReceiver = PowerButtonReceiver()
|
||||
|
||||
registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
|
||||
}
|
||||
}
|
||||
@ -719,7 +714,6 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun handleCheckTimeout(intent: Intent) {
|
||||
val callId = callManager.callId ?: return
|
||||
val callState = callManager.currentConnectionState
|
||||
@ -746,9 +740,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
|
||||
}
|
||||
|
||||
if (!CallNotificationBuilder.areNotificationsEnabled(this) && type == TYPE_INCOMING_PRE_OFFER) {
|
||||
// start an intent for the fullscreen
|
||||
// Start an intent for the fullscreen call activity
|
||||
val foregroundIntent = Intent(this, WebRtcCallActivity::class.java)
|
||||
.setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT)
|
||||
.setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
.setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT)
|
||||
startActivity(foregroundIntent)
|
||||
}
|
||||
|
@ -37,12 +37,10 @@ public class Stopwatch {
|
||||
for (int i = 1; i < splits.size(); i++) {
|
||||
out.append(splits.get(i).label).append(": ");
|
||||
out.append(splits.get(i).time - splits.get(i - 1).time);
|
||||
out.append(" ");
|
||||
out.append("ms ");
|
||||
}
|
||||
|
||||
out.append("total: ").append(splits.get(splits.size() - 1).time - startTime);
|
||||
out.append("total: ").append(splits.get(splits.size() - 1).time - startTime).append("ms.");
|
||||
}
|
||||
|
||||
Log.d(tag, out.toString());
|
||||
}
|
||||
|
||||
|
@ -9,13 +9,17 @@ import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.util.Size
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DimenRes
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import org.session.libsignal.utilities.Log
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.contains(point: PointF): Boolean {
|
||||
@ -32,6 +36,24 @@ val View.hitRect: Rect
|
||||
@ColorInt
|
||||
fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent)
|
||||
|
||||
// Method to grab the appropriate attribute for a message colour.
|
||||
// Note: This is an attribute, NOT a resource Id - see `getColorResourceIdFromAttr` for that.
|
||||
@AttrRes
|
||||
fun getMessageTextColourAttr(messageIsOutgoing: Boolean): Int {
|
||||
return if (messageIsOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
|
||||
}
|
||||
|
||||
// Method to get an actual R.id.<SOME_COLOUR> resource Id from an attribute such as R.attr.message_sent_text_color etc.
|
||||
@ColorRes
|
||||
fun getColorResourceIdFromAttr(context: Context, attr: Int): Int {
|
||||
val outTypedValue = TypedValue()
|
||||
val successfullyFoundAttribute = context.theme.resolveAttribute(attr, outTypedValue, true)
|
||||
if (successfullyFoundAttribute) { return outTypedValue.resourceId }
|
||||
|
||||
Log.w("ViewUtils", "Could not find colour attribute $attr in theme - using grey as a safe fallback")
|
||||
return R.color.gray50
|
||||
}
|
||||
|
||||
fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) {
|
||||
val startSize = resources.getDimension(startSizeID)
|
||||
val endSize = resources.getDimension(endSizeID)
|
||||
@ -70,7 +92,6 @@ fun View.hideKeyboard() {
|
||||
imm.hideSoftInputFromWindow(this.windowToken, 0)
|
||||
}
|
||||
|
||||
|
||||
fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888, longestWidth: Int = 2000): Bitmap {
|
||||
val size = Size(measuredWidth, measuredHeight).coerceAtMost(longestWidth)
|
||||
val scale = size.width / measuredWidth.toFloat()
|
||||
|
@ -4,7 +4,6 @@
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?android:textColorPrimary"/>
|
||||
<corners android:radius="@dimen/medium_button_corner_radius" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
|
@ -4,7 +4,6 @@
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?android:textColorPrimary"/>
|
||||
<corners android:radius="@dimen/medium_button_corner_radius" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
|
@ -6,8 +6,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:elevation="4dp"
|
||||
android:padding="@dimen/medium_spacing">
|
||||
android:elevation="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
@ -21,6 +20,8 @@
|
||||
android:id="@+id/dialogDescriptionText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginTop="@dimen/large_spacing"
|
||||
android:text="@string/dialog_clear_all_data_message"
|
||||
android:textAlignment="center"
|
||||
@ -46,16 +47,15 @@
|
||||
style="@style/Widget.Session.Button.Dialog.DestructiveText"
|
||||
android:id="@+id/clearAllDataButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/small_button_height"
|
||||
android:layout_height="@dimen/dialog_button_height"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="@dimen/medium_spacing"
|
||||
android:text="@string/dialog_clear_all_data_clear" />
|
||||
|
||||
<Button
|
||||
style="@style/Widget.Session.Button.Dialog.UnimportantText"
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/small_button_height"
|
||||
android:layout_height="@dimen/dialog_button_height"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cancel" />
|
||||
|
||||
|
@ -38,7 +38,7 @@
|
||||
style="@style/Widget.Session.Button.Dialog.UnimportantText"
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/small_button_height"
|
||||
android:layout_height="@dimen/dialog_button_height"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cancel" />
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
style="@style/Widget.Session.Button.Dialog.DestructiveText"
|
||||
android:id="@+id/sendSeedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/small_button_height"
|
||||
android:layout_height="@dimen/dialog_button_height"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="@dimen/medium_spacing"
|
||||
android:text="@string/dialog_send_seed_send_button_title" />
|
||||
|
@ -48,7 +48,7 @@
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="@dimen/very_small_font_size"
|
||||
android:textStyle="bold"
|
||||
tools:text="@string/MessageRecord_you_disabled_disappearing_messages" />
|
||||
tools:text="You disabled disappearing messages" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/call_view"
|
||||
|
@ -7,7 +7,7 @@
|
||||
<string name="ban">مسدود</string>
|
||||
<string name="save">ذخیره</string>
|
||||
<string name="note_to_self">یادداشت به خود</string>
|
||||
<string name="version_s">نسخه</string>
|
||||
<string name="version_s">%s نسخه</string>
|
||||
<!-- AbstractNotificationBuilder -->
|
||||
<string name="AbstractNotificationBuilder_new_message">پیام جدید</string>
|
||||
<!-- AlbumThumbnailView -->
|
||||
|
@ -11,6 +11,7 @@
|
||||
<dimen name="massive_font_size">50sp</dimen>
|
||||
|
||||
<!-- Element Sizes -->
|
||||
<dimen name="dialog_button_height">60dp</dimen>
|
||||
<dimen name="small_button_height">34dp</dimen>
|
||||
<dimen name="medium_button_height">38dp</dimen>
|
||||
<dimen name="large_button_height">54dp</dimen>
|
||||
|
@ -119,6 +119,7 @@
|
||||
<item name="android:textAllCaps">false</item>
|
||||
<item name="android:textSize">@dimen/small_font_size</item>
|
||||
<item name="android:textColor">?android:textColorPrimary</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Session.Button.Dialog.UnimportantText">
|
||||
|
@ -2,7 +2,7 @@ package org.session.libsession.messaging.mentions
|
||||
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
|
||||
object MentionsManager {
|
||||
var userPublicKeyCache = mutableMapOf<Long, Set<String>>() // Thread ID to set of user hex encoded public keys
|
||||
|
@ -10,11 +10,11 @@ import org.session.libsession.messaging.calls.CallMessageType.CALL_INCOMING
|
||||
import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED
|
||||
import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration.Companion.isNewConfigEnabled
|
||||
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
|
||||
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED
|
||||
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.SCREENSHOT
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
import org.session.libsession.utilities.getExpirationTypeDisplayValue
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsession.utilities.truncateIdForDisplay
|
||||
|
||||
object UpdateMessageBuilder {
|
||||
@ -31,47 +31,35 @@ object UpdateMessageBuilder {
|
||||
else getSenderName(senderId!!)
|
||||
|
||||
return when (updateData) {
|
||||
is UpdateMessageData.Kind.GroupCreation -> if (isOutgoing) {
|
||||
context.getString(R.string.MessageRecord_you_created_a_new_group)
|
||||
} else {
|
||||
context.getString(R.string.MessageRecord_s_added_you_to_the_group, senderName)
|
||||
is UpdateMessageData.Kind.GroupCreation -> {
|
||||
if (isOutgoing) context.getString(R.string.MessageRecord_you_created_a_new_group)
|
||||
else context.getString(R.string.MessageRecord_s_added_you_to_the_group, senderName)
|
||||
}
|
||||
is UpdateMessageData.Kind.GroupNameChange -> if (isOutgoing) {
|
||||
context.getString(R.string.MessageRecord_you_renamed_the_group_to_s, updateData.name)
|
||||
} else {
|
||||
context.getString(R.string.MessageRecord_s_renamed_the_group_to_s, senderName, updateData.name)
|
||||
is UpdateMessageData.Kind.GroupNameChange -> {
|
||||
if (isOutgoing) context.getString(R.string.MessageRecord_you_renamed_the_group_to_s, updateData.name)
|
||||
else context.getString(R.string.MessageRecord_s_renamed_the_group_to_s, senderName, updateData.name)
|
||||
}
|
||||
is UpdateMessageData.Kind.GroupMemberAdded -> {
|
||||
val members = updateData.updatedMembers.joinToString(", ", transform = ::getSenderName)
|
||||
if (isOutgoing) {
|
||||
context.getString(R.string.MessageRecord_you_added_s_to_the_group, members)
|
||||
} else {
|
||||
context.getString(R.string.MessageRecord_s_added_s_to_the_group, senderName, members)
|
||||
}
|
||||
if (isOutgoing) context.getString(R.string.MessageRecord_you_added_s_to_the_group, members)
|
||||
else context.getString(R.string.MessageRecord_s_added_s_to_the_group, senderName, members)
|
||||
}
|
||||
is UpdateMessageData.Kind.GroupMemberRemoved -> {
|
||||
val userPublicKey = storage.getUserPublicKey()!!
|
||||
// 1st case: you are part of the removed members
|
||||
return if (userPublicKey in updateData.updatedMembers) {
|
||||
if (isOutgoing) {
|
||||
context.getString(R.string.MessageRecord_left_group)
|
||||
} else {
|
||||
context.getString(R.string.MessageRecord_you_were_removed_from_the_group)
|
||||
}
|
||||
if (isOutgoing) context.getString(R.string.MessageRecord_left_group)
|
||||
else context.getString(R.string.MessageRecord_you_were_removed_from_the_group)
|
||||
} else {
|
||||
// 2nd case: you are not part of the removed members
|
||||
val members = updateData.updatedMembers.joinToString(", ", transform = ::getSenderName)
|
||||
if (isOutgoing) {
|
||||
context.getString(R.string.MessageRecord_you_removed_s_from_the_group, members)
|
||||
} else {
|
||||
context.getString(R.string.MessageRecord_s_removed_s_from_the_group, senderName, members)
|
||||
}
|
||||
if (isOutgoing) context.getString(R.string.MessageRecord_you_removed_s_from_the_group, members)
|
||||
else context.getString(R.string.MessageRecord_s_removed_s_from_the_group, senderName, members)
|
||||
}
|
||||
}
|
||||
is UpdateMessageData.Kind.GroupMemberLeft -> if (isOutgoing) {
|
||||
context.getString(R.string.MessageRecord_left_group)
|
||||
} else {
|
||||
context.getString(R.string.ConversationItem_group_action_left, senderName)
|
||||
is UpdateMessageData.Kind.GroupMemberLeft -> {
|
||||
if (isOutgoing) context.getString(R.string.MessageRecord_left_group)
|
||||
else context.getString(R.string.ConversationItem_group_action_left, senderName)
|
||||
}
|
||||
else -> return ""
|
||||
}
|
||||
@ -80,7 +68,7 @@ object UpdateMessageBuilder {
|
||||
fun buildExpirationTimerMessage(
|
||||
context: Context,
|
||||
duration: Long,
|
||||
recipient: Recipient,
|
||||
isGroup: Boolean,
|
||||
senderId: String? = null,
|
||||
isOutgoing: Boolean = false,
|
||||
timestamp: Long,
|
||||
@ -89,44 +77,28 @@ object UpdateMessageBuilder {
|
||||
if (!isOutgoing && senderId == null) return ""
|
||||
val senderName = if (isOutgoing) context.getString(R.string.MessageRecord_you) else getSenderName(senderId!!)
|
||||
return if (duration <= 0) {
|
||||
if (isOutgoing) {
|
||||
if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_you_disabled_disappearing_messages)
|
||||
else context.getString(if (recipient.is1on1) R.string.MessageRecord_you_turned_off_disappearing_messages_1_on_1 else R.string.MessageRecord_you_turned_off_disappearing_messages)
|
||||
} else {
|
||||
if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, senderName)
|
||||
else context.getString(if (recipient.is1on1) R.string.MessageRecord_s_turned_off_disappearing_messages_1_on_1 else R.string.MessageRecord_s_turned_off_disappearing_messages, senderName)
|
||||
}
|
||||
if (isOutgoing) context.getString(if (isGroup) R.string.MessageRecord_you_turned_off_disappearing_messages else R.string.MessageRecord_you_turned_off_disappearing_messages_1_on_1)
|
||||
else context.getString(if (isGroup) R.string.MessageRecord_s_turned_off_disappearing_messages else R.string.MessageRecord_s_turned_off_disappearing_messages_1_on_1, senderName)
|
||||
} else {
|
||||
val time = ExpirationUtil.getExpirationDisplayValue(context, duration.toInt())
|
||||
val action = context.getExpirationTypeDisplayValue(timestamp == expireStarted)
|
||||
if (isOutgoing) {
|
||||
if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time)
|
||||
else context.getString(
|
||||
if (recipient.is1on1) R.string.MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1 else R.string.MessageRecord_you_set_messages_to_disappear_s_after_s,
|
||||
time,
|
||||
action
|
||||
)
|
||||
} else {
|
||||
if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, senderName, time)
|
||||
else context.getString(
|
||||
if (recipient.is1on1) R.string.MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1 else R.string.MessageRecord_s_set_messages_to_disappear_s_after_s,
|
||||
senderName,
|
||||
time,
|
||||
action
|
||||
)
|
||||
}
|
||||
val action = context.getExpirationTypeDisplayValue(timestamp >= expireStarted)
|
||||
if (isOutgoing) context.getString(
|
||||
if (isGroup) R.string.MessageRecord_you_set_messages_to_disappear_s_after_s else R.string.MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1,
|
||||
time,
|
||||
action
|
||||
) else context.getString(
|
||||
if (isGroup) R.string.MessageRecord_s_set_messages_to_disappear_s_after_s else R.string.MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1,
|
||||
senderName,
|
||||
time,
|
||||
action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null): String {
|
||||
val senderName = getSenderName(senderId!!)
|
||||
return when (kind) {
|
||||
DataExtractionNotificationInfoMessage.Kind.SCREENSHOT ->
|
||||
context.getString(R.string.MessageRecord_s_took_a_screenshot, senderName)
|
||||
DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED ->
|
||||
context.getString(R.string.MessageRecord_media_saved_by_s, senderName)
|
||||
}
|
||||
}
|
||||
fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null) = when (kind) {
|
||||
SCREENSHOT -> R.string.MessageRecord_s_took_a_screenshot
|
||||
MEDIA_SAVED -> R.string.MessageRecord_media_saved_by_s
|
||||
}.let { context.getString(it, getSenderName(senderId!!)) }
|
||||
|
||||
fun buildCallMessage(context: Context, type: CallMessageType, sender: String): String =
|
||||
when (type) {
|
||||
|
@ -847,7 +847,7 @@ interface TextSecurePreferences {
|
||||
getDefaultSharedPreferences(context).edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
private fun getIntegerPreference(context: Context, key: String, defaultValue: Int): Int {
|
||||
fun getIntegerPreference(context: Context, key: String, defaultValue: Int): Int {
|
||||
return getDefaultSharedPreferences(context).getInt(key, defaultValue)
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,10 @@ public class ThemeUtil {
|
||||
return getAttributeText(context, R.attr.theme_type, "light").equals("dark");
|
||||
}
|
||||
|
||||
public static boolean isLightTheme(@NonNull Context context) {
|
||||
return getAttributeText(context, R.attr.theme_type, "light").equals("light");
|
||||
}
|
||||
|
||||
public static boolean getThemedBoolean(@NonNull Context context, @AttrRes int attr) {
|
||||
TypedValue typedValue = new TypedValue();
|
||||
Resources.Theme theme = context.getTheme();
|
||||
|
@ -15,10 +15,6 @@
|
||||
<string name="MessageRecord_s_called_you">%s vous a appelé·e</string>
|
||||
<string name="MessageRecord_called_s">Vous avez appelé %s</string>
|
||||
<string name="MessageRecord_missed_call_from">Appel manqué de %s</string>
|
||||
<string name="MessageRecord_you_disabled_disappearing_messages">Vous avez désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s a désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_you_set_disappearing_message_time_to_s">Vous avez défini l’expiration des messages éphémères à %1$s</string>
|
||||
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s a défini l’expiration des messages éphémères à %2$s</string>
|
||||
<string name="MessageRecord_s_took_a_screenshot">%1$s a pris une capture d\'écran.</string>
|
||||
<string name="MessageRecord_media_saved_by_s">%1$s a enregistré le média.</string>
|
||||
<!-- expiration -->
|
||||
|
@ -15,10 +15,6 @@
|
||||
<string name="MessageRecord_s_called_you">%s vous a appelé·e</string>
|
||||
<string name="MessageRecord_called_s">Vous avez appelé %s</string>
|
||||
<string name="MessageRecord_missed_call_from">Appel manqué de %s</string>
|
||||
<string name="MessageRecord_you_disabled_disappearing_messages">Vous avez désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s a désactivé les messages éphémères.</string>
|
||||
<string name="MessageRecord_you_set_disappearing_message_time_to_s">Vous avez défini l’expiration des messages éphémères à %1$s</string>
|
||||
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s a défini l’expiration des messages éphémères à %2$s</string>
|
||||
<string name="MessageRecord_s_took_a_screenshot">%1$s a pris une capture d\'écran.</string>
|
||||
<string name="MessageRecord_media_saved_by_s">%1$s a enregistré le média.</string>
|
||||
<!-- expiration -->
|
||||
|
@ -17,17 +17,13 @@
|
||||
<string name="MessageRecord_missed_call_from">Missed call from %s</string>
|
||||
<string name="MessageRecord_follow_setting">Follow Setting</string>
|
||||
<string name="AccessibilityId_follow_setting">Follow setting</string>
|
||||
<string name="MessageRecord_you_disabled_disappearing_messages">You disabled disappearing messages.</string>
|
||||
<string name="MessageRecord_you_turned_off_disappearing_messages">You have turned off disappearing messages.</string>
|
||||
<string name="MessageRecord_you_turned_off_disappearing_messages_1_on_1">You turned off disappearing messages. Messages you send will no longer disappear.</string>
|
||||
<string name="MessageRecord_s_disabled_disappearing_messages">%1$s disabled disappearing messages.</string>
|
||||
<string name="MessageRecord_s_turned_off_disappearing_messages">%1$s turned off disappearing messages.</string>
|
||||
<string name="MessageRecord_s_turned_off_disappearing_messages_1_on_1">%1$s has turned off disappearing messages. Messages they send will no longer disappear.</string>
|
||||
<string name="MessageRecord_you_set_disappearing_message_time_to_s">You set the disappearing message timer to %1$s</string>
|
||||
<string name="MessageRecord_you_set_messages_to_disappear_s_after_s">You have set messages to disappear %1$s after they have been %2$s</string>
|
||||
<string name="MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1">You set your messages to disappear %1$s after they have been %2$s.</string>
|
||||
<string name="MessageRecord_you_changed_messages_to_disappear_s_after_s">You have changed messages to disappear %1$s after they have been %2$s</string>
|
||||
<string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s set the disappearing message timer to %2$s</string>
|
||||
<string name="MessageRecord_s_set_messages_to_disappear_s_after_s">%1$s has set messages to disappear %2$s after they have been %3$s</string>
|
||||
<string name="MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1">%1$s has set their messages to disappear %2$s after they have been %3$s.</string>
|
||||
<string name="MessageRecord_s_changed_messages_to_disappear_s_after_s">%1$s has changed messages to disappear %2$s after they have been %3$s</string>
|
||||
|
Loading…
Reference in New Issue
Block a user