com.layer.atlas.AtlasMessagesList.java Source code

Java tutorial

Introduction

Here is the source code for com.layer.atlas.AtlasMessagesList.java

Source

/*
 * Copyright (c) 2015 Layer. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.layer.atlas;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.json.JSONException;
import org.json.JSONObject;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.BaseAdapter;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;

import com.layer.atlas.Atlas.ImageLoader;
import com.layer.atlas.Atlas.ImageLoader.BitmapLoadListener;
import com.layer.atlas.Atlas.ImageLoader.ImageSpec;
import com.layer.atlas.Atlas.ImageLoader.MessagePartStreamProvider;
import com.layer.atlas.Atlas.Tools;
import com.layer.sdk.LayerClient;
import com.layer.sdk.changes.LayerChange;
import com.layer.sdk.changes.LayerChange.Type;
import com.layer.sdk.changes.LayerChangeEvent;
import com.layer.sdk.listeners.LayerChangeEventListener;
import com.layer.sdk.listeners.LayerProgressListener;
import com.layer.sdk.messaging.Conversation;
import com.layer.sdk.messaging.LayerObject;
import com.layer.sdk.messaging.Message;
import com.layer.sdk.messaging.Message.RecipientStatus;
import com.layer.sdk.messaging.MessagePart;

/**
 * @author Oleg Orlov
 * @since 13 May 2015
 */
public class AtlasMessagesList extends FrameLayout implements LayerChangeEventListener.MainThread {
    private static final String TAG = AtlasMessagesList.class.getSimpleName();
    private static final boolean debug = false;

    private static final boolean CLUSTERED_BUBBLES = false;

    private static final int MESSAGE_TYPE_UPDATE_VALUES = 0;
    private static final int MESSAGE_REFRESH_UPDATE_ALL = 0;
    private static final int MESSAGE_REFRESH_UPDATE_DELIVERY = 1;

    private final DateFormat timeFormat;

    private ListView messagesList;
    private BaseAdapter messagesAdapter;

    private ArrayList<Cell> cells = new ArrayList<Cell>();

    private LayerClient client;
    private Conversation conv;

    private Message latestReadMessage = null;
    private Message latestDeliveredMessage = null;

    private ItemClickListener clickListener;

    //styles
    private static final float CELL_CONTAINER_ALPHA_UNSENT = 0.5f;
    private static final float CELL_CONTAINER_ALPHA_SENT = 1.0f;
    private int myBubbleColor;
    private int myTextColor;
    private int myTextStyle;
    private float myTextSize;
    private Typeface myTextTypeface;

    private int otherBubbleColor;
    private int otherTextColor;
    private int otherTextStyle;
    private float otherTextSize;
    private Typeface otherTextTypeface;

    private int dateTextColor;
    private int avatarTextColor;
    private int avatarBackgroundColor;

    public AtlasMessagesList(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        parseStyle(context, attrs, defStyle);
        this.timeFormat = android.text.format.DateFormat.getTimeFormat(context);
    }

