diff --git a/res/layout/chat.xml b/res/layout/chat.xml index 0a04dfe8..8b8ea719 100644 --- a/res/layout/chat.xml +++ b/res/layout/chat.xml @@ -39,8 +39,9 @@ diff --git a/res/layout/chat_item.xml b/res/layout/chat_item.xml index faaa2d64..3dad7870 100644 --- a/res/layout/chat_item.xml +++ b/res/layout/chat_item.xml @@ -1,48 +1,66 @@ - + - + + a:orientation="vertical" + a:layout_toEndOf="@id/chat_avatar" + a:gravity="center_vertical"> - - - + a:ellipsize="marquee" + a:singleLine="true" + a:textIsSelectable="true" + a:textAppearance="?android:attr/textAppearanceMedium" + a:textStyle="bold" + a:layout_gravity="left" + a:gravity="center_vertical|left"/> - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/res/layout/chat_item_reverse.xml b/res/layout/chat_item_reverse.xml index c6206286..39ca0b0f 100644 --- a/res/layout/chat_item_reverse.xml +++ b/res/layout/chat_item_reverse.xml @@ -1,51 +1,71 @@ - - - + + a:orientation="vertical" + a:layout_gravity="right" + a:layout_alignParentEnd="false" + a:layout_toStartOf="@id/chat_avatar" + a:gravity="center_vertical|right"> - - + a:gravity="center_vertical|right" + a:layout_gravity="right" + a:ellipsize="marquee" + a:singleLine="true" + a:textIsSelectable="true" + a:textAppearance="?android:attr/textAppearanceMedium" + a:textStyle="bold"/> + + + + + + + - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/com/thejoshwa/ultrasonic/androidapp/domain/ChatMessage.java b/src/com/thejoshwa/ultrasonic/androidapp/domain/ChatMessage.java index e5b0adfc..258e2119 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/domain/ChatMessage.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/domain/ChatMessage.java @@ -41,4 +41,24 @@ public class ChatMessage implements Serializable { this.message = message; } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ChatMessage that = (ChatMessage) o; + + return message.equals(that.message) && time.equals(that.time) && username.equals(that.username); + } + + @Override + public int hashCode() + { + int result = username.hashCode(); + result = 31 * result + time.hashCode(); + result = 31 * result + message.hashCode(); + return result; + } } diff --git a/src/com/thejoshwa/ultrasonic/androidapp/service/CachedMusicService.java b/src/com/thejoshwa/ultrasonic/androidapp/service/CachedMusicService.java index f7884562..1a700357 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/service/CachedMusicService.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/service/CachedMusicService.java @@ -526,4 +526,10 @@ public class CachedMusicService implements MusicService { musicService.updateShare(id, description, expires, context, progressListener); } + + @Override + public Bitmap getAvatar(Context context, String username, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception + { + return musicService.getAvatar(context, username, size, saveToFile, highQuality, progressListener); + } } diff --git a/src/com/thejoshwa/ultrasonic/androidapp/service/MusicService.java b/src/com/thejoshwa/ultrasonic/androidapp/service/MusicService.java index f963e8cb..a082a855 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/service/MusicService.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/service/MusicService.java @@ -147,4 +147,6 @@ public interface MusicService void deleteShare(String id, Context context, ProgressListener progressListener) throws Exception; void updateShare(String id, String description, Long expires, Context context, ProgressListener progressListener) throws Exception; + + Bitmap getAvatar(Context context, String username, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception; } \ No newline at end of file diff --git a/src/com/thejoshwa/ultrasonic/androidapp/service/OfflineMusicService.java b/src/com/thejoshwa/ultrasonic/androidapp/service/OfflineMusicService.java index 24de3c4d..1c89e2bf 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/service/OfflineMusicService.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/service/OfflineMusicService.java @@ -328,6 +328,20 @@ public class OfflineMusicService extends RESTMusicService return entry; } + @Override + public Bitmap getAvatar(Context context, String username, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception + { + try + { + Bitmap bitmap = FileUtil.getAvatarBitmap(username, size, highQuality); + return Util.scaleBitmap(bitmap, size); + } + catch (Exception e) + { + return null; + } + } + @Override public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception { diff --git a/src/com/thejoshwa/ultrasonic/androidapp/service/RESTMusicService.java b/src/com/thejoshwa/ultrasonic/androidapp/service/RESTMusicService.java index cb08e549..11935dc3 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/service/RESTMusicService.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/service/RESTMusicService.java @@ -872,6 +872,13 @@ public class RESTMusicService implements MusicService } } + private static boolean checkServerVersion(Context context, String version) + { + Version serverVersion = Util.getServerRestVersion(context); + Version requiredVersion = new Version(version); + return serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0; + } + @Override public Bitmap getCoverArt(Context context, final MusicDirectory.Entry entry, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception { @@ -1659,4 +1666,79 @@ public class RESTMusicService implements MusicService Util.close(reader); } } + + @Override + public Bitmap getAvatar(Context context, String username, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception + { + // Return silently if server is too old + if (!checkServerVersion(context, "1.8")) + return null; + + // Synchronize on the username so that we don't download concurrently for + // the same user. + if (username == null) + { + return null; + } + + synchronized (username) + { + // Use cached file, if existing. + Bitmap bitmap = FileUtil.getAvatarBitmap(username, size, highQuality); + + if (bitmap == null) + { + String url = Util.getRestUrl(context, "getAvatar"); + + InputStream in = null; + + try + { + List parameterNames; + List parameterValues; + + parameterNames = Collections.singletonList("username"); + parameterValues = Arrays.asList(username); + + HttpEntity entity = getEntityForURL(context, url, null, parameterNames, parameterValues, progressListener); + in = entity.getContent(); + + // If content type is XML, an error occurred. Get it. + String contentType = Util.getContentType(entity); + if (contentType != null && contentType.startsWith("text/xml")) + { + new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8)); + return null; // Never reached. + } + + byte[] bytes = Util.toByteArray(in); + + // If we aren't allowing server-side scaling, always save the file to disk because it will be unmodified + if (saveToFile) + { + OutputStream out = null; + + try + { + out = new FileOutputStream(FileUtil.getAvatarFile(username)); + out.write(bytes); + } + finally + { + Util.close(out); + } + } + + bitmap = FileUtil.getSampledBitmap(bytes, size, highQuality); + } + finally + { + Util.close(in); + } + } + + // Return scaled bitmap + return Util.scaleBitmap(bitmap, size); + } + } } diff --git a/src/com/thejoshwa/ultrasonic/androidapp/util/FileUtil.java b/src/com/thejoshwa/ultrasonic/androidapp/util/FileUtil.java index d4d9c159..1e8f9b03 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/util/FileUtil.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/util/FileUtil.java @@ -111,6 +111,19 @@ public class FileUtil return getAlbumArtFile(albumDir); } + public static File getAvatarFile(String username) + { + File albumArtDir = getAlbumArtDirectory(); + + if (albumArtDir == null || username == null) + { + return null; + } + + String md5Hex = Util.md5Hex(username); + return new File(albumArtDir, String.format("%s.jpeg", md5Hex)); + } + public static File getAlbumArtFile(File albumDir) { File albumArtDir = getAlbumArtDirectory(); @@ -124,6 +137,76 @@ public class FileUtil return new File(albumArtDir, String.format("%s.jpeg", md5Hex)); } + public static Bitmap getAvatarBitmap(String username, int size, boolean highQuality) + { + if (username == null) return null; + + File avatarFile = getAvatarFile(username); + + SubsonicTabActivity subsonicTabActivity = SubsonicTabActivity.getInstance(); + Bitmap bitmap = null; + ImageLoader imageLoader = null; + + if (subsonicTabActivity != null) + { + imageLoader = subsonicTabActivity.getImageLoader(); + + if (imageLoader != null) + { + bitmap = imageLoader.getImageBitmap(username, size); + } + } + + if (bitmap != null) + { + return bitmap.copy(bitmap.getConfig(), false); + } + + if (avatarFile != null && avatarFile.exists()) + { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + + if (size > 0) + { + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(avatarFile.getPath(), opt); + + if (highQuality) + { + opt.inDither = true; + opt.inPreferQualityOverSpeed = true; + } + + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + } + + try + { + bitmap = BitmapFactory.decodeFile(avatarFile.getPath(), opt); + } + catch (Exception ex) + { + Log.e(TAG, "Exception in BitmapFactory.decodeFile()", ex); + } + + Log.i("getAvatarBitmap", String.valueOf(size)); + + if (bitmap != null) + { + if (imageLoader != null) + { + imageLoader.addImageToCache(bitmap, username, size); + } + } + + return bitmap == null ? null : bitmap; + } + + return null; + } + public static Bitmap getAlbumArtBitmap(Context context, MusicDirectory.Entry entry, int size, boolean highQuality) { if (entry == null) return null; diff --git a/src/com/thejoshwa/ultrasonic/androidapp/util/ImageLoader.java b/src/com/thejoshwa/ultrasonic/androidapp/util/ImageLoader.java index 961b78a2..1af2aa1e 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/util/ImageLoader.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/util/ImageLoader.java @@ -58,6 +58,7 @@ public class ImageLoader implements Runnable private int imageSizeDefault; private final int imageSizeLarge; private Bitmap largeUnknownImage; + private Bitmap unknownAvatarImage; private Context context; private Collection threads; private AtomicBoolean running = new AtomicBoolean(); @@ -80,6 +81,7 @@ public class ImageLoader implements Runnable imageSizeLarge = Util.getMaxDisplayMetric(context); createLargeUnknownImage(context); + createUnknownAvatarImage(context); } public synchronized boolean isRunning() @@ -130,6 +132,41 @@ public class ImageLoader implements Runnable } } + private void createUnknownAvatarImage(Context context) + { + Resources res = context.getResources(); + Drawable contact = res.getDrawable(R.drawable.ic_contact_picture); + unknownAvatarImage = Util.createBitmapFromDrawable(contact); + } + + public void loadAvatarImage(View view, String username, boolean large, int size, boolean crossFade, boolean highQuality) + { + view.invalidate(); + + if (username == null) + { + setUnknownAvatarImage(view); + return; + } + + if (size <= 0) + { + size = large ? imageSizeLarge : imageSizeDefault; + } + + Bitmap bitmap = cache.get(getKey(username, size)); + + if (bitmap != null) + { + setAvatarImageBitmap(view, username, bitmap, crossFade); + return; + } + + setUnknownAvatarImage(view); + + queue.offer(new Task(view, username, size, large, crossFade, highQuality)); + } + public void loadImage(View view, MusicDirectory.Entry entry, boolean large, int size, boolean crossFade, boolean highQuality) { view.invalidate(); @@ -171,6 +208,19 @@ public class ImageLoader implements Runnable return String.format("%s:%d", coverArtId, size); } + public Bitmap getImageBitmap(String username, int size) + { + Bitmap bitmap = cache.get(getKey(username, size)); + + if (bitmap != null && !bitmap.isRecycled()) + { + Bitmap.Config config = bitmap.getConfig(); + return bitmap.copy(config, false); + } + + return null; + } + public Bitmap getImageBitmap(MusicDirectory.Entry entry, boolean large, int size) { if (entry == null) @@ -224,7 +274,7 @@ public class ImageLoader implements Runnable if (existingDrawable == null) { Bitmap emptyImage = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); - existingDrawable = new BitmapDrawable(emptyImage); + existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); } Drawable[] layers = new Drawable[]{existingDrawable, newDrawable}; @@ -240,6 +290,50 @@ public class ImageLoader implements Runnable } } + private void setAvatarImageBitmap(View view, String username, Bitmap bitmap, boolean crossFade) + { + if (view instanceof ImageView) + { + ImageView imageView = (ImageView) view; + + String tagEntry = (String) view.getTag(); + + // Only apply image to the view if the view is intended for this entry + if (username != null && tagEntry != null && !username.equals(tagEntry)) + { + Log.i(TAG, "View is no longer valid, not setting ImageBitmap"); + return; + } + + if (crossFade) + { + Drawable existingDrawable = imageView.getDrawable(); + Drawable newDrawable = Util.createDrawableFromBitmap(this.context, bitmap); + + if (existingDrawable == null) + { + Bitmap emptyImage = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); + } + + Drawable[] layers = new Drawable[]{existingDrawable, newDrawable}; + + TransitionDrawable transitionDrawable = new TransitionDrawable(layers); + imageView.setImageDrawable(transitionDrawable); + transitionDrawable.startTransition(250); + } + else + { + imageView.setImageBitmap(bitmap); + } + } + } + + public void setUnknownAvatarImage(View view) + { + setAvatarImageBitmap(view, null, unknownAvatarImage, false); + } + public void setUnknownImage(View view, boolean large) { if (large) @@ -264,6 +358,11 @@ public class ImageLoader implements Runnable cache.put(getKey(entry.getCoverArt(), size), bitmap); } + public void addImageToCache(Bitmap bitmap, String username, int size) + { + cache.put(getKey(username, size), bitmap); + } + public void clear() { queue.clear(); @@ -295,6 +394,7 @@ public class ImageLoader implements Runnable { private final View view; private final MusicDirectory.Entry entry; + private final String username; private final Handler handler; private final int size; private final boolean saveToFile; @@ -305,6 +405,19 @@ public class ImageLoader implements Runnable { this.view = view; this.entry = entry; + this.username = null; + this.size = size; + this.saveToFile = saveToFile; + this.crossFade = crossFade; + this.highQuality = highQuality; + handler = new Handler(); + } + + public Task(View view, String username, int size, boolean saveToFile, boolean crossFade, boolean highQuality) + { + this.view = view; + this.entry = null; + this.username = username; this.size = size; this.saveToFile = saveToFile; this.crossFade = crossFade; @@ -317,16 +430,27 @@ public class ImageLoader implements Runnable try { MusicService musicService = MusicServiceFactory.getMusicService(view.getContext()); - final Bitmap bitmap = musicService.getCoverArt(view.getContext(), entry, size, saveToFile, highQuality, null); + final boolean isAvatar = this.username != null && this.entry == null; + final Bitmap bitmap = this.entry != null ? musicService.getCoverArt(view.getContext(), entry, size, saveToFile, highQuality, null) : musicService.getAvatar(view.getContext(), username, size, saveToFile, highQuality, null); - addImageToCache(bitmap, entry, size); + if (isAvatar) + addImageToCache(bitmap, username, size); + else + addImageToCache(bitmap, entry, size); handler.post(new Runnable() { @Override public void run() { - setImageBitmap(view, entry, bitmap, crossFade); + if (isAvatar) + { + setAvatarImageBitmap(view, username, bitmap, crossFade); + } + else + { + setImageBitmap(view, entry, bitmap, crossFade); + } } }); } diff --git a/src/com/thejoshwa/ultrasonic/androidapp/util/Util.java b/src/com/thejoshwa/ultrasonic/androidapp/util/Util.java index e3216912..f1cb024b 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/util/Util.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/util/Util.java @@ -31,6 +31,7 @@ import android.content.pm.PackageManager; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Canvas; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.media.AudioManager; @@ -871,6 +872,19 @@ public class Util extends DownloadActivity return new BitmapDrawable(context.getResources(), bitmap); } + public static Bitmap createBitmapFromDrawable(Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable)drawable).getBitmap(); + } + + Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + public static WifiManager.WifiLock createWifiLock(Context context, String tag) { WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); diff --git a/src/com/thejoshwa/ultrasonic/androidapp/view/ChatAdapter.java b/src/com/thejoshwa/ultrasonic/androidapp/view/ChatAdapter.java index 14a824e8..5d81f706 100644 --- a/src/com/thejoshwa/ultrasonic/androidapp/view/ChatAdapter.java +++ b/src/com/thejoshwa/ultrasonic/androidapp/view/ChatAdapter.java @@ -6,34 +6,45 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; +import android.widget.ImageView; import android.widget.TextView; import com.thejoshwa.ultrasonic.androidapp.R; import com.thejoshwa.ultrasonic.androidapp.activity.SubsonicTabActivity; import com.thejoshwa.ultrasonic.androidapp.domain.ChatMessage; +import com.thejoshwa.ultrasonic.androidapp.util.ImageLoader; import com.thejoshwa.ultrasonic.androidapp.util.Util; import java.text.DateFormat; -import java.util.ArrayList; import java.util.Date; +import java.util.List; import java.util.regex.Pattern; public class ChatAdapter extends ArrayAdapter { - private final SubsonicTabActivity activity; - private ArrayList messages; + private List messages; - private static final String phoneRegex = "1?\\W*([2-9][0-8][0-9])\\W*([2-9][0-9]{2})\\W*([0-9]{4})"; //you can just place your support phone here + private static final String phoneRegex = "1?\\W*([2-9][0-8][0-9])\\W*([2-9][0-9]{2})\\W*([0-9]{4})"; private static final Pattern phoneMatcher = Pattern.compile(phoneRegex); - public ChatAdapter(SubsonicTabActivity activity, ArrayList messages) + public ChatAdapter(SubsonicTabActivity activity, List messages) { super(activity, R.layout.chat_item, messages); this.activity = activity; this.messages = messages; } + @Override + public boolean areAllItemsEnabled() { + return true; + } + + @Override + public boolean isEnabled(int position) { + return false; + } + @Override public int getCount() { @@ -65,16 +76,25 @@ public class ChatAdapter extends ArrayAdapter { holder = (ViewHolder) convertView.getTag(); - if (holder.layout != layout) + if (!holder.chatMessage.equals(message)) { convertView = inflateView(layout, parent); holder = createViewHolder(layout, convertView); } } + holder.chatMessage = message; + DateFormat timeFormat = android.text.format.DateFormat.getTimeFormat(activity); String messageTimeFormatted = String.format("[%s]", timeFormat.format(messageTime)); + ImageLoader imageLoader = activity.getImageLoader(); + + if (imageLoader != null) + { + imageLoader.loadAvatarImage(holder.avatar, messageUser, false, holder.avatar.getWidth(), false, true); + } + holder.username.setText(messageUser); holder.message.setText(messageText); holder.time.setText(messageTimeFormatted); @@ -95,18 +115,20 @@ public class ChatAdapter extends ArrayAdapter TextView usernameView; TextView timeView; TextView messageView; + ImageView imageView; if (convertView != null) { usernameView = (TextView) convertView.findViewById(R.id.chat_username); timeView = (TextView) convertView.findViewById(R.id.chat_time); messageView = (TextView) convertView.findViewById(R.id.chat_message); + imageView = (ImageView) convertView.findViewById(R.id.chat_avatar); messageView.setMovementMethod(LinkMovementMethod.getInstance()); - Linkify.addLinks(messageView, Linkify.EMAIL_ADDRESSES); - Linkify.addLinks(messageView, Linkify.WEB_URLS); + Linkify.addLinks(messageView, Linkify.ALL); Linkify.addLinks(messageView, phoneMatcher, "tel:"); + holder.avatar = imageView; holder.message = messageView; holder.username = usernameView; holder.time = timeView; @@ -120,8 +142,10 @@ public class ChatAdapter extends ArrayAdapter private static class ViewHolder { int layout; + ImageView avatar; TextView message; TextView username; TextView time; + ChatMessage chatMessage; } }