Disappear 1:1 calls.

This commit is contained in:
Michelle Tang
2026-06-01 14:06:14 -04:00
committed by Alex Hart
parent a0d605d1b1
commit be80619a3b
12 changed files with 193 additions and 49 deletions
@@ -7,11 +7,13 @@ import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.visible
/**
* Renders a single call preference row when displaying call info.
@@ -41,6 +43,25 @@ object CallPreference {
binding.callIcon.setImageResource(getCallIcon(model.call))
binding.callType.text = getCallType(model.call)
binding.callTime.text = getCallTime(model.record)
presentTimer(model.record)
}
private fun presentTimer(messageRecord: MessageRecord) {
if (messageRecord.expiresIn > 0) {
binding.callTimer.visible = true
binding.callTimer.setPercentComplete(0f)
if (messageRecord.expireStarted > 0) {
binding.callTimer.setExpirationTime(messageRecord.expireStarted, messageRecord.expiresIn)
binding.callTimer.startAnimation()
if (messageRecord.expireStarted + messageRecord.expiresIn <= System.currentTimeMillis()) {
AppDependencies.expiringMessageManager.checkSchedule()
}
}
} else {
binding.callTimer.visible = false
}
}
@DrawableRes
@@ -19,7 +19,6 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
@@ -104,6 +103,7 @@ public final class ConversationUpdateItem extends FrameLayout
private EventListener eventListener;
private Button collapsedButton;
private float lastYDownRelativeToThis;
private int tint;
private final UpdateObserver updateObserver = new UpdateObserver();
@@ -221,6 +221,8 @@ public final class ConversationUpdateItem extends FrameLayout
observeDisplayBody(lifecycleOwner, spannableMessage);
observeDisplayBodyWithTimer(lifecycleOwner);
this.tint = updateDescription.getTint(getContext());
boolean donationRequest = conversationMessage.getMessageRecord().isReleaseChannelDonationRequest();
present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted);
@@ -487,8 +489,9 @@ public final class ConversationUpdateItem extends FrameLayout
SpannableStringBuilder builder = new SpannableStringBuilder(displayBody);
int color = tint != 0 ? tint : ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary);
if (latestFrame != 0) {
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary));
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), color);
SpanUtil.appendCenteredImageSpan(builder, drawable, 12, 12);
}
@@ -41,6 +41,10 @@ class ExpirationTimer(
}
fun calculateProgress(): Float {
if (startedAt == 0L) {
return 0f
}
val progressed = System.currentTimeMillis() - startedAt
val percentComplete = progressed.toFloat() / expiresIn.toFloat()
@@ -30,6 +30,7 @@ import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager.RingUpdate
import org.thoughtcrime.securesms.database.CallTable.Companion.TIMESTAMP
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
@@ -109,24 +110,66 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
)
}
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE) {
val now = System.currentTimeMillis()
val allUnreadMissedCalls = readableDatabase
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
}
val updateCount = writableDatabase
.update(TABLE_NAME)
.values(READ to ReadState.serialize(ReadState.READ))
.where("$TIMESTAMP <= ? AND $READ != ?", timestamp, ReadState.serialize(ReadState.READ))
.run()
val expiringCalls = SignalDatabase.messages.getUnstartedExpirations(allUnreadMissedCalls)
if (expiringCalls.isNotEmpty()) {
Log.i(TAG, "Found ${expiringCalls.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to now })
for ((messageId, expiresIn) in expiringCalls) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, now, expiresIn)
}
}
if (updateCount > 0) {
notifyConversationListListeners()
}
}
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long): Call? {
val now = System.currentTimeMillis()
val latestCallAsOfTimestamp = writableDatabase.withinTransaction { db ->
val unreadMissedCalls = db
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
}
val updated = db.update(TABLE_NAME)
.values(READ to ReadState.serialize(ReadState.READ))
.where("$PEER = ? AND $TIMESTAMP <= ?", peer.toLong(), timestamp)
.run()
val expiring = SignalDatabase.messages.getUnstartedExpirations(unreadMissedCalls)
if (expiring.isNotEmpty()) {
Log.i(TAG, "Found ${expiring.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to now })
for ((messageId, expiresIn) in expiring) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, now, expiresIn)
}
}
if (updated == 0) {
null
} else {
@@ -157,7 +200,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
val messageType: Long = Call.getMessageType(type, direction, event)
writableDatabase.withinTransaction {
val result = SignalDatabase.messages.insertCallLog(peer, messageType, timestamp, direction == Direction.OUTGOING)
val result = SignalDatabase.messages.insertOneToOneCallLog(peer, messageType, timestamp, direction == Direction.OUTGOING)
val values = contentValuesOf(
CALL_ID to callId,
MESSAGE_ID to result.messageId,
@@ -202,7 +245,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (call.messageId == null) {
Log.w(TAG, "Call does not have an associated message id! No message to update.")
} else {
SignalDatabase.messages.updateCallLog(call.messageId, call.messageType)
SignalDatabase.messages.updateOneToOneCallLog(call.messageId, call.messageType)
}
AppDependencies.messageNotifier.updateNotification(context)
@@ -655,6 +655,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return queryMessages(where, null)
}
/**
* Given a list of ids, will return a list of the ids that have an expiration and what that expiration is.
*/
fun getUnstartedExpirations(messageIds: List<Long>): Map<Long, Long> {
val expirations: MutableMap<Long, Long> = hashMapOf()
SqlUtil.buildCollectionQuery(
column = ID,
values = messageIds,
prefix = "$EXPIRES_IN != 0 AND $EXPIRE_STARTED = 0 AND"
).forEach { query ->
readableDatabase
.select(ID, EXPIRES_IN)
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.use { cursor ->
while (cursor.moveToNext()) {
expirations[cursor.requireLong(ID)] = cursor.requireLong(EXPIRES_IN)
}
}
}
return expirations
}
/**
* Returns true iff
* - the message will expire within [ChatItemArchiveExporter.EXPIRATION_CUTOFF] once viewed
@@ -876,11 +902,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return results
}
fun insertCallLog(recipientId: RecipientId, type: Long, timestamp: Long, outgoing: Boolean): InsertResult {
fun insertOneToOneCallLog(recipientId: RecipientId, type: Long, timestamp: Long, outgoing: Boolean): InsertResult {
val recipient = Recipient.resolved(recipientId)
val threadIdResult = threads.getOrCreateThreadIdResultFor(recipient.id, recipient.isGroup)
val threadId = threadIdResult.threadId
val dateReceived = System.currentTimeMillis()
val expiresIn = if (RemoteConfig.disappearMore) threads.getExpiresIn(threadId) else 0
val values = contentValuesOf(
FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else recipientId.serialize(),
@@ -891,7 +918,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
READ to 1,
NOTIFIED to 1,
TYPE to type,
THREAD_ID to threadId
THREAD_ID to threadId,
EXPIRES_IN to expiresIn
)
val messageId = writableDatabase.insert(TABLE_NAME, null, values)
@@ -910,7 +938,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
)
}
fun updateCallLog(messageId: Long, type: Long) {
fun updateOneToOneCallLog(messageId: Long, type: Long) {
val message = getMessageRecordOrNull(messageId = messageId)
writableDatabase
.update(TABLE_NAME)
@@ -930,6 +958,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = message.dateReceived, messageExtras = message.messageExtras, messageType = type)
}
// Start disappearing timer when a call is answered or declined (e.g. not missed)
if (message?.expiresIn != null && message.expiresIn != 0L && !MessageTypes.isMissedVideoCall(type) && !MessageTypes.isMissedAudioCall(type)) {
val now = System.currentTimeMillis()
markExpireStarted(messageId, now)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, message.isMms, now, message.expiresIn)
}
notifyConversationListeners(threadId)
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
@@ -2086,7 +2086,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
private fun getExpiresIn(threadId: Long): Long {
fun getExpiresIn(threadId: Long): Long {
return readableDatabase
.select(EXPIRES_IN)
.from(TABLE_NAME)
@@ -511,6 +511,10 @@ public abstract class MessageRecord extends DisplayRecord {
return UpdateDescription.staticDescriptionWithExpiration(string, glyph);
}
protected static @NonNull UpdateDescription staticUpdateDescriptionWithExpiration(@NonNull String string, Glyph glyph, @ColorInt int lightTint, @ColorInt int darkTint) {
return UpdateDescription.staticDescriptionWithExpiration(string, glyph, lightTint, darkTint);
}
protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string,
Glyph glyph,
@ColorInt int lightTint,
@@ -265,13 +265,13 @@ public class MmsMessageRecord extends MessageRecord {
if (call.getEvent() == CallTable.Event.NOT_ACCEPTED) {
int message = isVideoCall ? R.string.MessageRecord_unanswered_video_call : R.string.MessageRecord_unanswered_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(message), callDateString),
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(message), callDateString),
icon,
ContextCompat.getColor(context, R.color.core_red_shade),
ContextCompat.getColor(context, R.color.core_red));
} else {
int updateString = isVideoCall ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_outgoing_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
}
} else {
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
@@ -279,7 +279,7 @@ public class MmsMessageRecord extends MessageRecord {
if (accepted || !call.isDisplayedAsMissedCallInUi()) {
int updateString = isVideoCall ? R.string.MessageRecord_incoming_video_call : R.string.MessageRecord_incoming_voice_call;
Glyph icon = isVideoCall ? Glyph.VIDEO_CAMERA : Glyph.PHONE;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
} else {
Glyph icon = isVideoCall ? Glyph.VIDEO_CAMERA : Glyph.PHONE;
int message;
@@ -291,7 +291,7 @@ public class MmsMessageRecord extends MessageRecord {
message = isVideoCall ? R.string.MessageRecord_missed_video_call : R.string.MessageRecord_missed_voice_call;
}
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date,
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date,
context.getString(message),
callDateString),
icon,
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@@ -10,6 +11,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.ui.util.ThemeUtil;
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
import org.signal.core.models.ServiceId;
@@ -98,7 +100,14 @@ public final class UpdateDescription {
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
*/
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph) {
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true,0, 0);
return staticDescriptionWithExpiration(staticString, glyph, 0, 0);
}
/**
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
*/
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph, @ColorInt int lightTint, @ColorInt int darkTint) {
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true, lightTint, darkTint);
}
/**
@@ -161,6 +170,11 @@ public final class UpdateDescription {
return darkTint;
}
public @ColorInt int getTint(Context context) {
boolean isDarkTheme = ThemeUtil.isDarkTheme(context);
return isDarkTheme ? getDarkTint() : getLightTint();
}
public boolean hasExpiration() {
return canExpire;
}
@@ -1467,5 +1467,13 @@ object RemoteConfig {
hotSwappable = true
)
@JvmStatic
@get:JvmName("disappearMore")
val disappearMore: Boolean by remoteBoolean(
key = "android.disappearMore",
defaultValue = false,
hotSwappable = true
)
// endregion
}