2020-05-02 00:13:23 +02:00
package org.thoughtcrime.securesms.groups.v2.processing ;
import android.content.Context ;
2020-05-14 20:57:40 +02:00
import android.text.TextUtils ;
2020-05-02 00:13:23 +02:00
import androidx.annotation.NonNull ;
import androidx.annotation.Nullable ;
import androidx.annotation.WorkerThread ;
import org.signal.storageservice.protos.groups.local.DecryptedGroup ;
2020-05-13 18:36:57 +02:00
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange ;
2020-05-21 22:04:57 +02:00
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember ;
2020-05-02 00:13:23 +02:00
import org.signal.zkgroup.VerificationFailedException ;
import org.signal.zkgroup.groups.GroupMasterKey ;
import org.signal.zkgroup.groups.GroupSecretParams ;
import org.thoughtcrime.securesms.database.DatabaseFactory ;
import org.thoughtcrime.securesms.database.GroupDatabase ;
import org.thoughtcrime.securesms.database.MmsDatabase ;
import org.thoughtcrime.securesms.database.RecipientDatabase ;
2020-05-13 18:36:57 +02:00
import org.thoughtcrime.securesms.database.SmsDatabase ;
2020-05-02 00:13:23 +02:00
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context ;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies ;
import org.thoughtcrime.securesms.groups.GroupId ;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException ;
import org.thoughtcrime.securesms.groups.GroupProtoUtil ;
2020-05-05 17:13:53 +02:00
import org.thoughtcrime.securesms.groups.GroupsV2Authorization ;
2020-05-02 00:13:23 +02:00
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet ;
import org.thoughtcrime.securesms.jobmanager.JobManager ;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob ;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob ;
import org.thoughtcrime.securesms.logging.Log ;
import org.thoughtcrime.securesms.mms.MmsException ;
2020-05-06 18:42:54 +02:00
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage ;
2020-05-02 00:13:23 +02:00
import org.thoughtcrime.securesms.recipients.Recipient ;
import org.thoughtcrime.securesms.recipients.RecipientId ;
2020-05-13 18:36:57 +02:00
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage ;
import org.thoughtcrime.securesms.sms.IncomingTextMessage ;
import org.whispersystems.libsignal.util.guava.Optional ;
2020-05-02 00:13:23 +02:00
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry ;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil ;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api ;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException ;
2020-05-13 18:36:57 +02:00
import org.whispersystems.signalservice.api.util.UuidUtil ;
2020-05-02 00:13:23 +02:00
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException ;
import java.io.IOException ;
import java.util.ArrayList ;
import java.util.Collection ;
import java.util.Collections ;
import java.util.List ;
import java.util.Locale ;
import java.util.UUID ;
/ * *
* Advances a groups state to a specified revision .
* /
public final class GroupsV2StateProcessor {
private static final String TAG = Log . tag ( GroupsV2StateProcessor . class ) ;
public static final int LATEST = GroupStateMapper . LATEST ;
private final Context context ;
private final JobManager jobManager ;
private final RecipientDatabase recipientDatabase ;
private final GroupDatabase groupDatabase ;
private final GroupsV2Authorization groupsV2Authorization ;
private final GroupsV2Api groupsV2Api ;
public GroupsV2StateProcessor ( @NonNull Context context ) {
this . context = context . getApplicationContext ( ) ;
this . jobManager = ApplicationDependencies . getJobManager ( ) ;
this . groupsV2Authorization = ApplicationDependencies . getGroupsV2Authorization ( ) ;
this . groupsV2Api = ApplicationDependencies . getSignalServiceAccountManager ( ) . getGroupsV2Api ( ) ;
this . recipientDatabase = DatabaseFactory . getRecipientDatabase ( context ) ;
this . groupDatabase = DatabaseFactory . getGroupDatabase ( context ) ;
}
public StateProcessorForGroup forGroup ( @NonNull GroupMasterKey groupMasterKey ) {
return new StateProcessorForGroup ( groupMasterKey ) ;
}
public enum GroupState {
/ * *
* The message revision was inconsistent with server revision , should ignore
* /
INCONSISTENT ,
/ * *
* The local group was successfully updated to be consistent with the message revision
* /
GROUP_UPDATED ,
/ * *
* The local group is already consistent with the message revision or is ahead of the message revision
* /
GROUP_CONSISTENT_OR_AHEAD
}
public static class GroupUpdateResult {
private final GroupState groupState ;
@Nullable private DecryptedGroup latestServer ;
GroupUpdateResult ( @NonNull GroupState groupState , @Nullable DecryptedGroup latestServer ) {
this . groupState = groupState ;
this . latestServer = latestServer ;
}
public GroupState getGroupState ( ) {
return groupState ;
}
public @Nullable DecryptedGroup getLatestServer ( ) {
return latestServer ;
}
}
public final class StateProcessorForGroup {
private final GroupMasterKey masterKey ;
private final GroupId . V2 groupId ;
private final GroupSecretParams groupSecretParams ;
private StateProcessorForGroup ( @NonNull GroupMasterKey groupMasterKey ) {
this . masterKey = groupMasterKey ;
this . groupId = GroupId . v2 ( masterKey ) ;
this . groupSecretParams = GroupSecretParams . deriveFromMasterKey ( groupMasterKey ) ;
}
/ * *
* Using network where required , will attempt to bring the local copy of the group up to the revision specified .
*
* @param revision use { @link # LATEST } to get latest .
* /
@WorkerThread
public GroupUpdateResult updateLocalGroupToRevision ( final int revision ,
2020-05-19 21:02:24 +02:00
final long timestamp ,
@Nullable DecryptedGroupChange signedGroupChange )
2020-05-02 00:13:23 +02:00
throws IOException , GroupNotAMemberException
{
if ( localIsAtLeast ( revision ) ) {
return new GroupUpdateResult ( GroupState . GROUP_CONSISTENT_OR_AHEAD , null ) ;
}
2020-05-19 21:02:24 +02:00
GlobalGroupState inputGroupState = null ;
DecryptedGroup localState = groupDatabase . getGroup ( groupId )
. transform ( g - > g . requireV2GroupProperties ( ) . getDecryptedGroup ( ) )
. orNull ( ) ;
if ( signedGroupChange ! = null & &
localState ! = null & &
2020-05-29 19:35:40 +02:00
localState . getRevision ( ) + 1 = = signedGroupChange . getRevision ( ) & &
revision = = signedGroupChange . getRevision ( ) )
2020-05-19 21:02:24 +02:00
{
try {
Log . i ( TAG , "Applying P2P group change" ) ;
DecryptedGroup newState = DecryptedGroupUtil . apply ( localState , signedGroupChange ) ;
inputGroupState = new GlobalGroupState ( localState , Collections . singletonList ( new GroupLogEntry ( newState , signedGroupChange ) ) ) ;
} catch ( DecryptedGroupUtil . NotAbleToApplyChangeException e ) {
Log . w ( TAG , "Unable to apply P2P group change" , e ) ;
}
2020-05-13 18:36:57 +02:00
}
2020-05-19 21:02:24 +02:00
if ( inputGroupState = = null ) {
try {
2020-05-26 21:02:34 +02:00
inputGroupState = queryServer ( localState , revision = = LATEST & & localState = = null ) ;
2020-05-19 21:02:24 +02:00
} catch ( GroupNotAMemberException e ) {
Log . w ( TAG , "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message" ) ;
insertGroupLeave ( ) ;
throw e ;
}
} else {
Log . i ( TAG , "Saved server query for group change" ) ;
}
2020-05-02 00:13:23 +02:00
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper . partiallyAdvanceGroupState ( inputGroupState , revision ) ;
DecryptedGroup newLocalState = advanceGroupStateResult . getNewGlobalGroupState ( ) . getLocalState ( ) ;
if ( newLocalState = = null | | newLocalState = = inputGroupState . getLocalState ( ) ) {
return new GroupUpdateResult ( GroupState . GROUP_CONSISTENT_OR_AHEAD , null ) ;
}
updateLocalDatabaseGroupState ( inputGroupState , newLocalState ) ;
insertUpdateMessages ( timestamp , advanceGroupStateResult . getProcessedLogEntries ( ) ) ;
persistLearnedProfileKeys ( inputGroupState ) ;
GlobalGroupState remainingWork = advanceGroupStateResult . getNewGlobalGroupState ( ) ;
if ( remainingWork . getHistory ( ) . size ( ) > 0 ) {
2020-05-29 19:35:40 +02:00
Log . i ( TAG , String . format ( Locale . US , "There are more revisions on the server for this group, not applying at this time, V[%d..%d]" , newLocalState . getRevision ( ) + 1 , remainingWork . getLatestRevisionNumber ( ) ) ) ;
2020-05-02 00:13:23 +02:00
}
return new GroupUpdateResult ( GroupState . GROUP_UPDATED , newLocalState ) ;
}
2020-05-13 18:36:57 +02:00
private void insertGroupLeave ( ) {
if ( ! groupDatabase . isActive ( groupId ) ) {
Log . w ( TAG , "Group has already been left." ) ;
return ;
}
Recipient groupRecipient = Recipient . externalGroup ( context , groupId ) ;
UUID selfUuid = Recipient . self ( ) . getUuid ( ) . get ( ) ;
DecryptedGroup decryptedGroup = groupDatabase . requireGroup ( groupId )
. requireV2GroupProperties ( )
. getDecryptedGroup ( ) ;
2020-05-29 19:35:40 +02:00
DecryptedGroup simulatedGroupState = DecryptedGroupUtil . removeMember ( decryptedGroup , selfUuid , decryptedGroup . getRevision ( ) + 1 ) ;
2020-05-13 18:36:57 +02:00
DecryptedGroupChange simulatedGroupChange = DecryptedGroupChange . newBuilder ( )
. setEditor ( UuidUtil . toByteString ( UuidUtil . UNKNOWN_UUID ) )
2020-05-29 19:35:40 +02:00
. setRevision ( simulatedGroupState . getRevision ( ) )
2020-05-13 18:36:57 +02:00
. addDeleteMembers ( UuidUtil . toByteString ( selfUuid ) )
. build ( ) ;
2020-05-19 21:02:24 +02:00
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil . createDecryptedGroupV2Context ( masterKey , simulatedGroupState , simulatedGroupChange , null ) ;
2020-05-13 18:36:57 +02:00
OutgoingGroupUpdateMessage leaveMessage = new OutgoingGroupUpdateMessage ( groupRecipient ,
decryptedGroupV2Context ,
null ,
System . currentTimeMillis ( ) ,
0 ,
false ,
null ,
Collections . emptyList ( ) ,
Collections . emptyList ( ) ) ;
try {
MmsDatabase mmsDatabase = DatabaseFactory . getMmsDatabase ( context ) ;
long threadId = DatabaseFactory . getThreadDatabase ( context ) . getThreadIdFor ( groupRecipient ) ;
long id = mmsDatabase . insertMessageOutbox ( leaveMessage , threadId , false , null ) ;
mmsDatabase . markAsSent ( id , true ) ;
} catch ( MmsException e ) {
Log . w ( TAG , "Failed to insert leave message." , e ) ;
}
groupDatabase . setActive ( groupId , false ) ;
groupDatabase . remove ( groupId , Recipient . self ( ) . getId ( ) ) ;
}
2020-05-02 00:13:23 +02:00
/ * *
* @return true iff group exists locally and is at least the specified revision .
* /
private boolean localIsAtLeast ( int revision ) {
if ( groupDatabase . isUnknownGroup ( groupId ) | | revision = = LATEST ) {
return false ;
}
int dbRevision = groupDatabase . getGroup ( groupId ) . get ( ) . requireV2GroupProperties ( ) . getGroupRevision ( ) ;
return revision < = dbRevision ;
}
private void updateLocalDatabaseGroupState ( @NonNull GlobalGroupState inputGroupState ,
@NonNull DecryptedGroup newLocalState )
{
2020-05-14 20:57:40 +02:00
boolean needsAvatarFetch ;
2020-05-02 00:13:23 +02:00
if ( inputGroupState . getLocalState ( ) = = null ) {
groupDatabase . create ( masterKey , newLocalState ) ;
2020-05-14 20:57:40 +02:00
needsAvatarFetch = ! TextUtils . isEmpty ( newLocalState . getAvatar ( ) ) ;
2020-05-02 00:13:23 +02:00
} else {
groupDatabase . update ( masterKey , newLocalState ) ;
2020-05-14 20:57:40 +02:00
needsAvatarFetch = ! newLocalState . getAvatar ( ) . equals ( inputGroupState . getLocalState ( ) . getAvatar ( ) ) ;
2020-05-02 00:13:23 +02:00
}
2020-05-14 20:57:40 +02:00
if ( needsAvatarFetch ) {
jobManager . add ( new AvatarGroupsV2DownloadJob ( groupId , newLocalState . getAvatar ( ) ) ) ;
2020-05-02 00:13:23 +02:00
}
final boolean fullMemberPostUpdate = GroupProtoUtil . isMember ( Recipient . self ( ) . getUuid ( ) . get ( ) , newLocalState . getMembersList ( ) ) ;
if ( fullMemberPostUpdate ) {
recipientDatabase . setProfileSharing ( Recipient . externalGroup ( context , groupId ) . getId ( ) , true ) ;
}
}
private void insertUpdateMessages ( long timestamp , Collection < GroupLogEntry > processedLogEntries ) {
for ( GroupLogEntry entry : processedLogEntries ) {
2020-05-19 21:02:24 +02:00
storeMessage ( GroupProtoUtil . createDecryptedGroupV2Context ( masterKey , entry . getGroup ( ) , entry . getChange ( ) , null ) , timestamp ) ;
2020-05-02 00:13:23 +02:00
}
}
private void persistLearnedProfileKeys ( @NonNull GlobalGroupState globalGroupState ) {
final ProfileKeySet profileKeys = new ProfileKeySet ( ) ;
for ( GroupLogEntry entry : globalGroupState . getHistory ( ) ) {
2020-05-21 22:04:57 +02:00
Optional < UUID > editor = DecryptedGroupUtil . editorUuid ( entry . getChange ( ) ) ;
if ( editor . isPresent ( ) ) {
profileKeys . addKeysFromGroupState ( entry . getGroup ( ) , editor . get ( ) ) ;
}
2020-05-02 00:13:23 +02:00
}
Collection < RecipientId > updated = recipientDatabase . persistProfileKeySet ( profileKeys ) ;
if ( ! updated . isEmpty ( ) ) {
Log . i ( TAG , String . format ( Locale . US , "Learned %d new profile keys, scheduling profile retrievals" , updated . size ( ) ) ) ;
2020-06-09 01:04:55 +02:00
RetrieveProfileJob . enqueue ( updated ) ;
2020-05-02 00:13:23 +02:00
}
}
2020-05-26 21:02:34 +02:00
private @NonNull GlobalGroupState queryServer ( @Nullable DecryptedGroup localState , boolean latestOnly )
2020-05-02 00:13:23 +02:00
throws IOException , GroupNotAMemberException
{
DecryptedGroup latestServerGroup ;
List < GroupLogEntry > history ;
2020-05-19 21:02:24 +02:00
UUID selfUuid = Recipient . self ( ) . getUuid ( ) . get ( ) ;
2020-05-02 00:13:23 +02:00
try {
2020-05-05 17:13:53 +02:00
latestServerGroup = groupsV2Api . getGroup ( groupSecretParams , groupsV2Authorization . getAuthorizationForToday ( selfUuid , groupSecretParams ) ) ;
2020-05-02 00:13:23 +02:00
} catch ( NotInGroupException e ) {
throw new GroupNotAMemberException ( e ) ;
} catch ( VerificationFailedException | InvalidGroupStateException e ) {
throw new IOException ( e ) ;
}
2020-05-26 21:02:34 +02:00
if ( latestOnly | | ! GroupProtoUtil . isMember ( selfUuid , latestServerGroup . getMembersList ( ) ) ) {
history = Collections . singletonList ( new GroupLogEntry ( latestServerGroup , null ) ) ;
} else {
2020-05-29 19:35:40 +02:00
int revisionWeWereAdded = GroupProtoUtil . findRevisionWeWereAdded ( latestServerGroup , selfUuid ) ;
int logsNeededFrom = localState ! = null ? Math . max ( localState . getRevision ( ) , revisionWeWereAdded ) : revisionWeWereAdded ;
2020-05-02 00:13:23 +02:00
history = getFullMemberHistory ( selfUuid , logsNeededFrom ) ;
}
return new GlobalGroupState ( localState , history ) ;
}
2020-05-29 19:35:40 +02:00
private List < GroupLogEntry > getFullMemberHistory ( @NonNull UUID selfUuid , int logsNeededFromRevision ) throws IOException {
2020-05-02 00:13:23 +02:00
try {
2020-05-29 19:35:40 +02:00
Collection < DecryptedGroupHistoryEntry > groupStatesFromRevision = groupsV2Api . getGroupHistory ( groupSecretParams , logsNeededFromRevision , groupsV2Authorization . getAuthorizationForToday ( selfUuid , groupSecretParams ) ) ;
2020-05-02 00:13:23 +02:00
ArrayList < GroupLogEntry > history = new ArrayList < > ( groupStatesFromRevision . size ( ) ) ;
for ( DecryptedGroupHistoryEntry entry : groupStatesFromRevision ) {
history . add ( new GroupLogEntry ( entry . getGroup ( ) , entry . getChange ( ) ) ) ;
}
return history ;
} catch ( InvalidGroupStateException | VerificationFailedException e ) {
throw new IOException ( e ) ;
}
}
private void storeMessage ( @NonNull DecryptedGroupV2Context decryptedGroupV2Context , long timestamp ) {
2020-05-21 22:04:57 +02:00
Optional < UUID > editor = getEditor ( decryptedGroupV2Context ) ;
if ( ! editor . isPresent ( ) | | UuidUtil . UNKNOWN_UUID . equals ( editor . get ( ) ) ) {
Log . w ( TAG , "Cannot determine editor of change, can't insert message" ) ;
return ;
}
boolean outgoing = Recipient . self ( ) . requireUuid ( ) . equals ( editor . get ( ) ) ;
2020-05-13 18:36:57 +02:00
if ( outgoing ) {
try {
MmsDatabase mmsDatabase = DatabaseFactory . getMmsDatabase ( context ) ;
RecipientId recipientId = recipientDatabase . getOrInsertFromGroupId ( groupId ) ;
Recipient recipient = Recipient . resolved ( recipientId ) ;
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage ( recipient , decryptedGroupV2Context , null , timestamp , 0 , false , null , Collections . emptyList ( ) , Collections . emptyList ( ) ) ;
long threadId = DatabaseFactory . getThreadDatabase ( context ) . getThreadIdFor ( recipient ) ;
long messageId = mmsDatabase . insertMessageOutbox ( outgoingMessage , threadId , false , null ) ;
mmsDatabase . markAsSent ( messageId , true ) ;
} catch ( MmsException e ) {
Log . w ( TAG , e ) ;
}
} else {
SmsDatabase smsDatabase = DatabaseFactory . getSmsDatabase ( context ) ;
2020-05-21 22:04:57 +02:00
RecipientId sender = RecipientId . from ( editor . get ( ) , null ) ;
2020-05-13 18:36:57 +02:00
IncomingTextMessage incoming = new IncomingTextMessage ( sender , - 1 , timestamp , timestamp , "" , Optional . of ( groupId ) , 0 , false ) ;
IncomingGroupUpdateMessage groupMessage = new IncomingGroupUpdateMessage ( incoming , decryptedGroupV2Context ) ;
smsDatabase . insertMessageInbox ( groupMessage ) ;
2020-05-02 00:13:23 +02:00
}
}
2020-05-21 22:04:57 +02:00
private Optional < UUID > getEditor ( @NonNull DecryptedGroupV2Context decryptedGroupV2Context ) {
DecryptedGroupChange change = decryptedGroupV2Context . getChange ( ) ;
Optional < UUID > changeEditor = DecryptedGroupUtil . editorUuid ( change ) ;
if ( changeEditor . isPresent ( ) ) {
return changeEditor ;
} else {
Optional < DecryptedPendingMember > pendingByUuid = DecryptedGroupUtil . findPendingByUuid ( decryptedGroupV2Context . getGroupState ( ) . getPendingMembersList ( ) , Recipient . self ( ) . requireUuid ( ) ) ;
if ( pendingByUuid . isPresent ( ) ) {
return Optional . fromNullable ( UuidUtil . fromByteStringOrNull ( pendingByUuid . get ( ) . getAddedByUuid ( ) ) ) ;
}
}
return Optional . absent ( ) ;
}
2020-05-02 00:13:23 +02:00
}
}