    public AtlasMessagesList(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public AtlasMessagesList(Context context) {
        super(context);
        this.timeFormat = android.text.format.DateFormat.getTimeFormat(context);
    }

    public void init(final LayerClient layerClient, final Atlas.ParticipantProvider participantProvider) {
        if (layerClient == null)
            throw new IllegalArgumentException("LayerClient cannot be null");
        if (participantProvider == null)
            throw new IllegalArgumentException("ParticipantProvider cannot be null");

        this.client = layerClient;
        LayoutInflater.from(getContext()).inflate(R.layout.atlas_messages_list, this);

        // --- message view
        messagesList = (ListView) findViewById(R.id.atlas_messages_list);
        messagesList.setAdapter(messagesAdapter = new BaseAdapter() {

            public View getView(int position, View convertView, ViewGroup parent) {
                final Cell cell = cells.get(position);
                MessagePart part = cell.messagePart;
                String userId = part.getMessage().getSender().getUserId();

                boolean myMessage = client.getAuthenticatedUserId().equals(userId);

                if (convertView == null) {
                    convertView = LayoutInflater.from(parent.getContext())
                            .inflate(R.layout.atlas_view_messages_convert, parent, false);
                }

                View spacerTop = convertView.findViewById(R.id.atlas_view_messages_convert_spacer_top);
                spacerTop.setVisibility(
                        cell.clusterItemId == cell.clusterHeadItemId && !cell.timeHeader ? View.VISIBLE
                                : View.GONE);

                View spacerBottom = convertView.findViewById(R.id.atlas_view_messages_convert_spacer_bottom);
                spacerBottom.setVisibility(cell.clusterTail ? View.VISIBLE : View.GONE);

                // format date
                View timeBar = convertView.findViewById(R.id.atlas_view_messages_convert_timebar);
                TextView timeBarDay = (TextView) convertView
                        .findViewById(R.id.atlas_view_messages_convert_timebar_day);
                TextView timeBarTime = (TextView) convertView
                        .findViewById(R.id.atlas_view_messages_convert_timebar_time);
                if (cell.timeHeader) {

                    Calendar cal = Calendar.getInstance();
                    cal.set(Calendar.HOUR_OF_DAY, 0);
                    cal.set(Calendar.MINUTE, 0);
                    cal.set(Calendar.SECOND, 0);
                    long todayMidnight = cal.getTimeInMillis();
                    long yesterMidnight = todayMidnight - (24 * 60 * 60 * 1000); // 24h less
                    Date sentAt = cell.messagePart.getMessage().getSentAt();
                    if (sentAt == null)
                        sentAt = new Date();

                    String timeBarTimeText = timeFormat.format(sentAt.getTime());
                    String timeBarDayText = null;
                    if (sentAt.getTime() > todayMidnight) {
                        timeBarDayText = "Today";
                    } else if (sentAt.getTime() > yesterMidnight) {
                        timeBarDayText = "Yesterday";
                    } else {
                        timeBarDayText = Tools.sdfDayOfWeek.format(sentAt);
                    }
                    timeBarDay.setText(timeBarDayText);
                    timeBarTime.setText(timeBarTimeText);
                    timeBar.setVisibility(View.VISIBLE);
                } else {
                    timeBar.setVisibility(View.GONE);
                }

                TextView textAvatar = (TextView) convertView
                        .findViewById(R.id.atlas_view_messages_convert_initials);
                View spacerRight = convertView.findViewById(R.id.atlas_view_messages_convert_spacer_right);
                if (myMessage) {
                    spacerRight.setVisibility(View.GONE);
                    textAvatar.setVisibility(View.INVISIBLE);
                } else {
                    spacerRight.setVisibility(View.VISIBLE);
                    Atlas.Participant participant = participantProvider.getParticipant(userId);
                    String displayText = participant != null ? Atlas.getInitials(participant) : "";
                    textAvatar.setText(displayText);
                    textAvatar.setVisibility(View.VISIBLE);
                }

                // mark unsent messages
                View cellContainer = convertView.findViewById(R.id.atlas_view_messages_cell_container);
                cellContainer.setAlpha(
                        (myMessage && !cell.messagePart.getMessage().isSent()) ? CELL_CONTAINER_ALPHA_UNSENT
                                : CELL_CONTAINER_ALPHA_SENT);

                // delivery receipt check
                TextView receiptView = (TextView) convertView
                        .findViewById(R.id.atlas_view_messages_convert_delivery_receipt);
                receiptView.setVisibility(View.GONE);
                if (latestDeliveredMessage != null
                        && latestDeliveredMessage.getId().equals(cell.messagePart.getMessage().getId())) {
                    receiptView.setVisibility(View.VISIBLE);
                    receiptView.setText("Delivered");
                }
                if (latestReadMessage != null
                        && latestReadMessage.getId().equals(cell.messagePart.getMessage().getId())) {
                    receiptView.setVisibility(View.VISIBLE);
                    receiptView.setText("Read");
                }

                // processing cell
                bindCell(convertView, cell);

                // mark displayed message as read
                Message msg = part.getMessage();
                if (!msg.getSender().getUserId().equals(client.getAuthenticatedUserId())) {
                    msg.markAsRead();
                }

                timeBarDay.setTextColor(dateTextColor);
                timeBarTime.setTextColor(dateTextColor);
                textAvatar.setTextColor(avatarTextColor);
                ((GradientDrawable) textAvatar.getBackground()).setColor(avatarBackgroundColor);

                return convertView;
            }

            private void bindCell(View convertView, final Cell cell) {

                ViewGroup cellContainer = (ViewGroup) convertView
                        .findViewById(R.id.atlas_view_messages_cell_container);

                View cellRootView = cell.onBind(cellContainer);
                boolean alreadyInContainer = false;
                // cleanUp container
                cellRootView.setVisibility(View.VISIBLE);
                for (int iChild = 0; iChild < cellContainer.getChildCount(); iChild++) {
                    View child = cellContainer.getChildAt(iChild);
                    if (child != cellRootView) {
                        child.setVisibility(View.GONE);
                    } else {
                        alreadyInContainer = true;
                    }
                }
                if (!alreadyInContainer) {
                    cellContainer.addView(cellRootView);
                }
            }

            public long getItemId(int position) {
                return position;
            }

            public Object getItem(int position) {
                return cells.get(position);
            }

            public int getCount() {
                return cells.size();
            }

        });

        messagesList.setOnItemClickListener(new OnItemClickListener() {
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                Cell item = cells.get(position);
                if (clickListener != null) {
                    clickListener.onItemClick(item);
                }
            }
        });
        // --- end of messageView

        updateValues();
    }

    public void parseStyle(Context context, AttributeSet attrs, int defStyle) {
        TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AtlasMessageList,
                R.attr.AtlasMessageList, defStyle);
        this.myTextColor = ta.getColor(R.styleable.AtlasMessageList_myTextColor,
                context.getResources().getColor(R.color.atlas_text_black));
        this.myTextStyle = ta.getInt(R.styleable.AtlasMessageList_myTextStyle, Typeface.NORMAL);
        String myTextTypefaceName = ta.getString(R.styleable.AtlasMessageList_myTextTypeface);
        this.myTextTypeface = myTextTypefaceName != null ? Typeface.create(myTextTypefaceName, myTextStyle) : null;
        //this.myTextSize = ta.getDimension(R.styleable.AtlasMessageList_myTextSize, context.getResources().getDimension(R.dimen.atlas_text_size_general));

        this.otherTextColor = ta.getColor(R.styleable.AtlasMessageList_theirTextColor,
                context.getResources().getColor(R.color.atlas_text_black));
        this.otherTextStyle = ta.getInt(R.styleable.AtlasMessageList_theirTextStyle, Typeface.NORMAL);
        String otherTextTypefaceName = ta.getString(R.styleable.AtlasMessageList_theirTextTypeface);
        this.otherTextTypeface = otherTextTypefaceName != null
                ? Typeface.create(otherTextTypefaceName, otherTextStyle)
                : null;
        //this.otherTextSize = ta.getDimension(R.styleable.AtlasMessageList_theirTextSize, context.getResources().getDimension(R.dimen.atlas_text_size_general));

