/* * Copyright 2012 Google Inc. * * 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.google.android.apps.iosched.util; import android.annotation.TargetApi; import android.app.Activity; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.*; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.preference.PreferenceManager; import android.text.Html; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.format.DateUtils; import android.text.method.LinkMovementMethod; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import com.google.android.apps.iosched.BuildConfig; import com.google.android.apps.iosched.Config; import com.google.android.apps.iosched.R; import com.google.android.apps.iosched.provider.ScheduleContract; import com.google.android.apps.iosched.provider.ScheduleContract.Blocks; import com.google.android.apps.iosched.provider.ScheduleContract.Rooms; import com.google.android.apps.iosched.ui.phone.MapActivity; import com.google.android.apps.iosched.ui.tablet.MapMultiPaneActivity; import java.io.*; import java.lang.ref.WeakReference; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Calendar; import java.util.Formatter; import java.util.Locale; import java.util.TimeZone; import java.util.regex.Pattern; import static com.google.android.apps.iosched.util.LogUtils.LOGE; import static com.google.android.apps.iosched.util.LogUtils.makeLogTag; /** * An assortment of UI helpers. */ public class UIUtils { private static final String TAG = makeLogTag(UIUtils.class); /** * Time zone to use when formatting all session times. To always use the * phone local time, use {@link TimeZone#getDefault()}. */ public static final TimeZone CONFERENCE_TIME_ZONE = TimeZone.getTimeZone("America/Los_Angeles"); public static final long CONFERENCE_START_MILLIS = ParserUtils.parseTime( "2013-05-15T09:00:00.000-07:00"); public static final long CONFERENCE_END_MILLIS = ParserUtils.parseTime( "2013-05-17T16:00:00.000-07:00"); public static final String CONFERENCE_HASHTAG = "#io13"; public static final String TARGET_FORM_FACTOR_ACTIVITY_METADATA = "com.google.android.apps.iosched.meta.TARGET_FORM_FACTOR"; public static final String TARGET_FORM_FACTOR_HANDSET = "handset"; public static final String TARGET_FORM_FACTOR_TABLET = "tablet"; /** * Flags used with {@link DateUtils#formatDateRange}. */ private static final int TIME_FLAGS = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_WEEKDAY; /** * Regex to search for HTML escape sequences. * * <p></p>Searches for any continuous string of characters starting with an ampersand and ending with a * semicolon. (Example: &amp;) */ private static final Pattern REGEX_HTML_ESCAPE = Pattern.compile(".*&\\S;.*"); /** * Used in {@link #tryTranslateHttpIntent(android.app.Activity)}. */ private static final Uri SESSION_DETAIL_WEB_URL_PREFIX = Uri.parse("https://developers.google.com/events/io/sessions/"); private static StyleSpan sBoldSpan = new StyleSpan(Typeface.BOLD); private static ForegroundColorSpan sColorSpan = new ForegroundColorSpan(0xff111111); private static CharSequence sEmptyRoomText; private static CharSequence sNowPlayingText; private static CharSequence sLivestreamNowText; private static CharSequence sLivestreamAvailableText; public static final String GOOGLE_PLUS_PACKAGE_NAME = "com.google.android.apps.plus"; public static final int ANIMATION_FADE_IN_TIME = 250; public static final String TRACK_ICONS_TAG = "tracks"; /** * Format and return the given {@link Blocks} and {@link Rooms} values using * {@link #CONFERENCE_TIME_ZONE}. */ public static String formatSessionSubtitle(String sessionTitle, long blockStart, long blockEnd, String roomName, StringBuilder recycle, Context context) { // Determine if the session is in the past long currentTimeMillis = UIUtils.getCurrentTime(context); boolean conferenceEnded = currentTimeMillis > UIUtils.CONFERENCE_END_MILLIS; boolean blockEnded = currentTimeMillis > blockEnd; if (blockEnded && !conferenceEnded) { return context.getString(R.string.session_finished); } if (sEmptyRoomText == null) { sEmptyRoomText = context.getText(R.string.unknown_room); } if (roomName == null) { return context.getString(R.string.session_subtitle, formatBlockTimeString(blockStart, blockEnd, recycle, context), sEmptyRoomText); } return context.getString(R.string.session_subtitle, formatBlockTimeString(blockStart, blockEnd, recycle, context), roomName); } /** * Format and return the given {@link Blocks} values using {@link #CONFERENCE_TIME_ZONE} * (unless local time was explicitly requested by the user). */ public static String formatBlockTimeString(long blockStart, long blockEnd, StringBuilder recycle, Context context) { if (recycle == null) { recycle = new StringBuilder(); } else { recycle.setLength(0); } Formatter formatter = new Formatter(recycle); return DateUtils.formatDateRange(context, formatter, blockStart, blockEnd, TIME_FLAGS, PrefUtils.getDisplayTimeZone(context).getID()).toString(); } public static boolean isSameDayDisplay(long time1, long time2, Context context) { TimeZone displayTimeZone = PrefUtils.getDisplayTimeZone(context); Calendar cal1 = Calendar.getInstance(displayTimeZone); Calendar cal2 = Calendar.getInstance(displayTimeZone); cal1.setTimeInMillis(time1); cal2.setTimeInMillis(time2); return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR); } /** * Populate the given {@link TextView} with the requested text, formatting * through {@link Html#fromHtml(String)} when applicable. Also sets * {@link TextView#setMovementMethod} so inline links are handled. */ public static void setTextMaybeHtml(TextView view, String text) { if (TextUtils.isEmpty(text)) { view.setText(""); return; } if ((text.contains("<") && text.contains(">")) || REGEX_HTML_ESCAPE.matcher(text).find()) { view.setText(Html.fromHtml(text)); view.setMovementMethod(LinkMovementMethod.getInstance()); } else { view.setText(text); } } public static void updateTimeAndLivestreamBlockUI(final Context context, long blockStart, long blockEnd, boolean hasLivestream, TextView titleView, TextView subtitleView, CharSequence subtitle) { long currentTimeMillis = getCurrentTime(context); boolean conferenceEnded = currentTimeMillis > CONFERENCE_END_MILLIS; boolean blockEnded = currentTimeMillis > blockEnd; boolean blockNow = (blockStart <= currentTimeMillis && currentTimeMillis <= blockEnd); if (titleView != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { titleView.setTypeface(Typeface.create((blockEnded && !conferenceEnded) ? "sans-serif-light" : "sans-serif", Typeface.NORMAL)); } else { titleView.setTypeface(Typeface.SANS_SERIF, (blockEnded && !conferenceEnded) ? Typeface.NORMAL : Typeface.BOLD); } } if (subtitleView != null) { boolean empty = true; SpannableStringBuilder sb = new SpannableStringBuilder(); // TODO: recycle if (subtitle != null) { sb.append(subtitle); empty = false; } if (blockNow) { if (sNowPlayingText == null) { sNowPlayingText = Html.fromHtml(context.getString(R.string.now_playing_badge)); } if (!empty) { sb.append(" "); } sb.append(sNowPlayingText); if (hasLivestream) { if (sLivestreamNowText == null) { sLivestreamNowText = Html.fromHtml("  " + context.getString(R.string.live_now_badge)); } sb.append(sLivestreamNowText); } } else if (hasLivestream) { if (sLivestreamAvailableText == null) { sLivestreamAvailableText = Html.fromHtml( context.getString(R.string.live_available_badge)); } if (!empty) { sb.append(" "); } sb.append(sLivestreamAvailableText); } subtitleView.setText(sb); } } /** * Given a snippet string with matching segments surrounded by curly * braces, turn those areas into bold spans, removing the curly braces. */ public static Spannable buildStyledSnippet(String snippet) { final SpannableStringBuilder builder = new SpannableStringBuilder(snippet); // Walk through string, inserting bold snippet spans int startIndex, endIndex = -1, delta = 0; while ((startIndex = snippet.indexOf('{', endIndex)) != -1) { endIndex = snippet.indexOf('}', startIndex); // Remove braces from both sides builder.delete(startIndex - delta, startIndex - delta + 1); builder.delete(endIndex - delta - 1, endIndex - delta); // Insert bold style builder.setSpan(sBoldSpan, startIndex - delta, endIndex - delta - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); builder.setSpan(sColorSpan, startIndex - delta, endIndex - delta - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); delta += 2; } return builder; } public static void preferPackageForIntent(Context context, Intent intent, String packageName) { PackageManager pm = context.getPackageManager(); for (ResolveInfo resolveInfo : pm.queryIntentActivities(intent, 0)) { if (resolveInfo.activityInfo.packageName.equals(packageName)) { intent.setPackage(packageName); break; } } } public static String getSessionHashtagsString(String hashtags) { if (!TextUtils.isEmpty(hashtags)) { if (!hashtags.startsWith("#")) { hashtags = "#" + hashtags; } if (hashtags.contains(CONFERENCE_HASHTAG)) { return hashtags; } return CONFERENCE_HASHTAG + " " + hashtags; } else { return CONFERENCE_HASHTAG; } } private static final int BRIGHTNESS_THRESHOLD = 130; /** * Calculate whether a color is light or dark, based on a commonly known * brightness formula. * * @see {@literal http://en.wikipedia.org/wiki/HSV_color_space%23Lightness} */ public static boolean isColorDark(int color) { return ((30 * Color.red(color) + 59 * Color.green(color) + 11 * Color.blue(color)) / 100) <= BRIGHTNESS_THRESHOLD; } /** * Create the track icon bitmap. Don't call this directly, instead use either * {@link UIUtils.TrackIconAsyncTask} or {@link UIUtils.TrackIconViewAsyncTask} to * asynchronously load the track icon. */ private static Bitmap createTrackIcon(Context context, String trackName, int trackColor) { final Resources res = context.getResources(); int iconSize = res.getDimensionPixelSize(R.dimen.track_icon_source_size); Bitmap icon = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(icon); Paint paint = new Paint(); paint.setAntiAlias(true); paint.setColor(trackColor); canvas.drawCircle(iconSize / 2, iconSize / 2, iconSize / 2, paint); int iconResId = res.getIdentifier( "track_" + ParserUtils.sanitizeId(trackName), "drawable", context.getPackageName()); if (iconResId != 0) { Drawable sourceIconDrawable = res.getDrawable(iconResId); sourceIconDrawable.setBounds(0, 0, iconSize, iconSize); sourceIconDrawable.draw(canvas); } return icon; } /** * Synchronously get the track icon bitmap. Don't call this from the main thread, instead use either * {@link UIUtils.TrackIconAsyncTask} or {@link UIUtils.TrackIconViewAsyncTask} to * asynchronously load the track icon. */ public static Bitmap getTrackIconSync(Context ctx, String trackName, int trackColor) { if (TextUtils.isEmpty(trackName)) { return null; } // Find a suitable disk cache directory for the track icons and create if it doesn't // already exist. File outputDir = ImageLoader.getDiskCacheDir(ctx, TRACK_ICONS_TAG); if (!outputDir.exists()) { outputDir.mkdirs(); } // Generate a unique filename to store this track icon in using a hash function. File imageFile = new File(outputDir + File.separator + hashKeyForDisk(trackName)); Bitmap bitmap = null; // If file already exists and is readable, try and decode the bitmap from the disk. if (imageFile.exists() && imageFile.canRead()) { bitmap = BitmapFactory.decodeFile(imageFile.toString()); } // If bitmap is still null here the track icon was not found in the disk cache. if (bitmap == null) { // Create the icon using the provided track name and color. bitmap = UIUtils.createTrackIcon(ctx, trackName, trackColor); // Now write it out to disk for future use. BufferedOutputStream outputStream = null; try { outputStream = new BufferedOutputStream(new FileOutputStream(imageFile)); bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream); } catch (FileNotFoundException e) { LOGE(TAG, "TrackIconAsyncTask - unable to open file - " + e); } finally { if (outputStream != null) { try { outputStream.close(); } catch (IOException ignored) { } } } } return bitmap; } /** * A hashing method that changes a string (like a URL) into a hash suitable for using as a * disk filename. */ private static String hashKeyForDisk(String key) { String cacheKey; try { final MessageDigest mDigest = MessageDigest.getInstance("MD5"); mDigest.update(key.getBytes()); cacheKey = bytesToHexString(mDigest.digest()); } catch (NoSuchAlgorithmException e) { cacheKey = String.valueOf(key.hashCode()); } return cacheKey; } private static String bytesToHexString(byte[] bytes) { // http://stackoverflow.com/questions/332079 StringBuilder sb = new StringBuilder(); for (int i = 0; i < bytes.length; i++) { String hex = Integer.toHexString(0xFF & bytes[i]); if (hex.length() == 1) { sb.append('0'); } sb.append(hex); } return sb.toString(); } /** * A subclass of {@link TrackIconAsyncTask} that loads the generated track icon bitmap into * the provided {@link ImageView}. This class also handles concurrency in the case the * ImageView is recycled (eg. in a ListView adapter) so that the incorrect image will not show * in a recycled view. */ public static class TrackIconViewAsyncTask extends TrackIconAsyncTask { private WeakReference<ImageView> mImageViewReference; public TrackIconViewAsyncTask(ImageView imageView, String trackName, int trackColor, BitmapCache bitmapCache) { super(trackName, trackColor, bitmapCache); // Store this AsyncTask in the tag of the ImageView so we can compare if the same task // is still running on this ImageView once processing is complete. This helps with // view recycling that takes place in a ListView type adapter. imageView.setTag(this); // If we have a BitmapCache, check if this track icon is available already. Bitmap bitmap = bitmapCache != null ? bitmapCache.getBitmapFromMemCache(trackName) : null; // If found in BitmapCache set the Bitmap directly and cancel the task. if (bitmap != null) { imageView.setImageBitmap(bitmap); cancel(true); } else { // Otherwise clear the ImageView and store a WeakReference for later use. Better // to use a WeakReference here in case the task runs long and the holding Activity // or Fragment goes away. imageView.setImageDrawable(null); mImageViewReference = new WeakReference<ImageView>(imageView); } } @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) @Override protected void onPostExecute(Bitmap bitmap) { ImageView imageView = mImageViewReference != null ? mImageViewReference.get() : null; // If ImageView is still around, bitmap processed OK and this task is not canceled. if (imageView != null && bitmap != null && !isCancelled()) { // Ensure this task is still the same one assigned to this ImageView, if not the // view was likely recycled and a new task with a different icon is now running // on the view and we shouldn't proceed. if (this.equals(imageView.getTag())) { // On HC-MR1 run a quick fade-in animation. if (hasHoneycombMR1()) { imageView.setAlpha(0f); imageView.setImageBitmap(bitmap); imageView.animate() .alpha(1f) .setDuration(ANIMATION_FADE_IN_TIME) .setListener(null); } else { // Before HC-MR1 set the Bitmap directly. imageView.setImageBitmap(bitmap); } } } } } /** * Asynchronously load the track icon bitmap. To use, subclass and override * {@link #onPostExecute(android.graphics.Bitmap)} which passes in the generated track icon * bitmap. */ public static abstract class TrackIconAsyncTask extends AsyncTask<Context, Void, Bitmap> { private String mTrackName; private int mTrackColor; private BitmapCache mBitmapCache; public TrackIconAsyncTask(String trackName, int trackColor) { mTrackName = trackName; mTrackColor = trackColor; } public TrackIconAsyncTask(String trackName, int trackColor, BitmapCache bitmapCache) { mTrackName = trackName; mTrackColor = trackColor; mBitmapCache = bitmapCache; } @Override protected Bitmap doInBackground(Context... contexts) { Bitmap bitmap = getTrackIconSync(contexts[0], mTrackName, mTrackColor); // Store bitmap in memory cache for future use. if (bitmap != null && mBitmapCache != null) { mBitmapCache.addBitmapToCache(mTrackName, bitmap); } return bitmap; } protected abstract void onPostExecute(Bitmap bitmap); } public static boolean hasHoneycomb() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB; } public static boolean hasHoneycombMR1() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1; } public static boolean hasICS() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH; } public static boolean hasJellyBeanMR1() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1; } public static boolean isTablet(Context context) { return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; } public static boolean isHoneycombTablet(Context context) { return hasHoneycomb() && isTablet(context); } // Shows whether a notification was fired for a particular session time block. In the // event that notification has not been fired yet, return false and set the bit. public static boolean isNotificationFiredForBlock(Context context, String blockId) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); final String key = String.format("notification_fired_%s", blockId); boolean fired = sp.getBoolean(key, false); sp.edit().putBoolean(key, true).commit(); return fired; } private static final long sAppLoadTime = System.currentTimeMillis(); public static long getCurrentTime(final Context context) { if (BuildConfig.DEBUG) { return context.getSharedPreferences("mock_data", Context.MODE_PRIVATE) .getLong("mock_current_time", System.currentTimeMillis()) + System.currentTimeMillis() - sAppLoadTime; // return ParserUtils.parseTime("2012-06-27T09:44:45.000-07:00") // + System.currentTimeMillis() - sAppLoadTime; } else { return System.currentTimeMillis(); } } public static boolean shouldShowLiveSessionsOnly(final Context context) { return !PrefUtils.isAttendeeAtVenue(context) && getCurrentTime(context) < CONFERENCE_END_MILLIS; } /** * Enables and disables {@linkplain android.app.Activity activities} based on their * {@link #TARGET_FORM_FACTOR_ACTIVITY_METADATA}" meta-data and the current device. * Values should be either "handset", "tablet", or not present (meaning universal). * <p> * <a href="http://stackoverflow.com/questions/13202805">Original code</a> by Dandre Allison. * @param context the current context of the device * @see #isHoneycombTablet(android.content.Context) */ public static void enableDisableActivitiesByFormFactor(Context context) { final PackageManager pm = context.getPackageManager(); boolean isTablet = isHoneycombTablet(context); try { PackageInfo pi = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_ACTIVITIES | PackageManager.GET_META_DATA); if (pi == null) { LOGE(TAG, "No package info found for our own package."); return; } final ActivityInfo[] activityInfos = pi.activities; for (ActivityInfo info : activityInfos) { String targetDevice = null; if (info.metaData != null) { targetDevice = info.metaData.getString(TARGET_FORM_FACTOR_ACTIVITY_METADATA); } boolean tabletActivity = TARGET_FORM_FACTOR_TABLET.equals(targetDevice); boolean handsetActivity = TARGET_FORM_FACTOR_HANDSET.equals(targetDevice); boolean enable = !(handsetActivity && isTablet) && !(tabletActivity && !isTablet); String className = info.name; pm.setComponentEnabledSetting( new ComponentName(context, Class.forName(className)), enable ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); } } catch (PackageManager.NameNotFoundException e) { LOGE(TAG, "No package info found for our own package.", e); } catch (ClassNotFoundException e) { LOGE(TAG, "Activity not found within package.", e); } } public static Class getMapActivityClass(Context context) { if (UIUtils.isHoneycombTablet(context)) { return MapMultiPaneActivity.class; } return MapActivity.class; } @TargetApi(Build.VERSION_CODES.HONEYCOMB) public static void setActivatedCompat(View view, boolean activated) { if (hasHoneycomb()) { view.setActivated(activated); } } /** * If an activity's intent is for a Google I/O web URL that the app can handle * natively, this method translates the intent to the equivalent native intent. */ public static void tryTranslateHttpIntent(Activity activity) { Intent intent = activity.getIntent(); if (intent == null) { return; } Uri uri = intent.getData(); if (uri == null || TextUtils.isEmpty(uri.getPath())) { return; } String prefixPath = SESSION_DETAIL_WEB_URL_PREFIX.getPath(); String path = uri.getPath(); if (SESSION_DETAIL_WEB_URL_PREFIX.getScheme().equals(uri.getScheme()) && SESSION_DETAIL_WEB_URL_PREFIX.getHost().equals(uri.getHost()) && path.startsWith(prefixPath)) { String sessionId = path.substring(prefixPath.length()); activity.setIntent(new Intent( Intent.ACTION_VIEW, ScheduleContract.Sessions.buildSessionUri(sessionId))); } } /** * The Conference API image URLs can be absolute or relative. In the case they are relative * we should prepend the main Conference URL. * @param imageUrl A Conference API image URL * @return An absolute image URL */ public static String getConferenceImageUrl(String imageUrl) { if (!TextUtils.isEmpty(imageUrl) && !imageUrl.startsWith("http")) { return Config.CONFERENCE_IMAGE_PREFIX_URL + imageUrl; } return imageUrl; } }