1108 lines
32 KiB
TypeScript
1108 lines
32 KiB
TypeScript
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import Long from 'long';
|
|
import type { Call, PeekInfo, LocalDeviceState } from '@signalapp/ringrtc';
|
|
import {
|
|
CallState,
|
|
ConnectionState,
|
|
JoinState,
|
|
callIdFromEra,
|
|
callIdFromRingId,
|
|
RingUpdate,
|
|
} from '@signalapp/ringrtc';
|
|
import { v4 as generateGuid } from 'uuid';
|
|
import { strictAssert } from './assert';
|
|
import { SignalService as Proto } from '../protobuf';
|
|
import { bytesToUuid, uuidToBytes } from './uuidToBytes';
|
|
import { missingCaseError } from './missingCaseError';
|
|
import {
|
|
CallEndedReason,
|
|
CallMode,
|
|
GroupCallJoinState,
|
|
} from '../types/Calling';
|
|
import type { AciString } from '../types/ServiceId';
|
|
import { isAciString } from './isAciString';
|
|
import { isMe } from './whatTypeOfConversation';
|
|
import * as log from '../logging/log';
|
|
import * as Errors from '../types/errors';
|
|
import { incrementMessageCounter } from './incrementMessageCounter';
|
|
import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus';
|
|
import { SeenStatus, maxSeenStatus } from '../MessageSeenStatus';
|
|
import { canConversationBeUnarchived } from './canConversationBeUnarchived';
|
|
import type {
|
|
ConversationAttributesType,
|
|
MessageAttributesType,
|
|
} from '../model-types';
|
|
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
|
import MessageSender from '../textsecure/SendMessage';
|
|
import * as Bytes from '../Bytes';
|
|
import type {
|
|
CallDetails,
|
|
CallEvent,
|
|
CallEventDetails,
|
|
CallHistoryDetails,
|
|
CallHistoryGroup,
|
|
CallStatus,
|
|
GroupCallMeta,
|
|
} from '../types/CallDisposition';
|
|
import {
|
|
DirectCallStatus,
|
|
GroupCallStatus,
|
|
callEventNormalizeSchema,
|
|
CallType,
|
|
CallDirection,
|
|
callEventDetailsSchema,
|
|
LocalCallEvent,
|
|
RemoteCallEvent,
|
|
callHistoryDetailsSchema,
|
|
callDetailsSchema,
|
|
} from '../types/CallDisposition';
|
|
import type { ConversationType } from '../state/ducks/conversations';
|
|
import type { ConversationModel } from '../models/conversations';
|
|
|
|
// utils
|
|
// -----
|
|
|
|
export function formatCallEvent(callEvent: CallEventDetails): string {
|
|
const {
|
|
callId,
|
|
peerId,
|
|
direction,
|
|
event,
|
|
eventSource,
|
|
type,
|
|
mode,
|
|
ringerId,
|
|
timestamp,
|
|
} = callEvent;
|
|
const peerIdLog = mode === CallMode.Group ? `groupv2(${peerId})` : peerId;
|
|
return `CallEvent (${callId}, ${peerIdLog}, ${mode}, ${event}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId}, ${eventSource})`;
|
|
}
|
|
|
|
export function formatCallHistory(callHistory: CallHistoryDetails): string {
|
|
const { callId, peerId, direction, status, type, mode, timestamp, ringerId } =
|
|
callHistory;
|
|
return `CallHistory (${callId}, ${peerId}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId})`;
|
|
}
|
|
|
|
export function formatCallHistoryGroup(
|
|
callHistoryGroup: CallHistoryGroup
|
|
): string {
|
|
const { peerId, direction, status, type, mode, timestamp } = callHistoryGroup;
|
|
return `CallHistoryGroup (${peerId}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp})`;
|
|
}
|
|
|
|
export function formatPeekInfo(peekInfo: PeekInfo): string {
|
|
const { eraId, deviceCount, creator } = peekInfo;
|
|
const callId = eraId != null ? getCallIdFromEra(eraId) : null;
|
|
const creatorAci = creator != null ? getCreatorAci(creator) : null;
|
|
return `PeekInfo (${eraId}, ${callId}, ${creatorAci}, ${deviceCount})`;
|
|
}
|
|
|
|
export function formatLocalDeviceState(
|
|
localDeviceState: LocalDeviceState
|
|
): string {
|
|
const connectionState = ConnectionState[localDeviceState.connectionState];
|
|
const joinState = JoinState[localDeviceState.joinState];
|
|
return `LocalDeviceState (${connectionState}, ${joinState})`;
|
|
}
|
|
|
|
export function getCallIdFromRing(ringId: bigint): string {
|
|
return Long.fromValue(callIdFromRingId(ringId)).toString();
|
|
}
|
|
|
|
export function getCallIdFromEra(eraId: string): string {
|
|
return Long.fromValue(callIdFromEra(eraId)).toString();
|
|
}
|
|
|
|
export function getCreatorAci(creator: Buffer): AciString {
|
|
const aci = bytesToUuid(creator);
|
|
strictAssert(aci != null, 'creator uuid buffer was not a valid uuid');
|
|
strictAssert(isAciString(aci), 'creator uuid buffer was not a valid aci');
|
|
return aci;
|
|
}
|
|
|
|
export function getGroupCallMeta(
|
|
peekInfo: PeekInfo | null
|
|
): GroupCallMeta | null {
|
|
if (peekInfo?.eraId == null || peekInfo?.creator == null) {
|
|
return null;
|
|
}
|
|
const callId = getCallIdFromEra(peekInfo.eraId);
|
|
const ringerId = bytesToUuid(peekInfo.creator);
|
|
strictAssert(ringerId != null, 'peekInfo.creator was invalid uuid');
|
|
strictAssert(isAciString(ringerId), 'peekInfo.creator was invalid aci');
|
|
return { callId, ringerId };
|
|
}
|
|
|
|
export function getPeerIdFromConversation(
|
|
conversation: ConversationAttributesType | ConversationType
|
|
): AciString | string {
|
|
if (conversation.type === 'direct' || conversation.type === 'private') {
|
|
strictAssert(
|
|
isAciString(conversation.serviceId),
|
|
'ACI must exist for direct chat'
|
|
);
|
|
return conversation.serviceId;
|
|
}
|
|
strictAssert(
|
|
conversation.groupId != null,
|
|
'groupId must exist for group chat'
|
|
);
|
|
return conversation.groupId;
|
|
}
|
|
|
|
export function convertJoinState(joinState: JoinState): GroupCallJoinState {
|
|
if (joinState === JoinState.Joined) {
|
|
return GroupCallJoinState.Joined;
|
|
}
|
|
if (joinState === JoinState.Joining) {
|
|
return GroupCallJoinState.Joining;
|
|
}
|
|
if (joinState === JoinState.NotJoined) {
|
|
return GroupCallJoinState.NotJoined;
|
|
}
|
|
if (joinState === JoinState.Pending) {
|
|
return GroupCallJoinState.Pending;
|
|
}
|
|
throw missingCaseError(joinState);
|
|
}
|
|
|
|
// Call Events <-> Protos
|
|
// ----------------------
|
|
|
|
export function getCallEventForProto(
|
|
callEventProto: Proto.SyncMessage.ICallEvent,
|
|
eventSource: string
|
|
): CallEventDetails {
|
|
const callEvent = callEventNormalizeSchema.parse(callEventProto);
|
|
const { callId, peerId, timestamp } = callEvent;
|
|
|
|
let type: CallType;
|
|
if (callEvent.type === Proto.SyncMessage.CallEvent.Type.GROUP_CALL) {
|
|
type = CallType.Group;
|
|
} else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.AUDIO_CALL) {
|
|
type = CallType.Audio;
|
|
} else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL) {
|
|
type = CallType.Video;
|
|
} else {
|
|
throw new TypeError(`Unknown call type ${callEvent.type}`);
|
|
}
|
|
|
|
let mode: CallMode;
|
|
if (type === CallType.Group) {
|
|
mode = CallMode.Group;
|
|
} else {
|
|
mode = CallMode.Direct;
|
|
}
|
|
|
|
let direction: CallDirection;
|
|
if (callEvent.direction === Proto.SyncMessage.CallEvent.Direction.INCOMING) {
|
|
direction = CallDirection.Incoming;
|
|
} else if (
|
|
callEvent.direction === Proto.SyncMessage.CallEvent.Direction.OUTGOING
|
|
) {
|
|
direction = CallDirection.Outgoing;
|
|
} else {
|
|
throw new TypeError(`Unknown call direction ${callEvent.direction}`);
|
|
}
|
|
|
|
let event: RemoteCallEvent;
|
|
if (callEvent.event === Proto.SyncMessage.CallEvent.Event.ACCEPTED) {
|
|
event = RemoteCallEvent.Accepted;
|
|
} else if (
|
|
callEvent.event === Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED
|
|
) {
|
|
event = RemoteCallEvent.NotAccepted;
|
|
} else if (callEvent.event === Proto.SyncMessage.CallEvent.Event.DELETE) {
|
|
event = RemoteCallEvent.Delete;
|
|
} else {
|
|
throw new TypeError(`Unknown call event ${callEvent.event}`);
|
|
}
|
|
|
|
return callEventDetailsSchema.parse({
|
|
callId,
|
|
peerId,
|
|
ringerId: null,
|
|
mode,
|
|
type,
|
|
direction,
|
|
timestamp,
|
|
event,
|
|
eventSource,
|
|
});
|
|
}
|
|
|
|
const directionToProto = {
|
|
[CallDirection.Incoming]: Proto.SyncMessage.CallEvent.Direction.INCOMING,
|
|
[CallDirection.Outgoing]: Proto.SyncMessage.CallEvent.Direction.OUTGOING,
|
|
};
|
|
|
|
const typeToProto = {
|
|
[CallType.Audio]: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL,
|
|
[CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL,
|
|
[CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL,
|
|
};
|
|
|
|
const statusToProto: Record<
|
|
CallStatus,
|
|
Proto.SyncMessage.CallEvent.Event | null
|
|
> = {
|
|
[DirectCallStatus.Accepted]: Proto.SyncMessage.CallEvent.Event.ACCEPTED, // and GroupCallStatus.Accepted
|
|
[DirectCallStatus.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, // and GroupCallStatus.Declined
|
|
[DirectCallStatus.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE, // and GroupCallStatus.Deleted
|
|
[DirectCallStatus.Missed]: null, // and GroupCallStatus.Missed
|
|
[DirectCallStatus.Pending]: null,
|
|
[GroupCallStatus.GenericGroupCall]: null,
|
|
[GroupCallStatus.Joined]: null,
|
|
[GroupCallStatus.OutgoingRing]: null,
|
|
[GroupCallStatus.Ringing]: null,
|
|
};
|
|
|
|
function shouldSyncStatus(callStatus: CallStatus) {
|
|
return statusToProto[callStatus] != null;
|
|
}
|
|
|
|
function getProtoForCallHistory(
|
|
callHistory: CallHistoryDetails
|
|
): Proto.SyncMessage.ICallEvent | null {
|
|
const event = statusToProto[callHistory.status];
|
|
|
|
strictAssert(
|
|
event != null,
|
|
`getProtoForCallHistory: Cannot create proto for status ${formatCallHistory(
|
|
callHistory
|
|
)}`
|
|
);
|
|
|
|
let peerId = uuidToBytes(callHistory.peerId);
|
|
if (peerId.length === 0) {
|
|
peerId = Bytes.fromBase64(callHistory.peerId);
|
|
}
|
|
|
|
return new Proto.SyncMessage.CallEvent({
|
|
peerId,
|
|
callId: Long.fromString(callHistory.callId),
|
|
type: typeToProto[callHistory.type],
|
|
direction: directionToProto[callHistory.direction],
|
|
event,
|
|
timestamp: Long.fromNumber(callHistory.timestamp),
|
|
});
|
|
}
|
|
|
|
// Local Events
|
|
// ------------
|
|
|
|
const endedReasonToEvent: Record<CallEndedReason, LocalCallEvent> = {
|
|
// Accepted
|
|
[CallEndedReason.AcceptedOnAnotherDevice]: LocalCallEvent.Accepted,
|
|
// Hangup (Incoming = Declined, Outgoing = Missed)
|
|
[CallEndedReason.RemoteHangup]: LocalCallEvent.RemoteHangup,
|
|
[CallEndedReason.LocalHangup]: LocalCallEvent.Hangup,
|
|
// Missed
|
|
[CallEndedReason.Busy]: LocalCallEvent.Missed,
|
|
[CallEndedReason.BusyOnAnotherDevice]: LocalCallEvent.Missed,
|
|
[CallEndedReason.ConnectionFailure]: LocalCallEvent.Missed,
|
|
[CallEndedReason.Glare]: LocalCallEvent.Missed,
|
|
[CallEndedReason.GlareFailure]: LocalCallEvent.Missed,
|
|
[CallEndedReason.InternalFailure]: LocalCallEvent.Missed,
|
|
[CallEndedReason.ReCall]: LocalCallEvent.Missed,
|
|
[CallEndedReason.ReceivedOfferExpired]: LocalCallEvent.Missed,
|
|
[CallEndedReason.ReceivedOfferWhileActive]: LocalCallEvent.Missed,
|
|
[CallEndedReason.ReceivedOfferWithGlare]: LocalCallEvent.Missed,
|
|
[CallEndedReason.RemoteHangupNeedPermission]: LocalCallEvent.Missed,
|
|
[CallEndedReason.SignalingFailure]: LocalCallEvent.Missed,
|
|
[CallEndedReason.Timeout]: LocalCallEvent.Missed,
|
|
[CallEndedReason.Declined]: LocalCallEvent.Missed,
|
|
[CallEndedReason.DeclinedOnAnotherDevice]: LocalCallEvent.Missed,
|
|
};
|
|
|
|
export function getLocalCallEventFromCallEndedReason(
|
|
callEndedReason: CallEndedReason
|
|
): LocalCallEvent {
|
|
log.info('getLocalCallEventFromCallEndedReason', callEndedReason);
|
|
return endedReasonToEvent[callEndedReason];
|
|
}
|
|
|
|
export function getLocalCallEventFromDirectCall(
|
|
call: Call
|
|
): LocalCallEvent | null {
|
|
log.info('getLocalCallEventFromDirectCall', call.state);
|
|
if (call.state === CallState.Accepted) {
|
|
return LocalCallEvent.Accepted;
|
|
}
|
|
if (call.state === CallState.Ended) {
|
|
strictAssert(call.endedReason != null, 'Call ended without reason');
|
|
return getLocalCallEventFromCallEndedReason(call.endedReason);
|
|
}
|
|
if (call.state === CallState.Ringing) {
|
|
return LocalCallEvent.Ringing;
|
|
}
|
|
if (call.state === CallState.Prering) {
|
|
return null;
|
|
}
|
|
if (call.state === CallState.Reconnecting) {
|
|
return null;
|
|
}
|
|
throw missingCaseError(call.state);
|
|
}
|
|
|
|
const ringUpdateToEvent: Record<RingUpdate, LocalCallEvent> = {
|
|
[RingUpdate.AcceptedOnAnotherDevice]: LocalCallEvent.Accepted,
|
|
[RingUpdate.BusyLocally]: LocalCallEvent.Missed,
|
|
[RingUpdate.BusyOnAnotherDevice]: LocalCallEvent.Missed,
|
|
[RingUpdate.CancelledByRinger]: LocalCallEvent.Missed,
|
|
[RingUpdate.DeclinedOnAnotherDevice]: LocalCallEvent.Missed,
|
|
[RingUpdate.ExpiredRequest]: LocalCallEvent.Missed,
|
|
[RingUpdate.Requested]: LocalCallEvent.Ringing,
|
|
};
|
|
|
|
export function getLocalCallEventFromRingUpdate(
|
|
update: RingUpdate
|
|
): LocalCallEvent | null {
|
|
log.info('getLocalCallEventFromRingUpdate', RingUpdate[update]);
|
|
return ringUpdateToEvent[update];
|
|
}
|
|
|
|
export function getLocalCallEventFromJoinState(
|
|
joinState: GroupCallJoinState | null,
|
|
groupCallMeta: GroupCallMeta
|
|
): LocalCallEvent | null {
|
|
const direction = getCallDirectionFromRingerId(groupCallMeta.ringerId);
|
|
log.info(
|
|
'getLocalCallEventFromGroupCall',
|
|
direction,
|
|
joinState != null ? GroupCallJoinState[joinState] : null
|
|
);
|
|
if (direction === CallDirection.Incoming) {
|
|
if (joinState === GroupCallJoinState.Joined) {
|
|
return LocalCallEvent.Accepted;
|
|
}
|
|
if (joinState === GroupCallJoinState.NotJoined || joinState == null) {
|
|
return LocalCallEvent.Started;
|
|
}
|
|
if (
|
|
joinState === GroupCallJoinState.Joining ||
|
|
joinState === GroupCallJoinState.Pending
|
|
) {
|
|
return LocalCallEvent.Accepted;
|
|
}
|
|
throw missingCaseError(joinState);
|
|
} else {
|
|
if (joinState === GroupCallJoinState.NotJoined || joinState == null) {
|
|
return LocalCallEvent.Started;
|
|
}
|
|
return LocalCallEvent.Ringing;
|
|
}
|
|
}
|
|
|
|
// Call Direction
|
|
// --------------
|
|
|
|
function getCallDirectionFromRingerId(
|
|
ringerId: AciString | string
|
|
): CallDirection {
|
|
const ringerConversation = window.ConversationController.get(ringerId);
|
|
strictAssert(
|
|
ringerConversation != null,
|
|
`getCallDirectionFromRingerId: Missing ringer conversation (${ringerId})`
|
|
);
|
|
const direction = isMe(ringerConversation.attributes)
|
|
? CallDirection.Outgoing
|
|
: CallDirection.Incoming;
|
|
return direction;
|
|
}
|
|
|
|
// Call Details
|
|
// ------------
|
|
|
|
export function getCallDetailsFromDirectCall(
|
|
peerId: AciString | string,
|
|
call: Call
|
|
): CallDetails {
|
|
return callDetailsSchema.parse({
|
|
callId: Long.fromValue(call.callId).toString(),
|
|
peerId,
|
|
ringerId: call.isIncoming ? call.remoteUserId : null,
|
|
mode: CallMode.Direct,
|
|
type: call.isVideoCall ? CallType.Video : CallType.Audio,
|
|
direction: call.isIncoming
|
|
? CallDirection.Incoming
|
|
: CallDirection.Outgoing,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
export function getCallDetailsFromEndedDirectCall(
|
|
callId: string,
|
|
peerId: AciString | string,
|
|
ringerId: AciString | string,
|
|
wasVideoCall: boolean,
|
|
timestamp: number
|
|
): CallDetails {
|
|
return callDetailsSchema.parse({
|
|
callId,
|
|
peerId,
|
|
ringerId,
|
|
mode: CallMode.Direct,
|
|
type: wasVideoCall ? CallType.Video : CallType.Audio,
|
|
direction: getCallDirectionFromRingerId(ringerId),
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
export function getCallDetailsFromGroupCallMeta(
|
|
peerId: AciString | string,
|
|
groupCallMeta: GroupCallMeta
|
|
): CallDetails {
|
|
return callDetailsSchema.parse({
|
|
callId: groupCallMeta.callId,
|
|
peerId,
|
|
ringerId: groupCallMeta.ringerId,
|
|
mode: CallMode.Group,
|
|
type: CallType.Group,
|
|
direction: getCallDirectionFromRingerId(groupCallMeta.ringerId),
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
// Call Event Details
|
|
// ------------------
|
|
|
|
export function getCallEventDetails(
|
|
callDetails: CallDetails,
|
|
event: LocalCallEvent,
|
|
eventSource: string
|
|
): CallEventDetails {
|
|
return callEventDetailsSchema.parse({ ...callDetails, event, eventSource });
|
|
}
|
|
|
|
// transitions
|
|
// -----------
|
|
|
|
export function transitionCallHistory(
|
|
callHistory: CallHistoryDetails | null,
|
|
callEvent: CallEventDetails
|
|
): CallHistoryDetails {
|
|
const { callId, peerId, ringerId, mode, type, direction, event } = callEvent;
|
|
|
|
if (callHistory != null) {
|
|
strictAssert(callHistory.callId === callId, 'callId must be same');
|
|
strictAssert(callHistory.peerId === peerId, 'peerId must be same');
|
|
strictAssert(
|
|
ringerId == null || callHistory.ringerId === ringerId,
|
|
'ringerId must be same if it exists'
|
|
);
|
|
strictAssert(callHistory.direction === direction, 'direction must be same');
|
|
strictAssert(callHistory.type === type, 'type must be same');
|
|
strictAssert(callHistory.mode === mode, 'mode must be same');
|
|
}
|
|
|
|
const prevStatus = callHistory?.status ?? null;
|
|
let status: DirectCallStatus | GroupCallStatus;
|
|
|
|
if (mode === CallMode.Direct) {
|
|
status = transitionDirectCallStatus(
|
|
prevStatus as DirectCallStatus | null,
|
|
event,
|
|
direction
|
|
);
|
|
} else if (mode === CallMode.Group) {
|
|
status = transitionGroupCallStatus(
|
|
prevStatus as GroupCallStatus | null,
|
|
event,
|
|
direction
|
|
);
|
|
} else {
|
|
throw missingCaseError(mode);
|
|
}
|
|
|
|
const timestamp = transitionTimestamp(callHistory, callEvent);
|
|
log.info(
|
|
`transitionCallHistory: Transitioned call history timestamp (before: ${callHistory?.timestamp}, after: ${timestamp})`
|
|
);
|
|
|
|
return callHistoryDetailsSchema.parse({
|
|
callId,
|
|
peerId,
|
|
ringerId,
|
|
mode,
|
|
type,
|
|
direction,
|
|
timestamp,
|
|
status,
|
|
});
|
|
}
|
|
|
|
function transitionTimestamp(
|
|
callHistory: CallHistoryDetails | null,
|
|
callEvent: CallEventDetails
|
|
): number {
|
|
// Sometimes when a device is asleep the timestamp will be stuck in the past.
|
|
// In some cases, we'll accept whatever the most recent timestamp is.
|
|
const latestTimestamp = Math.max(
|
|
callEvent.timestamp,
|
|
callHistory?.timestamp ?? 0
|
|
);
|
|
|
|
// Always accept the timestamp if we have no other timestamps.
|
|
if (callHistory == null) {
|
|
return callEvent.timestamp;
|
|
}
|
|
|
|
// Deleted call history should never be changed
|
|
if (
|
|
callHistory.status === DirectCallStatus.Deleted ||
|
|
callHistory.status === GroupCallStatus.Deleted
|
|
) {
|
|
return callHistory.timestamp;
|
|
}
|
|
|
|
// Accepted call history should only be changed if we get a remote accepted
|
|
// event with possibly a better timestamp.
|
|
if (
|
|
callHistory.status === DirectCallStatus.Accepted ||
|
|
callHistory.status === GroupCallStatus.Accepted ||
|
|
callHistory.status === GroupCallStatus.Joined
|
|
) {
|
|
if (callEvent.event === RemoteCallEvent.Accepted) {
|
|
return latestTimestamp;
|
|
}
|
|
return callHistory.timestamp;
|
|
}
|
|
|
|
// Declined call history should only be changed if if we transition to an
|
|
// accepted state or get a remote 'not accepted' event with possibly a better
|
|
// timestamp.
|
|
if (
|
|
callHistory.status === DirectCallStatus.Declined ||
|
|
callHistory.status === GroupCallStatus.Declined
|
|
) {
|
|
if (callEvent.event === RemoteCallEvent.NotAccepted) {
|
|
return latestTimestamp;
|
|
}
|
|
return callHistory.timestamp;
|
|
}
|
|
|
|
// We don't care about holding onto timestamps that were from these states
|
|
if (
|
|
callHistory.status === DirectCallStatus.Pending ||
|
|
callHistory.status === GroupCallStatus.GenericGroupCall ||
|
|
callHistory.status === GroupCallStatus.OutgoingRing ||
|
|
callHistory.status === GroupCallStatus.Ringing ||
|
|
callHistory.status === DirectCallStatus.Missed ||
|
|
callHistory.status === GroupCallStatus.Missed
|
|
) {
|
|
return latestTimestamp;
|
|
}
|
|
|
|
throw missingCaseError(callHistory.status);
|
|
}
|
|
|
|
function transitionDirectCallStatus(
|
|
status: DirectCallStatus | null,
|
|
callEvent: CallEvent,
|
|
direction: CallDirection
|
|
): DirectCallStatus {
|
|
log.info('transitionDirectCallStatus', status, callEvent, direction);
|
|
// In all cases if we get a delete event, we need to delete the call, and never
|
|
// transition from deleted.
|
|
if (
|
|
callEvent === RemoteCallEvent.Delete ||
|
|
callEvent === LocalCallEvent.Delete ||
|
|
status === DirectCallStatus.Deleted
|
|
) {
|
|
return DirectCallStatus.Deleted;
|
|
}
|
|
|
|
if (
|
|
callEvent === RemoteCallEvent.Accepted ||
|
|
callEvent === LocalCallEvent.Accepted
|
|
) {
|
|
return DirectCallStatus.Accepted;
|
|
}
|
|
|
|
if (status === DirectCallStatus.Accepted) {
|
|
return status;
|
|
}
|
|
|
|
if (callEvent === RemoteCallEvent.NotAccepted) {
|
|
if (status === DirectCallStatus.Declined) {
|
|
return DirectCallStatus.Declined;
|
|
}
|
|
return DirectCallStatus.Missed;
|
|
}
|
|
|
|
if (callEvent === LocalCallEvent.Missed) {
|
|
return DirectCallStatus.Missed;
|
|
}
|
|
|
|
if (callEvent === LocalCallEvent.Declined) {
|
|
return DirectCallStatus.Declined;
|
|
}
|
|
|
|
if (callEvent === LocalCallEvent.Hangup) {
|
|
if (direction === CallDirection.Incoming) {
|
|
return DirectCallStatus.Declined;
|
|
}
|
|
return DirectCallStatus.Missed;
|
|
}
|
|
|
|
if (callEvent === LocalCallEvent.RemoteHangup) {
|
|
if (direction === CallDirection.Incoming) {
|
|
return DirectCallStatus.Missed;
|
|
}
|
|
return DirectCallStatus.Declined;
|
|
}
|
|
|
|
if (
|
|
callEvent === LocalCallEvent.Started ||
|
|
callEvent === LocalCallEvent.Ringing
|
|
) {
|
|
return DirectCallStatus.Pending;
|
|
}
|
|
|
|
throw missingCaseError(callEvent);
|
|
}
|
|
|
|
function transitionGroupCallStatus(
|
|
status: GroupCallStatus | null,
|
|
event: CallEvent,
|
|
direction: CallDirection
|
|
): GroupCallStatus {
|
|
log.info('transitionGroupCallStatus', status, event, direction);
|
|
|
|
// In all cases if we get a delete event, we need to delete the call, and never
|
|
// transition from deleted.
|
|
if (
|
|
event === RemoteCallEvent.Delete ||
|
|
event === LocalCallEvent.Delete ||
|
|
status === GroupCallStatus.Deleted
|
|
) {
|
|
return GroupCallStatus.Deleted;
|
|
}
|
|
|
|
if (status === GroupCallStatus.Accepted) {
|
|
return status;
|
|
}
|
|
|
|
if (event === RemoteCallEvent.NotAccepted) {
|
|
throw new Error(`callHistoryDetails: Group calls shouldn't send ${event}`);
|
|
}
|
|
|
|
if (event === RemoteCallEvent.Accepted || event === LocalCallEvent.Accepted) {
|
|
switch (status) {
|
|
case null: {
|
|
return GroupCallStatus.Joined;
|
|
}
|
|
case GroupCallStatus.Ringing:
|
|
case GroupCallStatus.Missed:
|
|
case GroupCallStatus.Declined: {
|
|
return GroupCallStatus.Accepted;
|
|
}
|
|
case GroupCallStatus.GenericGroupCall: {
|
|
return GroupCallStatus.Joined;
|
|
}
|
|
case GroupCallStatus.Joined:
|
|
case GroupCallStatus.OutgoingRing: {
|
|
return status;
|
|
}
|
|
default: {
|
|
throw missingCaseError(status);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event === LocalCallEvent.Started) {
|
|
return GroupCallStatus.GenericGroupCall;
|
|
}
|
|
|
|
if (event === LocalCallEvent.Ringing) {
|
|
return GroupCallStatus.Ringing;
|
|
}
|
|
|
|
if (event === LocalCallEvent.Missed) {
|
|
return GroupCallStatus.Missed;
|
|
}
|
|
|
|
if (event === LocalCallEvent.Declined) {
|
|
return GroupCallStatus.Declined;
|
|
}
|
|
|
|
if (event === LocalCallEvent.Hangup) {
|
|
if (direction === CallDirection.Incoming) {
|
|
return GroupCallStatus.Declined;
|
|
}
|
|
return GroupCallStatus.Missed;
|
|
}
|
|
|
|
if (event === LocalCallEvent.RemoteHangup) {
|
|
if (direction === CallDirection.Incoming) {
|
|
return GroupCallStatus.Missed;
|
|
}
|
|
return GroupCallStatus.Declined;
|
|
}
|
|
|
|
throw missingCaseError(event);
|
|
}
|
|
|
|
// actions
|
|
// -------
|
|
|
|
async function updateLocalCallHistory(
|
|
callEvent: CallEventDetails,
|
|
receivedAtCounter: number | null
|
|
): Promise<CallHistoryDetails | null> {
|
|
const conversation = window.ConversationController.get(callEvent.peerId);
|
|
strictAssert(
|
|
conversation != null,
|
|
`updateLocalCallHistory: Conversation not found ${formatCallEvent(
|
|
callEvent
|
|
)}`
|
|
);
|
|
return conversation.queueJob<CallHistoryDetails | null>(
|
|
'updateLocalCallHistory',
|
|
async () => {
|
|
log.info(
|
|
'updateLocalCallHistory: Processing call event:',
|
|
formatCallEvent(callEvent)
|
|
);
|
|
|
|
const prevCallHistory =
|
|
(await window.Signal.Data.getCallHistory(
|
|
callEvent.callId,
|
|
callEvent.peerId
|
|
)) ?? null;
|
|
|
|
if (prevCallHistory != null) {
|
|
log.info(
|
|
'updateLocalCallHistory: Found previous call history:',
|
|
formatCallHistory(prevCallHistory)
|
|
);
|
|
} else {
|
|
log.info('updateLocalCallHistory: No previous call history');
|
|
}
|
|
|
|
let callHistory: CallHistoryDetails;
|
|
try {
|
|
callHistory = transitionCallHistory(prevCallHistory, callEvent);
|
|
} catch (error) {
|
|
log.error(
|
|
"updateLocalCallHistory: Couldn't transition call history:",
|
|
formatCallEvent(callEvent),
|
|
Errors.toLogFormat(error)
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const updatedCallHistory = await saveCallHistory(
|
|
callHistory,
|
|
conversation,
|
|
receivedAtCounter
|
|
);
|
|
return updatedCallHistory;
|
|
}
|
|
);
|
|
}
|
|
|
|
async function saveCallHistory(
|
|
callHistory: CallHistoryDetails,
|
|
conversation: ConversationModel,
|
|
receivedAtCounter: number | null
|
|
): Promise<CallHistoryDetails> {
|
|
log.info(
|
|
'saveCallHistory: Saving call history:',
|
|
formatCallHistory(callHistory)
|
|
);
|
|
|
|
const isDeleted =
|
|
callHistory.status === DirectCallStatus.Deleted ||
|
|
callHistory.status === GroupCallStatus.Deleted;
|
|
|
|
await window.Signal.Data.saveCallHistory(callHistory);
|
|
|
|
if (isDeleted) {
|
|
window.reduxActions.callHistory.removeCallHistory(callHistory.callId);
|
|
} else {
|
|
window.reduxActions.callHistory.addCallHistory(callHistory);
|
|
}
|
|
|
|
const prevMessage = await window.Signal.Data.getCallHistoryMessageByCallId({
|
|
conversationId: conversation.id,
|
|
callId: callHistory.callId,
|
|
});
|
|
|
|
if (prevMessage != null) {
|
|
log.info(
|
|
'saveCallHistory: Found previous call history message:',
|
|
prevMessage.id
|
|
);
|
|
} else {
|
|
log.info(
|
|
'saveCallHistory: No previous call history message',
|
|
conversation.id
|
|
);
|
|
}
|
|
|
|
if (isDeleted) {
|
|
if (prevMessage != null) {
|
|
await window.Signal.Data.removeMessage(prevMessage.id);
|
|
}
|
|
return callHistory;
|
|
}
|
|
|
|
let unread = false;
|
|
if (callHistory.mode === CallMode.Direct) {
|
|
unread =
|
|
callHistory.direction === CallDirection.Incoming &&
|
|
(callHistory.status === DirectCallStatus.Missed ||
|
|
callHistory.status === DirectCallStatus.Pending);
|
|
} else if (callHistory.mode === CallMode.Group) {
|
|
unread =
|
|
callHistory.direction === CallDirection.Incoming &&
|
|
(callHistory.status === GroupCallStatus.Ringing ||
|
|
callHistory.status === GroupCallStatus.GenericGroupCall ||
|
|
callHistory.status === GroupCallStatus.Missed);
|
|
}
|
|
|
|
let readStatus = unread ? ReadStatus.Unread : ReadStatus.Read;
|
|
let seenStatus = unread ? SeenStatus.Unseen : SeenStatus.NotApplicable;
|
|
|
|
if (prevMessage?.readStatus != null) {
|
|
readStatus = maxReadStatus(readStatus, prevMessage.readStatus);
|
|
}
|
|
if (prevMessage?.seenStatus != null) {
|
|
seenStatus = maxSeenStatus(seenStatus, prevMessage.seenStatus);
|
|
}
|
|
|
|
const message: MessageAttributesType = {
|
|
id: prevMessage?.id ?? generateGuid(),
|
|
conversationId: conversation.id,
|
|
type: 'call-history',
|
|
timestamp: prevMessage?.timestamp ?? callHistory.timestamp,
|
|
sent_at: prevMessage?.sent_at ?? callHistory.timestamp,
|
|
received_at:
|
|
prevMessage?.received_at ??
|
|
receivedAtCounter ??
|
|
incrementMessageCounter(),
|
|
received_at_ms: prevMessage?.received_at_ms ?? callHistory.timestamp,
|
|
readStatus,
|
|
seenStatus,
|
|
callId: callHistory.callId,
|
|
};
|
|
|
|
const id = await window.Signal.Data.saveMessage(message, {
|
|
ourAci: window.textsecure.storage.user.getCheckedAci(),
|
|
// We don't want to force save if we're updating an existing message
|
|
forceSave: prevMessage == null,
|
|
});
|
|
log.info('saveCallHistory: Saved call history message:', id);
|
|
|
|
const model = window.MessageCache.__DEPRECATED$register(
|
|
id,
|
|
new window.Whisper.Message({
|
|
...message,
|
|
id,
|
|
}),
|
|
'callDisposition'
|
|
);
|
|
|
|
if (prevMessage == null) {
|
|
if (callHistory.direction === CallDirection.Outgoing) {
|
|
conversation.incrementSentMessageCount();
|
|
} else {
|
|
conversation.incrementMessageCount();
|
|
}
|
|
conversation.trigger('newmessage', model);
|
|
}
|
|
|
|
await conversation.updateLastMessage().catch(error => {
|
|
log.error(
|
|
'saveCallHistory: Failed to update last message:',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
});
|
|
|
|
await conversation.updateUnread().catch(error => {
|
|
log.error(
|
|
'saveCallHistory: Failed to update unread',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
});
|
|
|
|
conversation.set(
|
|
'active_at',
|
|
Math.max(conversation.get('active_at') ?? 0, callHistory.timestamp)
|
|
);
|
|
|
|
if (canConversationBeUnarchived(conversation.attributes)) {
|
|
conversation.setArchived(false);
|
|
} else {
|
|
window.Signal.Data.updateConversation(conversation.attributes);
|
|
}
|
|
|
|
return callHistory;
|
|
}
|
|
|
|
async function updateRemoteCallHistory(
|
|
callHistory: CallHistoryDetails
|
|
): Promise<void> {
|
|
if (!shouldSyncStatus(callHistory.status)) {
|
|
log.info(
|
|
'updateRemoteCallHistory: Not syncing call history:',
|
|
formatCallHistory(callHistory)
|
|
);
|
|
return;
|
|
}
|
|
|
|
log.info(
|
|
'updateRemoteCallHistory: syncing call history:',
|
|
formatCallHistory(callHistory)
|
|
);
|
|
|
|
try {
|
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
|
const syncMessage = MessageSender.createSyncMessage();
|
|
|
|
syncMessage.callEvent = getProtoForCallHistory(callHistory);
|
|
|
|
const contentMessage = new Proto.Content();
|
|
contentMessage.syncMessage = syncMessage;
|
|
|
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
|
|
await singleProtoJobQueue.add({
|
|
contentHint: ContentHint.RESENDABLE,
|
|
serviceId: ourAci,
|
|
isSyncMessage: true,
|
|
protoBase64: Bytes.toBase64(
|
|
Proto.Content.encode(contentMessage).finish()
|
|
),
|
|
type: 'callEventSync',
|
|
urgent: false,
|
|
});
|
|
} catch (error) {
|
|
log.error(
|
|
'updateRemoteCallHistory: Failed to queue sync message:',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function updateCallHistoryFromRemoteEvent(
|
|
callEvent: CallEventDetails,
|
|
receivedAtCounter: number
|
|
): Promise<void> {
|
|
await updateLocalCallHistory(callEvent, receivedAtCounter);
|
|
}
|
|
|
|
export async function updateCallHistoryFromLocalEvent(
|
|
callEvent: CallEventDetails,
|
|
receivedAtCounter: number | null
|
|
): Promise<void> {
|
|
const updatedCallHistory = await updateLocalCallHistory(
|
|
callEvent,
|
|
receivedAtCounter
|
|
);
|
|
if (updatedCallHistory == null) {
|
|
return;
|
|
}
|
|
await updateRemoteCallHistory(updatedCallHistory);
|
|
}
|
|
|
|
export async function clearCallHistoryDataAndSync(): Promise<void> {
|
|
try {
|
|
const timestamp = Date.now();
|
|
|
|
log.info(`clearCallHistory: Clearing call history before ${timestamp}`);
|
|
const messageIds = await window.Signal.Data.clearCallHistory(timestamp);
|
|
|
|
messageIds.forEach(messageId => {
|
|
const message = window.MessageCache.__DEPRECATED$getById(messageId);
|
|
const conversation = message?.getConversation();
|
|
if (message == null || conversation == null) {
|
|
return;
|
|
}
|
|
window.reduxActions.conversations.messageDeleted(
|
|
messageId,
|
|
message.get('conversationId')
|
|
);
|
|
conversation.debouncedUpdateLastMessage();
|
|
window.MessageCache.__DEPRECATED$unregister(messageId);
|
|
});
|
|
|
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
|
|
|
const callLogEvent = new Proto.SyncMessage.CallLogEvent({
|
|
type: Proto.SyncMessage.CallLogEvent.Type.CLEAR,
|
|
timestamp: Long.fromNumber(timestamp),
|
|
});
|
|
|
|
const syncMessage = MessageSender.createSyncMessage();
|
|
syncMessage.callLogEvent = callLogEvent;
|
|
|
|
const contentMessage = new Proto.Content();
|
|
contentMessage.syncMessage = syncMessage;
|
|
|
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
|
|
log.info('clearCallHistory: Queueing sync message');
|
|
await singleProtoJobQueue.add({
|
|
contentHint: ContentHint.RESENDABLE,
|
|
serviceId: ourAci,
|
|
isSyncMessage: true,
|
|
protoBase64: Bytes.toBase64(
|
|
Proto.Content.encode(contentMessage).finish()
|
|
),
|
|
type: 'callLogEventSync',
|
|
urgent: false,
|
|
});
|
|
} catch (error) {
|
|
log.error('clearCallHistory: Failed to clear call history', error);
|
|
}
|
|
}
|
|
|
|
export async function updateLocalGroupCallHistoryTimestamp(
|
|
conversationId: string,
|
|
callId: string,
|
|
timestamp: number
|
|
): Promise<CallHistoryDetails | null> {
|
|
const conversation = window.ConversationController.get(conversationId);
|
|
if (conversation == null) {
|
|
return null;
|
|
}
|
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
|
|
|
const prevCallHistory =
|
|
(await window.Signal.Data.getCallHistory(callId, peerId)) ?? null;
|
|
|
|
// We don't have all the details to add new call history here
|
|
if (prevCallHistory != null) {
|
|
log.info(
|
|
'updateLocalGroupCallHistoryTimestamp: Found previous call history:',
|
|
formatCallHistory(prevCallHistory)
|
|
);
|
|
} else {
|
|
log.info('updateLocalGroupCallHistoryTimestamp: No previous call history');
|
|
return null;
|
|
}
|
|
|
|
if (timestamp >= prevCallHistory.timestamp) {
|
|
log.info(
|
|
'updateLocalGroupCallHistoryTimestamp: New timestamp is later than existing call history, ignoring'
|
|
);
|
|
return prevCallHistory;
|
|
}
|
|
|
|
const updatedCallHistory = await saveCallHistory(
|
|
{
|
|
...prevCallHistory,
|
|
timestamp,
|
|
},
|
|
conversation,
|
|
null
|
|
);
|
|
|
|
return updatedCallHistory;
|
|
}
|