001/**
002 *
003 * Copyright 2003-2007 Jive Software.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.jivesoftware.smackx.vcardtemp.packet;
019
020import java.io.BufferedInputStream;
021import java.io.File;
022import java.io.FileInputStream;
023import java.io.IOException;
024import java.lang.reflect.Field;
025import java.lang.reflect.Modifier;
026import java.net.URL;
027import java.security.MessageDigest;
028import java.security.NoSuchAlgorithmException;
029import java.util.HashMap;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.logging.Level;
033import java.util.logging.Logger;
034
035import org.jivesoftware.smack.SmackException.NoResponseException;
036import org.jivesoftware.smack.SmackException.NotConnectedException;
037import org.jivesoftware.smack.XMPPConnection;
038import org.jivesoftware.smack.XMPPException.XMPPErrorException;
039import org.jivesoftware.smack.packet.IQ;
040import org.jivesoftware.smack.util.StringUtils;
041import org.jivesoftware.smackx.vcardtemp.VCardManager;
042
043/**
044 * A VCard class for use with the
045 * <a href="http://www.jivesoftware.org/smack/" target="_blank">SMACK jabber library</a>.<p>
046 * <p/>
047 * You should refer to the
048 * <a href="http://www.xmpp.org/extensions/jep-0054.html" target="_blank">XEP-54 documentation</a>.<p>
049 * <p/>
050 * Please note that this class is incomplete but it does provide the most commonly found
051 * information in vCards. Also remember that VCard transfer is not a standard, and the protocol
052 * may change or be replaced.<p>
053 * <p/>
054 * <b>Usage:</b>
055 * <pre>
056 * <p/>
057 * // To save VCard:
058 * <p/>
059 * VCard vCard = new VCard();
060 * vCard.setFirstName("kir");
061 * vCard.setLastName("max");
062 * vCard.setEmailHome("foo@fee.bar");
063 * vCard.setJabberId("jabber@id.org");
064 * vCard.setOrganization("Jetbrains, s.r.o");
065 * vCard.setNickName("KIR");
066 * <p/>
067 * vCard.setField("TITLE", "Mr");
068 * vCard.setAddressFieldHome("STREET", "Some street");
069 * vCard.setAddressFieldWork("CTRY", "US");
070 * vCard.setPhoneWork("FAX", "3443233");
071 * <p/>
072 * vCard.save(connection);
073 * <p/>
074 * // To load VCard:
075 * <p/>
076 * VCard vCard = new VCard();
077 * vCard.load(conn); // load own VCard
078 * vCard.load(conn, "joe@foo.bar"); // load someone's VCard
079 * </pre>
080 *
081 * @author Kirill Maximov (kir@maxkir.com)
082 */
083public class VCard extends IQ {
084    private static final Logger LOGGER = Logger.getLogger(VCard.class.getName());
085    
086    private static final String DEFAULT_MIME_TYPE = "image/jpeg";
087    
088    /**
089     * Phone types:
090     * VOICE?, FAX?, PAGER?, MSG?, CELL?, VIDEO?, BBS?, MODEM?, ISDN?, PCS?, PREF?
091     */
092    private Map<String, String> homePhones = new HashMap<String, String>();
093    private Map<String, String> workPhones = new HashMap<String, String>();
094
095    /**
096     * Address types:
097     * POSTAL?, PARCEL?, (DOM | INTL)?, PREF?, POBOX?, EXTADR?, STREET?, LOCALITY?,
098     * REGION?, PCODE?, CTRY?
099     */
100    private Map<String, String> homeAddr = new HashMap<String, String>();
101    private Map<String, String> workAddr = new HashMap<String, String>();
102
103    private String firstName;
104    private String lastName;
105    private String middleName;
106
107    private String emailHome;
108    private String emailWork;
109
110    private String organization;
111    private String organizationUnit;
112
113    private String photoMimeType;
114    private String photoBinval;
115
116    /**
117     * Such as DESC ROLE GEO etc.. see XEP-0054
118     */
119    private Map<String, String> otherSimpleFields = new HashMap<String, String>();
120
121    // fields that, as they are should not be escaped before forwarding to the server
122    private Map<String, String> otherUnescapableFields = new HashMap<String, String>();
123
124    public VCard() {
125    }
126
127    /**
128     * Set generic VCard field.
129     *
130     * @param field value of field. Possible values: NICKNAME, PHOTO, BDAY, JABBERID, MAILER, TZ,
131     *              GEO, TITLE, ROLE, LOGO, NOTE, PRODID, REV, SORT-STRING, SOUND, UID, URL, DESC.
132     */
133    public String getField(String field) {
134        return otherSimpleFields.get(field);
135    }
136
137    /**
138     * Set generic VCard field.
139     *
140     * @param value value of field
141     * @param field field to set. See {@link #getField(String)}
142     * @see #getField(String)
143     */
144    public void setField(String field, String value) {
145        setField(field, value, false);
146    }
147
148    /**
149     * Set generic, unescapable VCard field. If unescabale is set to true, XML maybe a part of the
150     * value.
151     *
152     * @param value         value of field
153     * @param field         field to set. See {@link #getField(String)}
154     * @param isUnescapable True if the value should not be escaped, and false if it should.
155     */
156    public void setField(String field, String value, boolean isUnescapable) {
157        if (!isUnescapable) {
158            otherSimpleFields.put(field, value);
159        }
160        else {
161            otherUnescapableFields.put(field, value);
162        }
163    }
164
165    public String getFirstName() {
166        return firstName;
167    }
168
169    public void setFirstName(String firstName) {
170        this.firstName = firstName;
171        // Update FN field
172        updateFN();
173    }
174
175    public String getLastName() {
176        return lastName;
177    }
178
179    public void setLastName(String lastName) {
180        this.lastName = lastName;
181        // Update FN field
182        updateFN();
183    }
184
185    public String getMiddleName() {
186        return middleName;
187    }
188
189    public void setMiddleName(String middleName) {
190        this.middleName = middleName;
191        // Update FN field
192        updateFN();
193    }
194
195    public String getNickName() {
196        return otherSimpleFields.get("NICKNAME");
197    }
198
199    public void setNickName(String nickName) {
200        otherSimpleFields.put("NICKNAME", nickName);
201    }
202
203    public String getEmailHome() {
204        return emailHome;
205    }
206
207    public void setEmailHome(String email) {
208        this.emailHome = email;
209    }
210
211    public String getEmailWork() {
212        return emailWork;
213    }
214
215    public void setEmailWork(String emailWork) {
216        this.emailWork = emailWork;
217    }
218
219    public String getJabberId() {
220        return otherSimpleFields.get("JABBERID");
221    }
222
223    public void setJabberId(String jabberId) {
224        otherSimpleFields.put("JABBERID", jabberId);
225    }
226
227    public String getOrganization() {
228        return organization;
229    }
230
231    public void setOrganization(String organization) {
232        this.organization = organization;
233    }
234
235    public String getOrganizationUnit() {
236        return organizationUnit;
237    }
238
239    public void setOrganizationUnit(String organizationUnit) {
240        this.organizationUnit = organizationUnit;
241    }
242
243    /**
244     * Get home address field
245     *
246     * @param addrField one of POSTAL, PARCEL, (DOM | INTL), PREF, POBOX, EXTADR, STREET,
247     *                  LOCALITY, REGION, PCODE, CTRY
248     */
249    public String getAddressFieldHome(String addrField) {
250        return homeAddr.get(addrField);
251    }
252
253    /**
254     * Set home address field
255     *
256     * @param addrField one of POSTAL, PARCEL, (DOM | INTL), PREF, POBOX, EXTADR, STREET,
257     *                  LOCALITY, REGION, PCODE, CTRY
258     */
259    public void setAddressFieldHome(String addrField, String value) {
260        homeAddr.put(addrField, value);
261    }
262
263    /**
264     * Get work address field
265     *
266     * @param addrField one of POSTAL, PARCEL, (DOM | INTL), PREF, POBOX, EXTADR, STREET,
267     *                  LOCALITY, REGION, PCODE, CTRY
268     */
269    public String getAddressFieldWork(String addrField) {
270        return workAddr.get(addrField);
271    }
272
273    /**
274     * Set work address field
275     *
276     * @param addrField one of POSTAL, PARCEL, (DOM | INTL), PREF, POBOX, EXTADR, STREET,
277     *                  LOCALITY, REGION, PCODE, CTRY
278     */
279    public void setAddressFieldWork(String addrField, String value) {
280        workAddr.put(addrField, value);
281    }
282
283
284    /**
285     * Set home phone number
286     *
287     * @param phoneType one of VOICE, FAX, PAGER, MSG, CELL, VIDEO, BBS, MODEM, ISDN, PCS, PREF
288     * @param phoneNum  phone number
289     */
290    public void setPhoneHome(String phoneType, String phoneNum) {
291        homePhones.put(phoneType, phoneNum);
292    }
293
294    /**
295     * Get home phone number
296     *
297     * @param phoneType one of VOICE, FAX, PAGER, MSG, CELL, VIDEO, BBS, MODEM, ISDN, PCS, PREF
298     */
299    public String getPhoneHome(String phoneType) {
300        return homePhones.get(phoneType);
301    }
302
303    /**
304     * Set work phone number
305     *
306     * @param phoneType one of VOICE, FAX, PAGER, MSG, CELL, VIDEO, BBS, MODEM, ISDN, PCS, PREF
307     * @param phoneNum  phone number
308     */
309    public void setPhoneWork(String phoneType, String phoneNum) {
310        workPhones.put(phoneType, phoneNum);
311    }
312
313    /**
314     * Get work phone number
315     *
316     * @param phoneType one of VOICE, FAX, PAGER, MSG, CELL, VIDEO, BBS, MODEM, ISDN, PCS, PREF
317     */
318    public String getPhoneWork(String phoneType) {
319        return workPhones.get(phoneType);
320    }
321
322    /**
323     * Set the avatar for the VCard by specifying the url to the image.
324     *
325     * @param avatarURL the url to the image(png,jpeg,gif,bmp)
326     */
327    public void setAvatar(URL avatarURL) {
328        byte[] bytes = new byte[0];
329        try {
330            bytes = getBytes(avatarURL);
331        }
332        catch (IOException e) {
333            LOGGER.log(Level.SEVERE, "Error getting bytes from URL: " + avatarURL, e);
334        }
335
336        setAvatar(bytes);
337    }
338
339    /**
340     * Removes the avatar from the vCard
341     *
342     *  This is done by setting the PHOTO value to the empty string as defined in XEP-0153
343     */
344    public void removeAvatar() {
345        // Remove avatar (if any)
346        photoBinval = null;
347        photoMimeType = null;
348    }
349
350    /**
351     * Specify the bytes of the JPEG for the avatar to use.
352     * If bytes is null, then the avatar will be removed.
353     * 'image/jpeg' will be used as MIME type.
354     *
355     * @param bytes the bytes of the avatar, or null to remove the avatar data
356     */
357    public void setAvatar(byte[] bytes) {
358        setAvatar(bytes, DEFAULT_MIME_TYPE);
359    }
360
361    /**
362     * Specify the bytes for the avatar to use as well as the mime type.
363     *
364     * @param bytes the bytes of the avatar.
365     * @param mimeType the mime type of the avatar.
366     */
367    public void setAvatar(byte[] bytes, String mimeType) {
368        // If bytes is null, remove the avatar
369        if (bytes == null) {
370            removeAvatar();
371            return;
372        }
373
374        // Otherwise, add to mappings.
375        String encodedImage = StringUtils.encodeBase64(bytes);
376
377        setAvatar(encodedImage, mimeType);
378    }
379
380    /**
381     * Specify the Avatar used for this vCard.
382     *
383     * @param encodedImage the Base64 encoded image as String
384     * @param mimeType the MIME type of the image
385     */
386    public void setAvatar(String encodedImage, String mimeType) {
387        photoBinval = encodedImage;
388        photoMimeType = mimeType;
389    }
390
391    /**
392     * Set the encoded avatar string. This is used by the provider.
393     *
394     * @param encodedAvatar the encoded avatar string.
395     * @deprecated Use {@link #setAvatar(String, String)} instead.
396     */
397    public void setEncodedImage(String encodedAvatar) {
398        setAvatar(encodedAvatar, DEFAULT_MIME_TYPE);
399    }
400    
401    /**
402     * Return the byte representation of the avatar(if one exists), otherwise returns null if
403     * no avatar could be found.
404     * <b>Example 1</b>
405     * <pre>
406     * // Load Avatar from VCard
407     * byte[] avatarBytes = vCard.getAvatar();
408     * <p/>
409     * // To create an ImageIcon for Swing applications
410     * ImageIcon icon = new ImageIcon(avatar);
411     * <p/>
412     * // To create just an image object from the bytes
413     * ByteArrayInputStream bais = new ByteArrayInputStream(avatar);
414     * try {
415     *   Image image = ImageIO.read(bais);
416     *  }
417     *  catch (IOException e) {
418     *    e.printStackTrace();
419     * }
420     * </pre>
421     *
422     * @return byte representation of avatar.
423     */
424    public byte[] getAvatar() {
425        if (photoBinval == null) {
426            return null;
427        }
428        return StringUtils.decodeBase64(photoBinval);
429    }
430
431    /**
432     * Returns the MIME Type of the avatar or null if none is set
433     *
434     * @return the MIME Type of the avatar or null
435     */
436    public String getAvatarMimeType() {
437        return photoMimeType;
438    }
439
440    /**
441     * Common code for getting the bytes of a url.
442     *
443     * @param url the url to read.
444     */
445    public static byte[] getBytes(URL url) throws IOException {
446        final String path = url.getPath();
447        final File file = new File(path);
448        if (file.exists()) {
449            return getFileBytes(file);
450        }
451
452        return null;
453    }
454
455    private static byte[] getFileBytes(File file) throws IOException {
456        BufferedInputStream bis = null;
457        try {
458            bis = new BufferedInputStream(new FileInputStream(file));
459            int bytes = (int) file.length();
460            byte[] buffer = new byte[bytes];
461            int readBytes = bis.read(buffer);
462            if (readBytes != buffer.length) {
463                throw new IOException("Entire file not read");
464            }
465            return buffer;
466        }
467        finally {
468            if (bis != null) {
469                bis.close();
470            }
471        }
472    }
473
474    /**
475     * Returns the SHA-1 Hash of the Avatar image.
476     *
477     * @return the SHA-1 Hash of the Avatar image.
478     */
479    public String getAvatarHash() {
480        byte[] bytes = getAvatar();
481        if (bytes == null) {
482            return null;
483        }
484
485        MessageDigest digest;
486        try {
487            digest = MessageDigest.getInstance("SHA-1");
488        }
489        catch (NoSuchAlgorithmException e) {
490            LOGGER.log(Level.SEVERE, "Failed to get message digest", e);
491            return null;
492        }
493
494        digest.update(bytes);
495        return StringUtils.encodeHex(digest.digest());
496    }
497
498    private void updateFN() {
499        StringBuilder sb = new StringBuilder();
500        if (firstName != null) {
501            sb.append(StringUtils.escapeForXML(firstName)).append(' ');
502        }
503        if (middleName != null) {
504            sb.append(StringUtils.escapeForXML(middleName)).append(' ');
505        }
506        if (lastName != null) {
507            sb.append(StringUtils.escapeForXML(lastName));
508        }
509        setField("FN", sb.toString());
510    }
511
512    /**
513     * Save this vCard for the user connected by 'connection'. XMPPConnection should be authenticated
514     * and not anonymous.<p>
515     * <p/>
516     * NOTE: the method is asynchronous and does not wait for the returned value.
517     *
518     * @param connection the XMPPConnection to use.
519     * @throws XMPPErrorException thrown if there was an issue setting the VCard in the server.
520     * @throws NoResponseException if there was no response from the server.
521     * @throws NotConnectedException 
522     */
523    public void save(XMPPConnection connection) throws NoResponseException, XMPPErrorException, NotConnectedException {
524        checkAuthenticated(connection, true);
525
526        setType(IQ.Type.SET);
527        setFrom(connection.getUser());
528        connection.createPacketCollectorAndSend(this).nextResultOrThrow();
529    }
530
531    /**
532     * Load VCard information for a connected user. XMPPConnection should be authenticated
533     * and not anonymous.
534     * @throws XMPPErrorException 
535     * @throws NoResponseException 
536     * @throws NotConnectedException 
537     */
538    public void load(XMPPConnection connection) throws NoResponseException, XMPPErrorException, NotConnectedException  {
539        checkAuthenticated(connection, true);
540
541        setFrom(connection.getUser());
542        doLoad(connection, connection.getUser());
543    }
544
545    /**
546     * Load VCard information for a given user. XMPPConnection should be authenticated and not anonymous.
547     * @throws XMPPErrorException 
548     * @throws NoResponseException if there was no response from the server.
549     * @throws NotConnectedException 
550     */
551    public void load(XMPPConnection connection, String user) throws NoResponseException, XMPPErrorException, NotConnectedException {
552        checkAuthenticated(connection, false);
553
554        setTo(user);
555        doLoad(connection, user);
556    }
557
558    private void doLoad(XMPPConnection connection, String user) throws NoResponseException, XMPPErrorException, NotConnectedException {
559        setType(Type.GET);
560        VCard result = (VCard) connection.createPacketCollectorAndSend(this).nextResultOrThrow();
561        copyFieldsFrom(result);
562    }
563
564    public String getChildElementXML() {
565        StringBuilder sb = new StringBuilder();
566        new VCardWriter(sb).write();
567        return sb.toString();
568    }
569
570    private void copyFieldsFrom(VCard from) {
571        Field[] fields = VCard.class.getDeclaredFields();
572        for (Field field : fields) {
573            if (field.getDeclaringClass() == VCard.class &&
574                    !Modifier.isFinal(field.getModifiers())) {
575                try {
576                    field.setAccessible(true);
577                    field.set(this, field.get(from));
578                }
579                catch (IllegalAccessException e) {
580                    throw new RuntimeException("This cannot happen:" + field, e);
581                }
582            }
583        }
584    }
585
586    private void checkAuthenticated(XMPPConnection connection, boolean checkForAnonymous) {
587        if (connection == null) {
588            throw new IllegalArgumentException("No connection was provided");
589        }
590        if (!connection.isAuthenticated()) {
591            throw new IllegalArgumentException("XMPPConnection is not authenticated");
592        }
593        if (checkForAnonymous && connection.isAnonymous()) {
594            throw new IllegalArgumentException("XMPPConnection cannot be anonymous");
595        }
596    }
597
598    private boolean hasContent() {
599        //noinspection OverlyComplexBooleanExpression
600        return hasNameField()
601                || hasOrganizationFields()
602                || emailHome != null
603                || emailWork != null
604                || otherSimpleFields.size() > 0
605                || otherUnescapableFields.size() > 0
606                || homeAddr.size() > 0
607                || homePhones.size() > 0
608                || workAddr.size() > 0
609                || workPhones.size() > 0
610                || photoBinval != null
611                ;
612    }
613
614    private boolean hasNameField() {
615        return firstName != null || lastName != null || middleName != null;
616    }
617
618    private boolean hasOrganizationFields() {
619        return organization != null || organizationUnit != null;
620    }
621
622    // Used in tests:
623
624    public boolean equals(Object o) {
625        if (this == o) return true;
626        if (o == null || getClass() != o.getClass()) return false;
627
628        final VCard vCard = (VCard) o;
629
630        if (emailHome != null ? !emailHome.equals(vCard.emailHome) : vCard.emailHome != null) {
631            return false;
632        }
633        if (emailWork != null ? !emailWork.equals(vCard.emailWork) : vCard.emailWork != null) {
634            return false;
635        }
636        if (firstName != null ? !firstName.equals(vCard.firstName) : vCard.firstName != null) {
637            return false;
638        }
639        if (!homeAddr.equals(vCard.homeAddr)) {
640            return false;
641        }
642        if (!homePhones.equals(vCard.homePhones)) {
643            return false;
644        }
645        if (lastName != null ? !lastName.equals(vCard.lastName) : vCard.lastName != null) {
646            return false;
647        }
648        if (middleName != null ? !middleName.equals(vCard.middleName) : vCard.middleName != null) {
649            return false;
650        }
651        if (organization != null ?
652                !organization.equals(vCard.organization) : vCard.organization != null) {
653            return false;
654        }
655        if (organizationUnit != null ?
656                !organizationUnit.equals(vCard.organizationUnit) : vCard.organizationUnit != null) {
657            return false;
658        }
659        if (!otherSimpleFields.equals(vCard.otherSimpleFields)) {
660            return false;
661        }
662        if (!workAddr.equals(vCard.workAddr)) {
663            return false;
664        }
665        if (photoBinval != null ? !photoBinval.equals(vCard.photoBinval) : vCard.photoBinval != null) {
666            return false;
667        }
668
669        return workPhones.equals(vCard.workPhones);
670    }
671
672    public int hashCode() {
673        int result;
674        result = homePhones.hashCode();
675        result = 29 * result + workPhones.hashCode();
676        result = 29 * result + homeAddr.hashCode();
677        result = 29 * result + workAddr.hashCode();
678        result = 29 * result + (firstName != null ? firstName.hashCode() : 0);
679        result = 29 * result + (lastName != null ? lastName.hashCode() : 0);
680        result = 29 * result + (middleName != null ? middleName.hashCode() : 0);
681        result = 29 * result + (emailHome != null ? emailHome.hashCode() : 0);
682        result = 29 * result + (emailWork != null ? emailWork.hashCode() : 0);
683        result = 29 * result + (organization != null ? organization.hashCode() : 0);
684        result = 29 * result + (organizationUnit != null ? organizationUnit.hashCode() : 0);
685        result = 29 * result + otherSimpleFields.hashCode();
686        result = 29 * result + (photoBinval != null ? photoBinval.hashCode() : 0);
687        return result;
688    }
689
690    public String toString() {
691        return getChildElementXML();
692    }
693
694    //==============================================================
695
696    private class VCardWriter {
697
698        private final StringBuilder sb;
699
700        VCardWriter(StringBuilder sb) {
701            this.sb = sb;
702        }
703
704        public void write() {
705            appendTag(VCardManager.ELEMENT, "xmlns", VCardManager.NAMESPACE, hasContent(), new ContentBuilder() {
706                public void addTagContent() {
707                    buildActualContent();
708                }
709            });
710        }
711
712        private void buildActualContent() {
713            if (hasNameField()) {
714                appendN();
715            }
716
717            appendOrganization();
718            appendGenericFields();
719            appendPhoto();
720
721            appendEmail(emailWork, "WORK");
722            appendEmail(emailHome, "HOME");
723
724            appendPhones(workPhones, "WORK");
725            appendPhones(homePhones, "HOME");
726
727            appendAddress(workAddr, "WORK");
728            appendAddress(homeAddr, "HOME");
729        }
730
731        private void appendPhoto() {
732            if (photoBinval == null)
733                return;
734
735            appendTag("PHOTO", true, new ContentBuilder() {
736                public void addTagContent() {
737                    appendTag("BINVAL", photoBinval); // No need to escape photoBinval, as it's already Base64 encoded
738                    appendTag("TYPE", StringUtils.escapeForXML(photoMimeType));
739                }
740            });
741        }
742        private void appendEmail(final String email, final String type) {
743            if (email != null) {
744                appendTag("EMAIL", true, new ContentBuilder() {
745                    public void addTagContent() {
746                        appendEmptyTag(type);
747                        appendEmptyTag("INTERNET");
748                        appendEmptyTag("PREF");
749                        appendTag("USERID", StringUtils.escapeForXML(email));
750                    }
751                });
752            }
753        }
754
755        private void appendPhones(Map<String, String> phones, final String code) {
756            for (final Map.Entry<String,String> entry : phones.entrySet()) {
757                appendTag("TEL", true, new ContentBuilder() {
758                    public void addTagContent() {
759                        appendEmptyTag(entry.getKey());
760                        appendEmptyTag(code);
761                        appendTag("NUMBER", StringUtils.escapeForXML(entry.getValue()));
762                    }
763                });
764            }
765        }
766
767        private void appendAddress(final Map<String, String> addr, final String code) {
768            if (addr.size() > 0) {
769                appendTag("ADR", true, new ContentBuilder() {
770                    public void addTagContent() {
771                        appendEmptyTag(code);
772
773                        for (final Entry<String, String> entry : addr.entrySet()) {
774                            appendTag(entry.getKey(), StringUtils.escapeForXML(entry.getValue()));
775                        }
776                    }
777                });
778            }
779        }
780
781        private void appendEmptyTag(Object tag) {
782            sb.append('<').append(tag).append("/>");
783        }
784
785        private void appendGenericFields() {
786            for (Map.Entry<String, String> entry : otherSimpleFields.entrySet()) {
787                appendTag(entry.getKey().toString(),
788                        StringUtils.escapeForXML(entry.getValue()));
789            }
790
791            for (Map.Entry<String, String> entry : otherUnescapableFields.entrySet()) {
792                appendTag(entry.getKey().toString(),entry.getValue());
793            }
794        }
795
796        private void appendOrganization() {
797            if (hasOrganizationFields()) {
798                appendTag("ORG", true, new ContentBuilder() {
799                    public void addTagContent() {
800                        appendTag("ORGNAME", StringUtils.escapeForXML(organization));
801                        appendTag("ORGUNIT", StringUtils.escapeForXML(organizationUnit));
802                    }
803                });
804            }
805        }
806
807        private void appendN() {
808            appendTag("N", true, new ContentBuilder() {
809                public void addTagContent() {
810                    appendTag("FAMILY", StringUtils.escapeForXML(lastName));
811                    appendTag("GIVEN", StringUtils.escapeForXML(firstName));
812                    appendTag("MIDDLE", StringUtils.escapeForXML(middleName));
813                }
814            });
815        }
816
817        private void appendTag(String tag, String attr, String attrValue, boolean hasContent,
818                ContentBuilder builder) {
819            sb.append('<').append(tag);
820            if (attr != null) {
821                sb.append(' ').append(attr).append('=').append('\'').append(attrValue).append('\'');
822            }
823
824            if (hasContent) {
825                sb.append('>');
826                builder.addTagContent();
827                sb.append("</").append(tag).append(">\n");
828            }
829            else {
830                sb.append("/>\n");
831            }
832        }
833
834        private void appendTag(String tag, boolean hasContent, ContentBuilder builder) {
835            appendTag(tag, null, null, hasContent, builder);
836        }
837
838        private void appendTag(String tag, final CharSequence tagText) {
839            if (tagText == null) return;
840            final ContentBuilder contentBuilder = new ContentBuilder() {
841                public void addTagContent() {
842                    sb.append(tagText.toString().trim());
843                }
844            };
845            appendTag(tag, true, contentBuilder);
846        }
847
848    }
849
850    //==============================================================
851
852    private interface ContentBuilder {
853
854        void addTagContent();
855    }
856
857    //==============================================================
858}
859