        this.myBubbleColor = ta.getColor(R.styleable.AtlasMessageList_myBubbleColor,
                context.getResources().getColor(R.color.atlas_bubble_blue));
        this.otherBubbleColor = ta.getColor(R.styleable.AtlasMessageList_theirBubbleColor,
                context.getResources().getColor(R.color.atlas_background_gray));

        this.dateTextColor = ta.getColor(R.styleable.AtlasMessageList_dateTextColor,
                context.getResources().getColor(R.color.atlas_text_gray));
        this.avatarTextColor = ta.getColor(R.styleable.AtlasMessageList_avatarTextColor,
                context.getResources().getColor(R.color.atlas_text_black));
        this.avatarBackgroundColor = ta.getColor(R.styleable.AtlasMessageList_avatarBackgroundColor,
                context.getResources().getColor(R.color.atlas_background_gray));
        ta.recycle();
    }

    private void applyStyle() {
        messagesAdapter.notifyDataSetChanged();
    }

    protected void buildCellForMessage(Message msg, ArrayList<Cell> destination) {

        final ArrayList<MessagePart> parts = new ArrayList<MessagePart>(msg.getMessageParts());

        for (int partNo = 0; partNo < parts.size(); partNo++) {
            final MessagePart part = parts.get(partNo);
            final String mimeType = part.getMimeType();

            if (Atlas.MIME_TYPE_IMAGE_PNG.equals(mimeType) || Atlas.MIME_TYPE_IMAGE_JPEG.equals(mimeType)) {

                // 3 parts image support
                if ((partNo + 2 < parts.size())
                        && Atlas.MIME_TYPE_IMAGE_DIMENSIONS.equals(parts.get(partNo + 2).getMimeType())) {
                    String jsonDimensions = new String(parts.get(partNo + 2).getData());
                    try {
                        JSONObject jo = new JSONObject(jsonDimensions);
                        int orientation = jo.getInt("orientation");
                        int width = jo.getInt("width");
                        int height = jo.getInt("height");
                        if (orientation == 1 || orientation == 3) {
                            width = jo.getInt("height");
                            height = jo.getInt("width");
                        }
                        Cell imageCell = new ImageCell(part, parts.get(partNo + 1), width, height);
                        destination.add(imageCell);
                        if (debug)
                            Log.w(TAG, "cellForMessage() 3-image part found at partNo: " + partNo);
                        partNo++; // skip preview
                        partNo++; // skip dimensions part
                    } catch (JSONException e) {
                        Log.e(TAG, "cellForMessage() cannot parse 3-part image", e);
                    }
                } else {
                    // regular image
                    destination.add(new ImageCell(part));
                    if (debug)
                        Log.w(TAG, "cellForMessage() single-image part found at partNo: " + partNo);
                }

            } else if (Atlas.MIME_TYPE_ATLAS_LOCATION.equals(part.getMimeType())) {
                destination.add(new GeoCell(part));
            } else {
                Cell cellData = new TextCell(part);
                if (false && debug)
                    Log.w(TAG, "cellForMessage() default item: " + cellData);
                destination.add(cellData);
            }
        }

    }

    public void updateValues() {
        if (conv == null)
            return;

        long started = System.currentTimeMillis();

        List<Message> messages = client.getMessages(conv);
        cells.clear();
        if (messages.isEmpty())
            return;

        latestReadMessage = null;
        latestDeliveredMessage = null;

        ArrayList<Cell> messageItems = new ArrayList<AtlasMessagesList.Cell>();
        for (Message message : messages) {
            // System messages have `null` user ID
            if (message.getSender().getUserId() == null)
                continue;

            messageItems.clear();
            buildCellForMessage(message, messageItems);
            cells.addAll(messageItems);
        }

        updateDeliveryStatus(messages);

        // calculate heads/tails
        int currentItem = 0;
        int clusterId = currentItem;
        String currentUser = null;
        long lastMessageTime = 0;
        Calendar calLastMessage = Calendar.getInstance();
        Calendar calCurrent = Calendar.getInstance();
        long clusterTimeSpan = 60 * 1000; // 1 minute
        long oneHourSpan = 60 * 60 * 1000; // 1 hour
        for (int i = 0; i < cells.size(); i++) {
            Cell item = cells.get(i);
            boolean newCluster = false;
            if (!item.messagePart.getMessage().getSender().getUserId().equals(currentUser)) {
                newCluster = true;
            }
            Date sentAt = item.messagePart.getMessage().getSentAt();
            if (sentAt == null)
                sentAt = new Date();

            if (sentAt.getTime() - lastMessageTime > clusterTimeSpan) {
                newCluster = true;
            }

            if (newCluster) {
                clusterId = currentItem;
                if (i > 0)
                    cells.get(i - 1).clusterTail = true;
            }

            // check time header is needed
            if (sentAt.getTime() - lastMessageTime > oneHourSpan) {
                item.timeHeader = true;
            }
            calCurrent.setTime(sentAt);
            if (calCurrent.get(Calendar.DAY_OF_YEAR) != calLastMessage.get(Calendar.DAY_OF_YEAR)) {
                item.timeHeader = true;
            }

            item.clusterHeadItemId = clusterId;
            item.clusterItemId = currentItem++;

            currentUser = item.messagePart.getMessage().getSender().getUserId();
            lastMessageTime = sentAt.getTime();
            calLastMessage.setTime(sentAt);
            if (false && debug)
                Log.d(TAG, "updateValues() item: " + item);
        }
        cells.get(cells.size() - 1).clusterTail = true; // last one is always a tail

        if (debug)
            Log.d(TAG, "updateValues() parts finished in: " + (System.currentTimeMillis() - started));
        messagesAdapter.notifyDataSetChanged();

    }

    private boolean updateDeliveryStatus(List<Message> messages) {
        if (debug)
            Log.w(TAG, "updateDeliveryStatus() checking messages:   " + messages.size());
        Message oldLatestDeliveredMessage = latestDeliveredMessage;
        Message oldLatestReadMessage = latestReadMessage;
        // reset before scan
        latestDeliveredMessage = null;
        latestReadMessage = null;

        for (Message message : messages) {
            // only our messages
            if (client.getAuthenticatedUserId().equals(message.getSender().getUserId())) {
                if (!message.isSent())
                    continue;
                Map<String, RecipientStatus> statuses = message.getRecipientStatus();
                if (statuses == null || statuses.size() == 0)
                    continue;
                for (Map.Entry<String, RecipientStatus> entry : statuses.entrySet()) {
                    // our read-status doesn't matter 
                    if (entry.getKey().equals(client.getAuthenticatedUserId()))
                        continue;

                    if (entry.getValue() == RecipientStatus.READ) {
                        latestDeliveredMessage = message;
                        latestReadMessage = message;
                        break;
                    }
                    if (entry.getValue() == RecipientStatus.DELIVERED) {
                        latestDeliveredMessage = message;
                    }
                }
            }
        }
        boolean changed = false;
        if (oldLatestDeliveredMessage == null && latestDeliveredMessage != null)
            changed = true;
        else if (oldLatestDeliveredMessage != null && latestDeliveredMessage == null)
            changed = true;
        else if (oldLatestDeliveredMessage != null && latestDeliveredMessage != null
                && !oldLatestDeliveredMessage.getId().equals(latestDeliveredMessage.getId()))
            changed = true;

        if (oldLatestReadMessage == null && latestReadMessage != null)
            changed = true;
        else if (oldLatestReadMessage != null && latestReadMessage == null)
            changed = true;
        else if (oldLatestReadMessage != null && latestReadMessage != null
                && !oldLatestReadMessage.getId().equals(latestReadMessage.getId()))
            changed = true;

        if (debug)
            Log.w(TAG, "updateDeliveryStatus() read status changed: " + (changed ? "yes" : "no"));
        if (debug)
            Log.w(TAG,
                    "updateDeliveryStatus() latestRead:          " + (latestReadMessage != null
                            ? latestReadMessage.getSentAt() + ", id: " + latestReadMessage.getId()
                            : "null"));
        if (debug)
            Log.w(TAG,
                    "updateDeliveryStatus() latestDelivered:     " + (latestDeliveredMessage != null
                            ? latestDeliveredMessage.getSentAt() + ", id: " + latestDeliveredMessage.getId()
                            : "null"));

        return changed;
    }

    private long messageUpdateSentAt = 0;

    private final Handler refreshHandler = new Handler() {
        public void handleMessage(android.os.Message msg) {
            long started = System.currentTimeMillis();
            if (msg.what == MESSAGE_TYPE_UPDATE_VALUES) {
                if (msg.arg1 == MESSAGE_REFRESH_UPDATE_ALL) {
                    updateValues();
                } else if (msg.arg1 == MESSAGE_REFRESH_UPDATE_DELIVERY) {
                    LayerClient client = (LayerClient) msg.obj;
                    boolean changed = updateDeliveryStatus(client.getMessages(conv));
                    if (changed)
                        messagesAdapter.notifyDataSetInvalidated();
                    if (debug)
                        Log.w(TAG, "refreshHandler() delivery status changed: " + changed);
                }
                if (msg.arg2 > 0) {
                    messagesList.smoothScrollToPosition(messagesAdapter.getCount() - 1);
                }
            }
            final long currentTimeMillis = System.currentTimeMillis();
            if (debug)
                Log.w(TAG, "handleMessage() delay: " + (currentTimeMillis - messageUpdateSentAt)
                        + "ms, handled in: " + (currentTimeMillis - started) + "ms");
            messageUpdateSentAt = 0;
        }

    };

    @Override
    public void onEventMainThread(LayerChangeEvent event) {
        if (conv == null)
            return;
        boolean updateValues = false;
        boolean jumpToBottom = false;
        boolean updateDeliveryStatus = false;
        for (LayerChange change : event.getChanges()) {
            if (change.getObjectType() == LayerObject.Type.MESSAGE) {
                Message msg = (Message) change.getObject();
                if (msg.getConversation().getId().equals(conv.getId())) {
                    updateValues = true;
                    if (change.getChangeType() == Type.DELETE || change.getChangeType() == Type.INSERT) {
                        jumpToBottom = true;
                    }
                }
            }
            if (change.getChangeType() == Type.UPDATE && "recipientStatus".equals(change.getAttributeName())) {
                updateDeliveryStatus = true;
            }
        }

        if (updateValues || updateDeliveryStatus) {
            if (messageUpdateSentAt == 0)
                messageUpdateSentAt = System.currentTimeMillis();
            refreshHandler.removeMessages(MESSAGE_TYPE_UPDATE_VALUES);
            final android.os.Message message = refreshHandler.obtainMessage();
            message.arg1 = updateValues ? MESSAGE_REFRESH_UPDATE_ALL : MESSAGE_REFRESH_UPDATE_DELIVERY;
            message.arg2 = jumpToBottom ? 1 : 0;
            message.obj = event.getClient();
            refreshHandler.sendMessage(message);
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();

        if (debug)
            Log.d(TAG, "onDetachedFromWindow() clean cells and views... ");
        cells.clear();
        messagesAdapter.notifyDataSetChanged();
        messagesList.removeAllViewsInLayout();
    }

    public void jumpToLastMessage() {
        messagesList.smoothScrollToPosition(cells.size() - 1);
    }

    public Conversation getConversation() {
        return conv;
    }

    public void setConversation(Conversation conv) {
        this.conv = conv;
        updateValues();
        jumpToLastMessage();
    }

    public void setItemClickListener(ItemClickListener clickListener) {
        this.clickListener = clickListener;
    }

    private class GeoCell extends Cell implements DownloadQueue.CompleteListener {

        double lon;
        double lat;

        ImageSpec spec;

        public GeoCell(MessagePart messagePart) {
            super(messagePart);
            String jsonLonLat = new String(messagePart.getData());
            try {
                JSONObject json = new JSONObject(jsonLonLat);
                this.lon = json.getDouble("lon");
                this.lat = json.getDouble("lat");
            } catch (JSONException e) {
                throw new IllegalArgumentException("Wrong geoJSON format: " + jsonLonLat, e);
            }
        }

        @Override
        public View onBind(final ViewGroup cellContainer) {

            ViewGroup cellRoot = (ViewGroup) Tools.findChildById(cellContainer, R.id.atlas_view_messages_cell_geo);
            if (cellRoot == null) {
                cellRoot = (ViewGroup) LayoutInflater.from(cellContainer.getContext())
                        .inflate(R.layout.atlas_view_messages_cell_geo, cellContainer, false);
                if (debug)
                    Log.w(TAG, "geo.onBind() inflated geo cell");
            }

            ImageView geoImageMy = (ImageView) cellRoot.findViewById(R.id.atlas_view_messages_cell_geo_image_my);
            ImageView geoImageTheir = (ImageView) cellRoot
                    .findViewById(R.id.atlas_view_messages_cell_geo_image_their);
            View containerMy = cellRoot.findViewById(R.id.atlas_view_messages_cell_geo_container_my);
            View containerTheir = cellRoot.findViewById(R.id.atlas_view_messages_cell_geo_container_their);

            boolean myMessage = client.getAuthenticatedUserId()
                    .equals(messagePart.getMessage().getSender().getUserId());
            if (myMessage) {
                containerMy.setVisibility(View.VISIBLE);
                containerTheir.setVisibility(View.GONE);
            } else {
                containerMy.setVisibility(View.GONE);
                containerTheir.setVisibility(View.VISIBLE);
            }
            ImageView geoImage = myMessage ? geoImageMy : geoImageTheir;
            ShapedFrameLayout cellCustom = (ShapedFrameLayout) (myMessage ? containerMy : containerTheir);

            Object imageId = messagePart.getId();
            Bitmap bmp = imageLoader.getBitmapFromCache(imageId);
            if (bmp != null) {
                if (debug)
                    Log.w(TAG, "geo.onBind() bitmap: " + bmp.getWidth() + "x" + bmp.getHeight());
                geoImage.setImageBitmap(bmp);
            } else {
                if (debug)
                    Log.w(TAG, "geo.onBind() spec: " + spec);
                geoImage.setImageDrawable(EMPTY_DRAWABLE);
                // schedule image
                File tileFile = getTileFile();
                if (tileFile.exists()) {
                    if (debug)
                        Log.w(TAG, "geo.onBind() decodeImage: " + tileFile);
                    // request decoding
                    spec = imageLoader.requestBitmap(imageId, new ImageLoader.FileStreamProvider(tileFile),
                            (int) Tools.getPxFromDp(150, getContext()), (int) Tools.getPxFromDp(150, getContext()),
                            BITMAP_LOAD_LISTENER);
                } else {
                    int width = 300;
                    int height = 300;
                    int zoom = 16;
                    final String url = new StringBuilder().append("https://maps.googleapis.com/maps/api/staticmap?")
                            .append("format=png32&").append("center=").append(lat).append(",").append(lon)
                            .append("&").append("zoom=").append(zoom).append("&").append("size=").append(width)
                            .append("x").append(height).append("&").append("maptype=roadmap&")
                            .append("markers=color:red%7C").append(lat).append(",").append(lon).toString();

                    downloadQueue.schedule(url, tileFile, this);

                    if (debug)
                        Log.w(TAG, "geo.onBind() show as text and download image: " + tileFile);
                }
            }

            Cell cell = this;
            // clustering
            cellCustom.setCornerRadiusDp(16, 16, 16, 16);
            if (CLUSTERED_BUBBLES) {
                if (myMessage) {
                    if (cell.clusterHeadItemId == cell.clusterItemId && !cell.clusterTail) {
                        cellCustom.setCornerRadiusDp(16, 16, 2, 16);
                    } else if (cell.clusterTail && cell.clusterHeadItemId != cell.clusterItemId) {
                        cellCustom.setCornerRadiusDp(16, 2, 16, 16);
                    } else if (cell.clusterHeadItemId != cell.clusterItemId && !cell.clusterTail) {
                        cellCustom.setCornerRadiusDp(16, 2, 2, 16);
                    }
                } else {
                    if (cell.clusterHeadItemId == cell.clusterItemId && !cell.clusterTail) {
                        cellCustom.setCornerRadiusDp(16, 16, 16, 2);
                    } else if (cell.clusterTail && cell.clusterHeadItemId != cell.clusterItemId) {
                        cellCustom.setCornerRadiusDp(2, 16, 16, 16);
                    } else if (cell.clusterHeadItemId != cell.clusterItemId && !cell.clusterTail) {
                        cellCustom.setCornerRadiusDp(2, 16, 16, 2);
                    }
                }
            }

            return cellRoot;
        }

        private File getTileFile() {
            String fileDir = getContext().getCacheDir() + File.separator + "geo";
            String fileName = String.format("%f_%f.png", lat, lon);
            return new File(fileDir, fileName);
        }

        @Override
        public String toString() {
            final String text = "Location:\nlon: " + lon + "\nlat: " + lat;
            return text + " part: " + super.toString();
        }

        @Override
        public void onDownloadComplete(String url, final File file) {
            postViewRefresh();
        }
    }

    private static boolean downloadToFile(String url, File file) {
        HttpGet get = new HttpGet(url);
        HttpResponse response;
        try {
            response = (new DefaultHttpClient()).execute(get);
            if (HttpStatus.SC_OK != response.getStatusLine().getStatusCode()) {
                Log.e(TAG,
                        String.format("Expected status 200, but got %d", response.getStatusLine().getStatusCode()));
                return false;
            }
        } catch (Exception e) {
            Log.e(TAG, "downloadToFile() cannot execute http request: " + url, e);
            return false;
        }

        File dir = file.getParentFile();
        if (!dir.exists() && !dir.mkdirs()) {
            Log.e(TAG, String.format("Could not create directories for `%s`", dir.getAbsolutePath()));
            return false;
        }

        File tempFile = new File(file.getAbsolutePath() + ".tmp");

        try {
            Tools.streamCopyAndClose(response.getEntity().getContent(), new FileOutputStream(tempFile, false));
            response.getEntity().consumeContent();
        } catch (IOException e) {
            if (debug)
                Log.e(TAG, "downloadToFile() cannot extract content from http response: " + url, e);
        }

        if (tempFile.length() != response.getEntity().getContentLength()) {
            tempFile.delete();
            Log.e(TAG, String.format("downloadToFile() File size mismatch for `%s` (%d vs %d)",
                    tempFile.getAbsolutePath(), tempFile.length(), response.getEntity().getContentLength()));
            return false;
        }

        // last step
        if (tempFile.renameTo(file)) {
            if (debug)
                Log.w(TAG, "downloadToFile() Successfully downloaded file: " + file.getAbsolutePath());
            return true;
        } else {
            Log.e(TAG, "downloadToFile() Could not rename temp file: " + tempFile.getAbsolutePath() + " to: "
                    + file.getAbsolutePath());
            return false;
        }

    }

    private static class DownloadQueue {
        private static final String TAG = DownloadQueue.class.getSimpleName();

        final ArrayList<Entry> queue = new ArrayList<AtlasMessagesList.DownloadQueue.Entry>();
        final HashMap<String, Entry> url2Entry = new HashMap<String, Entry>();
        private volatile Entry inProgress = null;

        public DownloadQueue() {
            workingThread.setDaemon(true);
            workingThread.setName("Atlas-HttpDownloadQueue");
            workingThread.start();
        }

        public void schedule(String url, File to, CompleteListener onComplete) {
            if (inProgress != null && inProgress.url.equals(url)) {
                return;
            }
            synchronized (queue) {
                Entry existing = url2Entry.get(url);
                if (existing != null) {
                    queue.remove(existing);
                    queue.add(existing);
                } else {
                    Entry toSchedule = new Entry(url, to, onComplete);
                    queue.add(toSchedule);
                    url2Entry.put(toSchedule.url, toSchedule);
                }
                queue.notifyAll();
            }
        }

        private Thread workingThread = new Thread(new Runnable() {
            public void run() {
                while (true) {
                    Entry next = null;
                    synchronized (queue) {
                        while (queue.size() == 0) {
                            try {
                                queue.wait();
                            } catch (InterruptedException ignored) {
                            }
                        }
                        next = queue.remove(queue.size() - 1); // get last
                        url2Entry.remove(next.url);
                        inProgress = next;
                    }
                    try {
                        if (downloadToFile(next.url, next.file)) {
                            if (next.completeListener != null) {
                                next.completeListener.onDownloadComplete(next.url, next.file);
                            }
                        }
                        ;
                    } catch (Throwable e) {
                        Log.e(TAG, "onComplete() thrown an exception for: " + next.url, e);
                    }
                    inProgress = null;
                }
            }
        });

        private static class Entry {
            String url;
            File file;
            CompleteListener completeListener;

            public Entry(String url, File file, CompleteListener listener) {
                if (url == null)
                    throw new IllegalArgumentException("url cannot be null");
                if (file == null)
                    throw new IllegalArgumentException("file cannot be null");
                this.url = url;
                this.file = file;
                this.completeListener = listener;
            }
        }

        public interface CompleteListener {
            public void onDownloadComplete(String url, File file);
        }
    }

    private class TextCell extends Cell {

        protected String text;

        public TextCell(MessagePart messagePart) {
            super(messagePart);
        }

        public TextCell(MessagePart messagePart, String text) {
            super(messagePart);
            this.text = text;
        }

        public View onBind(ViewGroup cellContainer) {
            MessagePart part = messagePart;
            Cell cell = this;

            View cellText = Tools.findChildById(cellContainer, R.id.atlas_view_messages_cell_text);
            if (cellText == null) {
                cellText = LayoutInflater.from(cellContainer.getContext())
                        .inflate(R.layout.atlas_view_messages_cell_text, cellContainer, false);
            }

            if (text == null) {
                if (Atlas.MIME_TYPE_TEXT.equals(part.getMimeType())) {
                    text = new String(part.getData());
                } else {
                    text = "attach, type: " + part.getMimeType() + ", size: " + part.getSize();
                }
            }

            boolean myMessage = client.getAuthenticatedUserId()
                    .equals(cell.messagePart.getMessage().getSender().getUserId());
            TextView textMy = (TextView) cellText.findViewById(R.id.atlas_view_messages_convert_text);
            TextView textOther = (TextView) cellText
                    .findViewById(R.id.atlas_view_messages_convert_text_counterparty);
            if (myMessage) {
                textMy.setVisibility(View.VISIBLE);
                textMy.setText(text);
                textOther.setVisibility(View.GONE);

                textMy.setBackgroundResource(R.drawable.atlas_shape_rounded16_blue);

                if (CLUSTERED_BUBBLES) {
                    if (cell.clusterHeadItemId == cell.clusterItemId && !cell.clusterTail) {
                        textMy.setBackgroundResource(R.drawable.atlas_shape_rounded16_blue_no_bottom_right);
                    } else if (cell.clusterTail && cell.clusterHeadItemId != cell.clusterItemId) {
                        textMy.setBackgroundResource(R.drawable.atlas_shape_rounded16_blue_no_top_right);
                    } else if (cell.clusterHeadItemId != cell.clusterItemId && !cell.clusterTail) {
                        textMy.setBackgroundResource(R.drawable.atlas_shape_rounded16_blue_no_right);
                    }
                }
                ((GradientDrawable) textMy.getBackground()).setColor(myBubbleColor);
                textMy.setTextColor(myTextColor);
                //textMy.setTextSize(TypedValue.COMPLEX_UNIT_DIP, myTextSize);
                textMy.setTypeface(myTextTypeface, myTextStyle);
            } else {
                textOther.setVisibility(View.VISIBLE);
                textOther.setText(text);
                textMy.setVisibility(View.GONE);

                textOther.setBackgroundResource(R.drawable.atlas_shape_rounded16_gray);
                if (CLUSTERED_BUBBLES) {
                    if (cell.clusterHeadItemId == cell.clusterItemId && !cell.clusterTail) {
                        textOther.setBackgroundResource(R.drawable.atlas_shape_rounded16_gray_no_bottom_left);
                    } else if (cell.clusterTail && cell.clusterHeadItemId != cell.clusterItemId) {
                        textOther.setBackgroundResource(R.drawable.atlas_shape_rounded16_gray_no_top_left);
                    } else if (cell.clusterHeadItemId != cell.clusterItemId && !cell.clusterTail) {
                        textOther.setBackgroundResource(R.drawable.atlas_shape_rounded16_gray_no_left);
                    }
                }
                ((GradientDrawable) textOther.getBackground()).setColor(otherBubbleColor);
                textOther.setTextColor(otherTextColor);
                //textOther.setTextSize(TypedValue.COMPLEX_UNIT_DIP, otherTextSize);
                textOther.setTypeface(otherTextTypeface, otherTextStyle);
            }
            return cellText;
        }
    }

    private static final BitmapDrawable EMPTY_DRAWABLE = new BitmapDrawable(
            Bitmap.createBitmap(new int[] { Color.TRANSPARENT }, 1, 1, Bitmap.Config.ALPHA_8));

    private class ImageCell extends Cell implements LayerProgressListener {
        MessagePart previewPart;
        MessagePart fullPart;
        int width;
        int height;
        ImageLoader.ImageSpec imageSpec;

        private ImageCell(MessagePart fullImagePart) {
            super(fullImagePart);
            this.fullPart = fullImagePart;
        }

        private ImageCell(MessagePart fullImagePart, MessagePart previewImagePart, int width, int height) {
            super(fullImagePart);
            this.fullPart = fullImagePart;
            this.previewPart = previewImagePart;
            this.width = width;
            this.height = height;
        }

        @Override
        public View onBind(final ViewGroup cellContainer) {
            View rootView = Tools.findChildById(cellContainer, R.id.atlas_view_messages_cell_image);
            if (rootView == null) {
                rootView = LayoutInflater.from(cellContainer.getContext())
                        .inflate(R.layout.atlas_view_messages_cell_image, cellContainer, false);
            }

            Cell cell = this;
            boolean myMessage = client.getAuthenticatedUserId()
                    .equals(cell.messagePart.getMessage().getSender().getUserId());

            View imageContainerMy = rootView.findViewById(R.id.atlas_view_messages_cell_image_container_my);
            View imageContainerOther = rootView.findViewById(R.id.atlas_view_messages_cell_image_container_their);
            ImageView imageViewMy = (ImageView) imageContainerMy
                    .findViewById(R.id.atlas_view_messages_cell_image_my);
            ImageView imageViewOther = (ImageView) imageContainerOther
                    .findViewById(R.id.atlas_view_messages_cell_image_their);
            ImageView imageView = myMessage ? imageViewMy : imageViewOther;
            View imageContainer = myMessage ? imageContainerMy : imageContainerOther;

            if (myMessage) {
                imageContainerMy.setVisibility(View.VISIBLE);
                imageContainerOther.setVisibility(View.GONE);
            } else {
                imageContainerMy.setVisibility(View.GONE);
                imageContainerOther.setVisibility(View.VISIBLE);
            }

            // get BitmapDrawable

            int requiredWidth = /*imageContainer.getWidth() > 0 ? imageContainer.getWidth() :*/ messagesList
                    .getWidth();
            int requiredHeight = /*imageContainer.getHeight() > 0 ? imageContainer.getHeight() : */messagesList
                    .getHeight();
            MessagePart workingPart = previewPart != null ? previewPart : fullPart;
            Bitmap bmp = imageLoader.getBitmapFromCache(workingPart.getId());

            //adjust width/height
            int width = this.width;
            int height = this.height;
            if ((width == 0 || height == 0) && imageSpec != null && imageSpec.originalWidth != 0) {
                if (debug)
                    Log.w(TAG, "img.onBind() size from spec:   " + imageSpec.originalWidth + "x"
                            + imageSpec.originalHeight);
                width = imageSpec.originalWidth;
                height = imageSpec.originalHeight;
            }
            if ((width == 0 || height == 0) && bmp != null) {
                width = bmp.getWidth();
                height = bmp.getHeight();
                if (debug)
                    Log.w(TAG, "img.onBind() size from bitmap: " + bmp.getWidth() + "x" + bmp.getHeight());
            }

            int viewWidth = (int) (width != 0 ? width : Tools.getPxFromDp(48 * 4, imageContainer.getContext()));
            int viewHeight = (int) (height != 0 ? height : Tools.getPxFromDp(48 * 4, imageContainer.getContext()));
            int widthToFit = 0;
            imageContainer.getWidth();
            if (widthToFit == 0)
                widthToFit = cellContainer.getWidth();
            if (widthToFit == 0)
                widthToFit = messagesList.getWidth();
            if (viewWidth > widthToFit) {
                viewHeight = (int) (1.0 * viewHeight * widthToFit / viewWidth);
                viewWidth = widthToFit;
            }
            if (viewHeight > messagesList.getHeight() && messagesList.getHeight() > 0) {
                viewWidth = (int) (1.0 * viewWidth * messagesList.getHeight() / viewHeight);
                viewHeight = messagesList.getHeight();
            }
            if (debug)
                Log.w(TAG,
                        "img.onBind() view size: " + viewWidth + "x" + viewHeight + ", container: "
                                + (myMessage ? "my " : "their ") + imageContainer.getWidth() + "x"
                                + imageContainer.getHeight() + ", cell: " + cellContainer.getWidth() + "x"
                                + cellContainer.getHeight() + ", image: " + width + "x" + height);

            imageView.getLayoutParams().width = viewWidth;
            imageView.getLayoutParams().height = viewHeight;

            if (bmp != null) {
                imageView.setImageBitmap(bmp);
                if (debug)
                    Log.i(TAG,
                            "img.onBind() returned from cache! " + bmp.getWidth() + "x" + bmp.getHeight() + " "
                                    + bmp.getByteCount() + " bytes" + " req: " + requiredWidth + "x"
                                    + requiredHeight + " for " + messagePart.getId());
            } else {
                imageView.setImageDrawable(EMPTY_DRAWABLE);
                final Uri id = workingPart.getId();
                final MessagePartStreamProvider streamProvider = new MessagePartStreamProvider(workingPart);
                if (workingPart.isContentReady()) {
                    imageSpec = imageLoader.requestBitmap(id, streamProvider, requiredWidth, requiredHeight,
                            BITMAP_LOAD_LISTENER);
                } else {
                    workingPart.download(this);
                }
            }

            ShapedFrameLayout cellCustom = (ShapedFrameLayout) (myMessage ? imageContainerMy : imageContainerOther);
            // clustering
            cellCustom.setCornerRadiusDp(16, 16, 16, 16);
            if (!CLUSTERED_BUBBLES)
                return rootView;
            if (myMessage) {
                if (cell.clusterHeadItemId == cell.clusterItemId && !cell.clusterTail) {
                    cellCustom.setCornerRadiusDp(16, 16, 2, 16);
                } else if (cell.clusterTail && cell.clusterHeadItemId != cell.clusterItemId) {
                    cellCustom.setCornerRadiusDp(16, 2, 16, 16);
                } else if (cell.clusterHeadItemId != cell.clusterItemId && !cell.clusterTail) {
                    cellCustom.setCornerRadiusDp(16, 2, 2, 16);
                }
            } else {
                if (cell.clusterHeadItemId == cell.clusterItemId && !cell.clusterTail) {
                    cellCustom.setCornerRadiusDp(16, 16, 16, 2);
                } else if (cell.clusterTail && cell.clusterHeadItemId != cell.clusterItemId) {
                    cellCustom.setCornerRadiusDp(2, 16, 16, 16);
                } else if (cell.clusterHeadItemId != cell.clusterItemId && !cell.clusterTail) {
                    cellCustom.setCornerRadiusDp(2, 16, 16, 2);
                }
            }
            return rootView;
        }

        // LayerDownloadListener (when downloading part)
        public void onProgressStart(MessagePart part, Operation operation) {
        }

        public void onProgressUpdate(MessagePart part, Operation operation, long transferredBytes) {
        }

        public void onProgressError(MessagePart part, Operation operation, Throwable cause) {
        }

        public void onProgressComplete(MessagePart part, Operation operation) {
            postViewRefresh();
        }
    }

    private void postViewRefresh() {
        messagesList.post(INVALIDATE_VIEW);
    }

    private final Runnable INVALIDATE_VIEW = new Runnable() {
        public void run() {
            messagesList.invalidateViews();
        }
    };

    private final BitmapLoadListener BITMAP_LOAD_LISTENER = new ImageLoader.BitmapLoadListener() {
        public void onBitmapLoaded(ImageSpec spec) {
            postViewRefresh();
        }
    };

    private static final Atlas.ImageLoader imageLoader = new Atlas.ImageLoader();
    private static final DownloadQueue downloadQueue = new DownloadQueue();

    public abstract class Cell {
        public final MessagePart messagePart;
        private int clusterHeadItemId;
        private int clusterItemId;
        private boolean clusterTail;
        private boolean timeHeader;

        public Cell(MessagePart messagePart) {
            this.messagePart = messagePart;
        }

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append("[ ").append("messagePart: ").append(messagePart.getMimeType()).append(": ")
                    .append(messagePart.getSize() < 2048 ? new String(messagePart.getData())
                            : messagePart.getSize() + " bytes")
                    .append(", clusterId: ").append(clusterHeadItemId).append(", clusterItem: ")
                    .append(clusterItemId).append(", clusterTail: ").append(clusterTail).append(", timeHeader: ")
                    .append(timeHeader).append(" ]");
            return builder.toString();
        }

        public abstract View onBind(ViewGroup cellContainer);
    }

    public interface ItemClickListener {
        void onItemClick(Cell item);
    }

}