use of com.zimbra.cs.store.MailboxBlob in project zm-mailbox by Zimbra.
the class Mailbox method addMessageInternal.
private Message addMessageInternal(OperationContext octxt, ParsedMessage pm, int folderId, boolean noICal, int flags, String[] tags, int conversationId, String rcptEmail, Message.DraftInfo dinfo, CustomMetadata customData, DeliveryContext dctxt, StagedBlob staged) throws IOException, ServiceException {
assert lock.isWriteLockedByCurrentThread();
if (pm == null) {
throw ServiceException.INVALID_REQUEST("null ParsedMessage when adding message to mailbox " + mId, null);
}
if (Math.abs(conversationId) <= HIGHEST_SYSTEM_ID) {
conversationId = ID_AUTO_INCREMENT;
}
CreateMessage redoPlayer = (octxt == null ? null : (CreateMessage) octxt.getPlayer());
boolean needRedo = needRedo(octxt, redoPlayer);
boolean isRedo = redoPlayer != null;
Blob blob = dctxt.getIncomingBlob();
if (blob == null) {
throw ServiceException.FAILURE("Incoming blob not found.", null);
}
// make sure we're parsing headers using the target account's charset
pm.setDefaultCharset(getAccount().getPrefMailDefaultCharset());
// quick check to make sure we don't deliver 5 copies of the same message
String msgidHeader = pm.getMessageID();
boolean isSent = ((flags & Flag.BITMASK_FROM_ME) != 0);
if (!isRedo && msgidHeader != null && !isSent && mSentMessageIDs.containsKey(msgidHeader)) {
Integer sentMsgID = mSentMessageIDs.get(msgidHeader);
if (conversationId == ID_AUTO_INCREMENT) {
conversationId = getConversationIdFromReferent(pm.getMimeMessage(), sentMsgID.intValue());
ZimbraLog.mailbox.debug("duplicate detected but not deduped (%s); will try to slot into conversation %d", msgidHeader, conversationId);
}
}
// caller can't set system flags other than \Draft, \Sent and \Post
flags &= ~Flag.FLAGS_SYSTEM | Flag.BITMASK_DRAFT | Flag.BITMASK_FROM_ME | Flag.BITMASK_POST;
// caller can't specify non-message flags
flags &= Flag.FLAGS_GENERIC | Flag.FLAGS_MESSAGE;
String digest;
int msgSize;
try {
digest = blob.getDigest();
msgSize = (int) blob.getRawSize();
} catch (IOException e) {
throw ServiceException.FAILURE("Unable to get message properties.", e);
}
CreateMessage redoRecorder = new CreateMessage(mId, rcptEmail, pm.getReceivedDate(), dctxt.getShared(), digest, msgSize, folderId, noICal, flags, tags, customData);
StoreIncomingBlob storeRedoRecorder = null;
// strip out unread flag for internal storage (don't do this before redoRecorder initialization)
boolean unread = (flags & Flag.BITMASK_UNREAD) > 0;
flags &= ~Flag.BITMASK_UNREAD;
// "having attachments" is currently tracked via flags
if (pm.hasAttachments()) {
flags |= Flag.BITMASK_ATTACHED;
} else {
flags &= ~Flag.BITMASK_ATTACHED;
}
// priority is calculated from headers
flags &= ~(Flag.BITMASK_HIGH_PRIORITY | Flag.BITMASK_LOW_PRIORITY);
flags |= pm.getPriorityBitmask();
boolean isSpam = folderId == ID_FOLDER_SPAM;
boolean isDraft = (flags & Flag.BITMASK_DRAFT) != 0;
// draft replies get slotted in the same conversation as their parent, if possible
if (isDraft && !isRedo && conversationId == ID_AUTO_INCREMENT && dinfo != null && !Strings.isNullOrEmpty(dinfo.origId)) {
try {
ItemId iid = new ItemId(dinfo.origId, getAccountId());
if (iid.getId() > 0 && iid.belongsTo(this)) {
conversationId = getMessageById(octxt, iid.getId()).getConversationId();
}
} catch (ServiceException e) {
}
}
Message msg = null;
boolean success = false;
CustomMetadata.CustomMetadataList extended = MetadataCallback.preDelivery(pm);
if (customData != null) {
if (extended == null) {
extended = customData.asList();
} else {
extended.addSection(customData);
}
}
Threader threader = pm.getThreader(this);
String subject = pm.getNormalizedSubject();
try {
beginTransaction("addMessage", octxt, redoRecorder);
if (isRedo) {
rcptEmail = redoPlayer.getRcptEmail();
}
Tag.NormalizedTags ntags = new Tag.NormalizedTags(this, tags);
Folder folder = getFolderById(folderId);
// step 0: preemptively check for quota issues (actual update is done in Message.create)
if (!getAccount().isMailAllowReceiveButNotSendWhenOverQuota()) {
checkSizeChange(getSize() + staged.getSize());
}
// step 1: get an ID assigned for the new message
int messageId = getNextItemId(!isRedo ? ID_AUTO_INCREMENT : redoPlayer.getMessageId());
List<Conversation> mergeConvs = null;
if (isRedo) {
conversationId = redoPlayer.getConvId();
// fetch the conversations that were merged in as a result of the original delivery...
List<Integer> mergeConvIds = redoPlayer.getMergedConvIds();
mergeConvs = new ArrayList<Conversation>(mergeConvIds.size());
for (int mergeId : mergeConvIds) {
try {
mergeConvs.add(getConversationById(mergeId));
} catch (NoSuchItemException nsie) {
ZimbraLog.mailbox.debug("could not find merge conversation %d", mergeId);
}
}
}
// step 2: figure out where the message belongs
Conversation conv = null;
if (threader.isEnabled()) {
boolean isReply = pm.isReply();
if (conversationId != ID_AUTO_INCREMENT) {
try {
// fetch the requested conversation
// (we'll ensure that it's receiving new mail after the new message is added to it)
conv = getConversationById(conversationId);
ZimbraLog.mailbox.debug("fetched explicitly-specified conversation %d", conv.getId());
} catch (NoSuchItemException nsie) {
if (!isRedo) {
ZimbraLog.mailbox.debug("could not find explicitly-specified conversation %d", conversationId);
conversationId = ID_AUTO_INCREMENT;
}
}
} else if (!isRedo && !isSpam && (isReply || (!isSent && !subject.isEmpty()))) {
List<Conversation> matches = threader.lookupConversation();
if (matches != null && !matches.isEmpty()) {
// file the message into the largest conversation, then later merge any other matching convs
Collections.sort(matches, new MailItem.SortSizeDescending());
conv = matches.remove(0);
mergeConvs = matches;
}
}
}
if (conv != null && conv.isTagged(Flag.FlagInfo.MUTED)) {
// adding a message to a muted conversation marks it muted and read
unread = false;
flags |= Flag.BITMASK_MUTED;
}
// step 3: create the message and update the cache
// and if the message is also an invite, deal with the calendar item
Conversation convTarget = conv instanceof VirtualConversation ? null : conv;
if (convTarget != null) {
ZimbraLog.mailbox.debug(" placing message in existing conversation %d", convTarget.getId());
}
CalendarPartInfo cpi = pm.getCalendarPartInfo();
ZVCalendar iCal = null;
if (cpi != null && CalendarItem.isAcceptableInvite(getAccount(), cpi)) {
iCal = cpi.cal;
}
msg = Message.create(messageId, folder, convTarget, pm, staged, unread, flags, ntags, dinfo, noICal, iCal, extended);
redoRecorder.setMessageId(msg.getId());
// step 4: create a conversation for the message, if necessary
if (threader.isEnabled() && convTarget == null) {
if (conv == null && conversationId == ID_AUTO_INCREMENT) {
conv = VirtualConversation.create(this, msg);
ZimbraLog.mailbox.debug("placed message %d in vconv %d", msg.getId(), conv.getId());
redoRecorder.setConvFirstMsgId(-1);
} else {
Message[] contents = null;
VirtualConversation vconv = null;
if (!isRedo) {
vconv = (VirtualConversation) conv;
contents = (vconv == null ? new Message[] { msg } : new Message[] { vconv.getMessage(), msg });
} else {
// Executing redo.
int convFirstMsgId = redoPlayer.getConvFirstMsgId();
Message convFirstMsg = null;
// If there was a virtual conversation, then...
if (convFirstMsgId > 0) {
try {
convFirstMsg = getMessageById(octxt, redoPlayer.getConvFirstMsgId());
} catch (MailServiceException e) {
if (!MailServiceException.NO_SUCH_MSG.equals(e.getCode())) {
throw e;
}
// The first message of conversation may have been deleted
// by user between the time of original operation and redo.
// Handle the case by skipping the updating of its
// conversation ID.
}
// if it is still a standalone message.
if (convFirstMsg != null && convFirstMsg.getConversationId() < 0) {
contents = new Message[] { convFirstMsg, msg };
vconv = new VirtualConversation(this, convFirstMsg);
}
}
if (contents == null) {
contents = new Message[] { msg };
}
}
redoRecorder.setConvFirstMsgId(vconv != null ? vconv.getMessageId() : -1);
conv = createConversation(conversationId, contents);
if (vconv != null) {
ZimbraLog.mailbox.debug("removed vconv %d", vconv.getId());
vconv.removeChild(vconv.getMessage());
}
// associate the first message's reference hashes with the new conversation
if (contents.length == 2) {
threader.changeThreadingTargets(contents[0], conv);
}
}
} else {
// conversation feature turned off
redoRecorder.setConvFirstMsgId(-1);
}
redoRecorder.setConvId(conv != null && !(conv instanceof VirtualConversation) ? conv.getId() : -1);
// if we're threading by references, associate the new message's reference hashes with its conversation
if (!isSpam && !isDraft) {
threader.recordAddedMessage(conv);
}
if (conv != null && mergeConvs != null) {
redoRecorder.setMergedConversations(mergeConvs);
for (Conversation smaller : mergeConvs) {
ZimbraLog.mailbox.info("merging conversation %d for references threading", smaller.getId());
// try {
conv.merge(smaller);
// } catch (ServiceException e) {
// if (!e.getCode().equals(MailServiceException.NO_SUCH_MSG)) {
// throw e;
// }
// }
}
}
// conversations may have shifted, so the threader's cached state is now questionable
threader.reset();
// step 5: write the redolog entries
if (dctxt.getShared()) {
if (dctxt.isFirst() && needRedo) {
// Log entry in redolog for blob save. Blob bytes are logged in the StoreIncoming entry.
// Subsequent CreateMessage ops will reference this blob.
storeRedoRecorder = new StoreIncomingBlob(digest, msgSize, dctxt.getMailboxIdList());
storeRedoRecorder.start(getOperationTimestampMillis());
storeRedoRecorder.setBlobBodyInfo(blob.getFile());
storeRedoRecorder.log();
}
// Link to the file created by StoreIncomingBlob.
redoRecorder.setMessageLinkInfo(blob.getPath());
} else {
// Store the blob data inside the CreateMessage op.
redoRecorder.setMessageBodyInfo(blob.getFile());
}
// step 6: link to existing blob
MailboxBlob mblob = StoreManager.getInstance().link(staged, this, messageId, getOperationChangeID());
markOtherItemDirty(mblob);
// when we created the Message, we used the staged locator/size/digest;
// make sure that data actually matches the final blob in the store
msg.updateBlobData(mblob);
if (dctxt.getMailboxBlob() == null) {
// Set mailbox blob for in case we want to add the message to the
// message cache after delivery.
dctxt.setMailboxBlob(mblob);
}
// step 7: queue new message for indexing
index.add(msg);
success = true;
// step 8: send lawful intercept message
try {
Notification.getInstance().interceptIfNecessary(this, pm.getMimeMessage(), "add message", folder);
} catch (ServiceException e) {
ZimbraLog.mailbox.error("unable to send legal intercept message", e);
}
} finally {
if (storeRedoRecorder != null) {
if (success) {
storeRedoRecorder.commit();
} else {
storeRedoRecorder.abort();
}
}
endTransaction(success);
if (success) {
// Everything worked. Update the blob field in ParsedMessage
// so the next recipient in the multi-recipient case will link
// to this blob as opposed to saving its own copy.
dctxt.setFirst(false);
}
}
// step 8: remember the Message-ID header so that we can avoid receiving duplicates
if (isSent && !isRedo && msgidHeader != null) {
mSentMessageIDs.put(msgidHeader, msg.getId());
}
return msg;
}
use of com.zimbra.cs.store.MailboxBlob in project zm-mailbox by Zimbra.
the class Mailbox method addDocumentRevision.
public Document addDocumentRevision(OperationContext octxt, int docId, ParsedDocument pd) throws IOException, ServiceException {
StoreManager sm = StoreManager.getInstance();
StagedBlob staged = sm.stage(pd.getBlob(), this);
AddDocumentRevision redoRecorder = new AddDocumentRevision(mId, pd.getDigest(), pd.getSize(), 0);
boolean success = false;
try {
beginTransaction("addDocumentRevision", octxt, redoRecorder);
Document doc = getDocumentById(docId);
redoRecorder.setDocument(pd);
redoRecorder.setDocId(docId);
redoRecorder.setItemType(doc.getType());
// TODO: simplify the redoRecorder by not subclassing from CreateMessage
// Get the redolog data from the mailbox blob. This is less than ideal in the
// HTTP store case because it will result in network access, and possibly an
// extra write to local disk. If this becomes a problem, we should update the
// ParsedDocument constructor to take a DataSource instead of an InputStream.
MailboxBlob mailboxBlob = doc.setContent(staged, pd);
redoRecorder.setMessageBodyInfo(new MailboxBlobDataSource(mailboxBlob), mailboxBlob.getSize());
index.add(doc);
success = true;
return doc;
} catch (IOException ioe) {
throw ServiceException.FAILURE("error writing document blob", ioe);
} finally {
endTransaction(success);
sm.quietDelete(staged);
}
}
use of com.zimbra.cs.store.MailboxBlob in project zm-mailbox by Zimbra.
the class DbMailItem method accumulateLeafNodes.
/**
* Accumulates <tt>PendingDelete</tt> info for the given <tt>ResultSet</tt>.
* @return a <tt>List</tt> of all versioned items, to be used in a subsequent call to
* {@link DbMailItem#accumulateLeafRevisions}, or an empty list.
*/
static List<Integer> accumulateLeafNodes(PendingDelete info, Mailbox mbox, ResultSet rs) throws SQLException, ServiceException {
boolean dumpsterEnabled = mbox.dumpsterEnabled();
boolean useDumpsterForSpam = mbox.useDumpsterForSpam();
StoreManager sm = StoreManager.getInstance();
List<Integer> versioned = new ArrayList<Integer>();
while (rs.next()) {
// first check to make sure we don't have a modify conflict
int revision = rs.getInt(LEAF_CI_MOD_CONTENT);
int modMetadata = rs.getInt(LEAF_CI_MOD_METADATA);
if (!mbox.checkItemChangeID(modMetadata, revision)) {
info.incomplete = true;
continue;
}
int id = rs.getInt(LEAF_CI_ID);
String uuid = rs.getString(LEAF_CI_UUID);
long size = rs.getLong(LEAF_CI_SIZE);
MailItem.Type type = MailItem.Type.of(rs.getByte(LEAF_CI_TYPE));
Integer itemId = Integer.valueOf(id);
info.itemIds.add(type, itemId, uuid);
info.size += size;
if (rs.getBoolean(LEAF_CI_IS_UNREAD)) {
info.unreadIds.add(itemId);
}
boolean isMessage = false;
switch(type) {
case CONTACT:
info.contacts++;
break;
case CHAT:
case MESSAGE:
isMessage = true;
break;
}
// record deleted virtual conversations and modified-or-deleted real conversations
if (isMessage) {
int parentId = rs.getInt(LEAF_CI_PARENT_ID);
if (rs.wasNull() || parentId <= 0) {
// conversations don't have UUIDs, so this is safe
info.itemIds.add(MailItem.Type.VIRTUAL_CONVERSATION, -id, null);
} else {
info.modifiedIds.add(parentId);
}
}
int flags = rs.getInt(LEAF_CI_FLAGS);
if ((flags & Flag.BITMASK_VERSIONED) != 0) {
versioned.add(id);
}
Integer folderId = rs.getInt(LEAF_CI_FOLDER_ID);
boolean isDeleted = (flags & Flag.BITMASK_DELETED) != 0;
LocationCount fcount = info.folderCounts.get(folderId);
if (fcount == null) {
info.folderCounts.put(folderId, new LocationCount(1, isDeleted ? 1 : 0, size));
} else {
fcount.increment(1, isDeleted ? 1 : 0, size);
}
String[] tags = DbTag.deserializeTags(rs.getString(LEAF_CI_TAGS));
if (tags != null) {
for (String tag : tags) {
LocationCount tcount = info.tagCounts.get(tag);
if (tcount == null) {
info.tagCounts.put(tag, new LocationCount(1, isDeleted ? 1 : 0, size));
} else {
tcount.increment(1, isDeleted ? 1 : 0, size);
}
}
}
int fid = folderId != null ? folderId.intValue() : -1;
if (!dumpsterEnabled || fid == Mailbox.ID_FOLDER_DRAFTS || (fid == Mailbox.ID_FOLDER_SPAM && !useDumpsterForSpam)) {
String blobDigest = rs.getString(LEAF_CI_BLOB_DIGEST);
if (blobDigest != null) {
info.blobDigests.add(blobDigest);
String locator = rs.getString(LEAF_CI_LOCATOR);
try {
MailboxBlob mblob = sm.getMailboxBlob(mbox, id, revision, locator, false);
if (mblob == null) {
ZimbraLog.mailbox.warn("missing blob for id: %d, change: %d", id, revision);
} else {
info.blobs.add(mblob);
}
} catch (Exception e1) {
ZimbraLog.mailbox.warn("Exception while getting mailbox blob", e1);
}
}
int indexId = rs.getInt(LEAF_CI_INDEX_ID);
boolean indexed = !rs.wasNull();
if (indexed) {
if (info.sharedIndex == null) {
info.sharedIndex = new HashSet<Integer>();
}
boolean shared = (flags & Flag.BITMASK_COPIED) != 0;
if (shared) {
info.sharedIndex.add(indexId);
} else {
info.indexIds.add(indexId > MailItem.IndexStatus.STALE.id() ? indexId : id);
}
}
}
}
return versioned;
}
use of com.zimbra.cs.store.MailboxBlob in project zm-mailbox by Zimbra.
the class MailItem method setContent.
MailboxBlob setContent(StagedBlob staged, Object content) throws ServiceException, IOException {
addRevision(false);
// update the item's relevant attributes
markItemModified(Change.CONTENT | Change.DATE | Change.IMAP_UID | Change.SIZE);
// delete the old blob *unless* we've already rewritten it in this transaction
if (getSavedSequence() != mMailbox.getOperationChangeID()) {
if (!canAccess(ACL.RIGHT_WRITE)) {
throw ServiceException.PERM_DENIED("you do not have the necessary permissions on the item");
}
boolean delete = true;
// don't delete blob if last revision uses it
if (isTagged(Flag.FlagInfo.VERSIONED)) {
List<MailItem> revisions = loadRevisions();
if (!revisions.isEmpty()) {
MailItem lastRev = revisions.get(revisions.size() - 1);
if (lastRev.getSavedSequence() == getSavedSequence()) {
delete = false;
}
}
}
if (delete) {
markBlobForDeletion();
}
}
// remove the content from the cache
MessageCache.purge(this);
// update the object to reflect its new contents
long size = staged == null ? 0 : staged.getSize();
if (mData.size != size) {
mMailbox.updateSize(size - mData.size, isQuotaCheckRequired());
mData.size = size;
}
getFolder().updateSize(0, 0, size - mData.size);
mData.setBlobDigest(staged == null ? null : staged.getDigest());
mData.date = mMailbox.getOperationTimestamp();
mData.imapId = mMailbox.isTrackingImap() ? 0 : mData.id;
contentChanged();
// write the content (if any) to the store
MailboxBlob mblob = null;
if (staged != null) {
StoreManager sm = StoreManager.getInstance();
// under windows, a rename will fail if the incoming file is open
if (SystemUtil.ON_WINDOWS)
mblob = sm.link(staged, mMailbox, mId, getSavedSequence());
else
mblob = sm.renameTo(staged, mMailbox, mId, getSavedSequence());
mMailbox.markOtherItemDirty(mblob);
}
mBlob = null;
mData.locator = mblob == null ? null : mblob.getLocator();
// rewrite the DB row to reflect our new view (MUST call saveData)
reanalyze(content, size);
return mblob;
}
use of com.zimbra.cs.store.MailboxBlob in project zm-mailbox by Zimbra.
the class MailItem method icopy.
/** Copies the item to the target folder. Persists the new item to the
* database and the in-memory cache. Copies to the same folder as the
* original item will succeed, but it is strongly suggested that
* {@link #copy(Folder, int, int, short)} be used in that case.<p>
*
* Immutable copied items (both the original and the target) share the
* same entry in the index and get the {@link Flag#BITMASK_COPIED} flag to
* facilitate garbage collection of index entries. (Mutable copied items
* are indexed separately.) They do not share the same blob on disk,
* although the system will use a hard link where possible. Copied
* {@link Message}s are remain in the same {@link Conversation}, but the
* <b>original</b> Message is placed in a new {@link VirtualConversation}
* rather than being grouped with the copied Message.
*
* @param target The folder to copy the item to.
* @param copyId The item id for the newly-created copy.
* @perms {@link ACL#RIGHT_INSERT} on the target folder,
* {@link ACL#RIGHT_READ} on the original item
* @throws ServiceException The following error codes are possible:<ul>
* <li><tt>mail.CANNOT_COPY</tt> - if the item is not copyable
* <li><tt>mail.CANNOT_CONTAIN</tt> - if the target folder can't hold
* the copy of the item
* <li><tt>service.FAILURE</tt> - if there's a database failure
* <li><tt>service.PERM_DENIED</tt> - if you don't have sufficient
* permissions</ul> */
MailItem icopy(Folder target, int copyId, String copyUuid) throws IOException, ServiceException {
if (!isCopyable())
throw MailServiceException.CANNOT_COPY(mId);
if (!target.canContain(this))
throw MailServiceException.CANNOT_CONTAIN();
// permissions required are the same as for copy()
if (!canAccess(ACL.RIGHT_READ))
throw ServiceException.PERM_DENIED("you do not have the required rights on the item");
if (!target.canAccess(ACL.RIGHT_INSERT))
throw ServiceException.PERM_DENIED("you do not have the required rights on the target folder");
// fetch the parent *before* changing the DB
MailItem parent = getParent();
// first, copy the item to the target folder while setting:
// - FLAGS -> FLAGS | Flag.BITMASK_COPIED
// - INDEX_ID -> old index id
// - FOLDER_ID -> new folder
// - IMAP_ID -> new IMAP uid
// - VOLUME_ID -> target volume ID
// then, update the original item
// - PARENT_ID -> NULL
// - FLAGS -> FLAGS | Flag.BITMASK_COPIED
// finally, update OPEN_CONVERSATION if PARENT_ID was NULL
// - ITEM_ID = copy's id for hash
String locator = null;
MailboxBlob srcMblob = getBlob();
if (srcMblob != null) {
StoreManager sm = StoreManager.getInstance();
MailboxBlob mblob = sm.copy(srcMblob, mMailbox, copyId, mMailbox.getOperationChangeID());
mMailbox.markOtherItemDirty(mblob);
locator = mblob.getLocator();
}
// We'll share the index entry if this item can't change out from under us. Re-index the copy if existing item
// (a) wasn't indexed or (b) is mutable.
boolean shareIndex = !isMutable() && getIndexStatus() == IndexStatus.DONE && !target.inSpam();
UnderlyingData data = mData.duplicate(copyId, copyUuid, target.getId(), locator);
data.metadata = encodeMetadata().toString();
data.imapId = copyId;
data.indexId = shareIndex ? getIndexId() : IndexStatus.DEFERRED.id();
data.contentChanged(mMailbox);
ZimbraLog.mailop.info("Performing IMAP copy of %s: copyId=%d, folderId=%d, folderName=%s, parentId=%d.", getMailopContext(this), copyId, target.getId(), target.getName(), data.parentId);
DbMailItem.icopy(this, data, shareIndex);
MailItem copy = constructItem(mMailbox, data);
copy.finishCreation(null);
if (shareIndex && !isTagged(Flag.FlagInfo.COPIED)) {
Flag copiedFlag = mMailbox.getFlagById(Flag.ID_COPIED);
tagChanged(copiedFlag, true);
copy.tagChanged(copiedFlag, true);
if (parent != null)
parent.inheritedTagChanged(copiedFlag, true);
}
if (parent != null && parent.getId() > 0) {
markItemModified(Change.PARENT);
parent.markItemModified(Change.CHILDREN);
mData.parentId = mData.type == Type.MESSAGE.toByte() ? -mId : -1;
metadataChanged();
}
if (!shareIndex) {
mMailbox.index.add(copy);
}
return copy;
}
Aggregations