From 6c2a946d20608f984fad870c8e5a92333719d5f1 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Fri, 25 Jul 2014 18:26:29 -0400 Subject: [PATCH] Fixed bug in ReaderTagTable that caused tbl_tag_updates to always overwrite the existing row when updating a date column --- .gitignore | 25 + WordPressUtils/build.gradle | 55 ++ WordPressUtils/gradle.properties-example | 1 + WordPressUtils/src/main/AndroidManifest.xml | 5 + .../org/wordpress/android/util/AlertUtil.java | 101 ++++ .../org/wordpress/android/util/AppLog.java | 214 +++++++ .../org/wordpress/android/util/BlogUtils.java | 25 + .../wordpress/android/util/DeviceUtils.java | 94 +++ .../wordpress/android/util/DisplayUtils.java | 93 +++ .../wordpress/android/util/EditTextUtils.java | 77 +++ .../org/wordpress/android/util/Emoticons.java | 106 ++++ .../wordpress/android/util/FormatUtils.java | 35 ++ .../wordpress/android/util/GeocoderUtils.java | 116 ++++ .../wordpress/android/util/GravatarUtils.java | 22 + .../org/wordpress/android/util/HtmlUtils.java | 138 +++++ .../wordpress/android/util/ImageUtils.java | 554 ++++++++++++++++++ .../org/wordpress/android/util/JSONUtil.java | 236 ++++++++ .../util/ListScrollPositionManager.java | 36 ++ .../android/util/LocationHelper.java | 132 +++++ .../org/wordpress/android/util/MapUtils.java | 79 +++ .../wordpress/android/util/PhotonUtils.java | 96 +++ .../android/util/ProfilingUtils.java | 91 +++ .../java/org/wordpress/android/util/README.md | 1 + .../org/wordpress/android/util/SqlUtils.java | 121 ++++ .../wordpress/android/util/StringUtils.java | 278 +++++++++ .../android/util/SystemServiceFactory.java | 17 + .../util/SystemServiceFactoryAbstract.java | 7 + .../util/SystemServiceFactoryDefault.java | 9 + .../wordpress/android/util/ToastUtils.java | 37 ++ .../org/wordpress/android/util/UrlUtils.java | 165 ++++++ .../org/wordpress/android/util/UserEmail.java | 35 ++ .../org/wordpress/android/util/Version.java | 47 ++ .../android/util/WPHtmlTagHandler.java | 59 ++ .../wordpress/android/util/WPImageGetter.java | 198 +++++++ .../wordpress/android/util/WPQuoteSpan.java | 44 ++ .../android/util/WPWebChromeClient.java | 29 + .../ptr/PullToRefreshHeaderTransformer.java | 99 ++++ .../android/util/ptr/PullToRefreshHelper.java | 142 +++++ .../src/main/res/values/strings.xml | 4 + build.gradle | 0 gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 51348 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 164 ++++++ gradlew.bat | 90 +++ settings.gradle | 1 + 45 files changed, 3884 insertions(+) create mode 100644 .gitignore create mode 100644 WordPressUtils/build.gradle create mode 100644 WordPressUtils/gradle.properties-example create mode 100644 WordPressUtils/src/main/AndroidManifest.xml create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/README.md create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Version.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java create mode 100644 WordPressUtils/src/main/res/values/strings.xml create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..8babf679a8c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# generated files +build/ + +# Local configuration file (sdk path, etc) +local.properties +tools/deploy-mvn-artifact.conf + +# Intellij project files +*.iml +*.ipr +*.iws +.idea/ + +# Gradle +.gradle/ +gradle.properties + +# Idea +.idea/workspace.xml +*.iml + +# OS X +.DS_Store + +# dependencies diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle new file mode 100644 index 000000000000..da68c80de30e --- /dev/null +++ b/WordPressUtils/build.gradle @@ -0,0 +1,55 @@ + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:0.12.+' + } +} + +apply plugin: 'com.android.library' +apply plugin: 'maven' + +repositories { + mavenCentral() + maven { url 'http://wordpress-mobile.github.io/WordPress-Android' } +} + +dependencies { + compile 'commons-lang:commons-lang:2.6' + compile 'com.mcxiaoke.volley:library:1.0.+' + compile 'com.github.castorflex.smoothprogressbar:library:0.4.0' + compile 'org.wordpress:pulltorefresh-main:+@aar' // org.wordpress version includes some fixes + compile 'com.android.support:support-v13:19.0.+' +} + +android { + defaultPublishConfig 'debug' + + compileSdkVersion 19 + buildToolsVersion "19.1.0" + + defaultConfig { + applicationId "org.wordpress.android.util" + versionName "1.0.2" + versionCode 1 + minSdkVersion 14 + targetSdkVersion 19 + } +} + +uploadArchives { + repositories { + mavenDeployer { + def repo_url = "" + if (project.hasProperty("repository")) { + repo_url = project.repository + } + repository(url: repo_url) + pom.version = android.defaultConfig.versionName + pom.groupId = "org.wordpress" + pom.artifactId = "wordpress-utils" + } + } +} diff --git a/WordPressUtils/gradle.properties-example b/WordPressUtils/gradle.properties-example new file mode 100644 index 000000000000..36ceb8db22bc --- /dev/null +++ b/WordPressUtils/gradle.properties-example @@ -0,0 +1 @@ +repository=file:///Users/max/work/automattic/WordPress-Android-gh-pages/ diff --git a/WordPressUtils/src/main/AndroidManifest.xml b/WordPressUtils/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..4f3bd125a3c5 --- /dev/null +++ b/WordPressUtils/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java new file mode 100644 index 000000000000..76800de4cd6d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2011 wordpress.org + * + * 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 org.wordpress.android.util; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; + +public class AlertUtil { + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, int messageId) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(messageId) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, String message) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(message) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + * @param negativeButtontxt + * @param negativeListener + */ + public static void showAlert(Context context, int titleId, int messageId, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener, + CharSequence negativeButtontxt, DialogInterface.OnClickListener negativeListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setNegativeButton(negativeButtontxt, negativeListener) + .setMessage(messageId) + .setCancelable(false) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + */ + public static void showAlert(Context context, int titleId, String message, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setMessage(message) + .setCancelable(false) + .create(); + + dlg.show(); + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java new file mode 100644 index 000000000000..f2fff1b2ef4a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java @@ -0,0 +1,214 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * simple wrapper for Android log calls, enables recording & displaying log + */ +public class AppLog { + // T for Tag + public enum T {READER, EDITOR, MEDIA, NUX, API, STATS, UTILS, NOTIFS, DB, POSTS, COMMENTS, THEMES, TESTS, PROFILING, SIMPERIUM} + public static final String TAG = "WordPress"; + + private static boolean mEnableRecording = false; + + private AppLog() { + throw new AssertionError(); + } + + /* + * defaults to false, pass true to capture log so it can be displayed by AppLogViewerActivity + */ + public static void enableRecording(boolean enable) { + mEnableRecording = enable; + } + + public static void v(T tag, String message) { + Log.v(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.v, message); + } + + public static void d(T tag, String message) { + Log.d(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.d, message); + } + + public static void i(T tag, String message) { + Log.i(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.i, message); + } + + public static void w(T tag, String message) { + Log.w(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.w, message); + } + + public static void e(T tag, String message) { + Log.e(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.e, message); + } + + public static void e(T tag, String message, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), message, tr); + addEntry(tag, LogLevel.e, message + " - exception: " + tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), tr.getMessage(), tr); + addEntry(tag, LogLevel.e, tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, String volleyErrorMsg, int statusCode) { + if (TextUtils.isEmpty(volleyErrorMsg)) { + return; + } + String logText; + if (statusCode == -1) { + logText = volleyErrorMsg; + } else { + logText = volleyErrorMsg + ", status " + statusCode; + } + Log.e(TAG + "-" + tag.toString(), logText); + addEntry(tag, LogLevel.w, logText); + } + + // -------------------------------------------------------------------------------------------------------- + + private static final int MAX_ENTRIES = 99; + + private enum LogLevel { + v, d, i, w, e; + private String toHtmlColor() { + switch(this) { + case v: + return "grey"; + case i: + return "black"; + case w: + return "purple"; + case e: + return "red"; + case d: + default: + return "teal"; + } + } + } + + private static class LogEntry { + LogLevel logLevel; + String logText; + T logTag; + + private String toHtml() { + StringBuilder sb = new StringBuilder() + .append("") + .append("[") + .append(logTag.name()) + .append("] ") + .append(logLevel.name()) + .append(": ") + .append(logText) + .append(""); + return sb.toString(); + } + } + + private static class LogEntryList extends ArrayList { + private synchronized boolean addEntry(LogEntry entry) { + if (size() >= MAX_ENTRIES) + removeFirstEntry(); + return add(entry); + } + private void removeFirstEntry() { + Iterator it = iterator(); + if (!it.hasNext()) + return; + try { + remove(it.next()); + } catch (NoSuchElementException e) { + // ignore + } + } + } + + private static LogEntryList mLogEntries = new LogEntryList(); + + private static void addEntry(T tag, LogLevel level, String text) { + // skip if recording is disabled (default) + if (!mEnableRecording) + return; + LogEntry entry = new LogEntry(); + entry.logLevel = level; + entry.logText = text; + entry.logTag = tag; + mLogEntries.addEntry(entry); + } + + private static String getStringStackTrace(Throwable throwable) { + StringWriter errors = new StringWriter(); + throwable.printStackTrace(new PrintWriter(errors)); + return errors.toString(); + } + + private static String getHTMLStringStackTrace(Throwable throwable) { + return getStringStackTrace(throwable).replace("\n", "
"); + } + + /* + * returns entire log as html for display (see AppLogViewerActivity) + */ + public static String toHtml(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("
") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("
"); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append("") + .append(String.format("%02d", lineNum)) + .append(" ") + .append(it.next().toHtml()) + .append("
"); + lineNum++; + } + return sb.toString(); + } + + + /* + * returns entire log as plain text + */ + public static String toPlainText(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("\n") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("\n\n"); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append(String.format("%02d - ", lineNum)) + .append(it.next().logText) + .append("\n"); + lineNum++; + } + return sb.toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java new file mode 100644 index 000000000000..166085a4f066 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java @@ -0,0 +1,25 @@ +package org.wordpress.android.util; + +import java.util.Comparator; +import java.util.Map; + +public class BlogUtils { + public static Comparator BlogNameComparator = new Comparator() { + public int compare(Object blog1, Object blog2) { + Map blogMap1 = (Map) blog1; + Map blogMap2 = (Map) blog2; + + String blogName1 = MapUtils.getMapStr(blogMap1, "blogName"); + if (blogName1.length() == 0) { + blogName1 = MapUtils.getMapStr(blogMap1, "url"); + } + + String blogName2 = MapUtils.getMapStr(blogMap2, "blogName"); + if (blogName2.length() == 0) { + blogName2 = MapUtils.getMapStr(blogMap2, "url"); + } + + return blogName1.compareToIgnoreCase(blogName2); + } + }; +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java new file mode 100644 index 000000000000..639d5479c301 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java @@ -0,0 +1,94 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import org.wordpress.android.util.AppLog.T; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class DeviceUtils { + private static DeviceUtils instance; + private boolean isKindleFire = false; + + public boolean isKindleFire() { + return isKindleFire; + } + + public static DeviceUtils getInstance() { + if (instance == null) { + instance = new DeviceUtils(); + } + return instance; + } + + private DeviceUtils() { + isKindleFire = android.os.Build.MODEL.equalsIgnoreCase("kindle fire") ? true: false; + } + + /** + * Checks camera availability recursively based on API level. + * + * TODO: change "android.hardware.camera.front" and "android.hardware.camera.any" to + * {@link PackageManager#FEATURE_CAMERA_FRONT} and {@link PackageManager#FEATURE_CAMERA_ANY}, + * respectively, once they become accessible or minSdk version is incremented. + * + * @param context The context. + * @return Whether camera is available. + */ + public boolean hasCamera(Context context) { + final PackageManager pm = context.getPackageManager(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) + || pm.hasSystemFeature("android.hardware.camera.front"); + } + + return pm.hasSystemFeature("android.hardware.camera.any"); + } + + public String getDeviceName(Context context) { + String manufacturer = Build.MANUFACTURER; + String undecodedModel = Build.MODEL; + String model = null; + + try { + Properties prop = new Properties(); + InputStream fileStream; + // Read the device name from a precomplied list: + // see http://making.meetup.com/post/29648976176/human-readble-android-device-names + fileStream = context.getAssets().open("android_models.properties"); + prop.load(fileStream); + fileStream.close(); + String decodedModel = prop.getProperty(undecodedModel.replaceAll(" ", "_")); + if (decodedModel != null && !decodedModel.trim().equals("")) { + model = decodedModel; + } + } catch (IOException e) { + AppLog.e(T.UTILS, e.getMessage()); + } + + if (model == null) { //Device model not found in the list + if (undecodedModel.startsWith(manufacturer)) { + model = capitalize(undecodedModel); + } else { + model = capitalize(manufacturer) + " " + undecodedModel; + } + } + return model; + } + + private String capitalize(String s) { + if (s == null || s.length() == 0) { + return ""; + } + char first = s.charAt(0); + if (Character.isUpperCase(first)) { + return s; + } else { + return Character.toUpperCase(first) + s.substring(1); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java new file mode 100644 index 000000000000..f64527e9a622 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java @@ -0,0 +1,93 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +public class DisplayUtils { + private DisplayUtils() { + throw new AssertionError(); + } + + public static boolean isLandscape(Context context) { + if (context == null) + return false; + return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + public static boolean isLandscapeTablet(Context context) { + return isLandscape(context) && isTablet(context); + } + + public static Point getDisplayPixelSize(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + return size; + } + + public static int getDisplayPixelWidth(Context context) { + Point size = getDisplayPixelSize(context); + return (size.x); + } + + public static int getDisplayPixelHeight(Context context) { + Point size = getDisplayPixelSize(context); + return (size.y); + } + + public static int dpToPx(Context context, int dp) { + float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + return (int) px; + } + + public static int pxToDp(Context context, int px) { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + return (int) ((px/displayMetrics.density)+0.5); + } + + public static boolean isTablet(Context context) { + // http://stackoverflow.com/a/8427523/1673548 + if (context == null) + return false; + return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + public static boolean isXLarge(Context context) { + if ((context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE) + return true; + return false; + } + + /** + * returns the height of the ActionBar if one is enabled - supports both the native ActionBar + * and ActionBarSherlock - http://stackoverflow.com/a/15476793/1673548 + */ + public static int getActionBarHeight(Context context) { + if (context == null) { + return 0; + } + TypedValue tv = new TypedValue(); + if (context.getTheme() != null + && context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + return TypedValue.complexToDimensionPixelSize(tv.data, context.getResources().getDisplayMetrics()); + } + + // if we get this far, it's because the device doesn't support an ActionBar, + // so return the standard ActionBar height (48dp) + return dpToPx(context, 48); + } + + /** + * detect when FEATURE_ACTION_BAR_OVERLAY has been set + */ + public static boolean hasActionBarOverlay(Window window) { + return window.hasFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java new file mode 100644 index 000000000000..64ee67e566a9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java @@ -0,0 +1,77 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +/** + * EditText utils + */ +public class EditTextUtils { + private EditTextUtils() { + throw new AssertionError(); + } + + /** + * returns text string from passed EditText + */ + public static String getText(EditText edit) { + if (edit.getText() == null) { + return ""; + } + return edit.getText().toString(); + } + + /** + * moves caret to end of text + */ + public static void moveToEnd(EditText edit) { + if (edit.getText() == null) { + return; + } + edit.setSelection(edit.getText().toString().length()); + } + + /** + * returns true if nothing has been entered into passed editor + */ + public static boolean isEmpty(EditText edit) { + return TextUtils.isEmpty(getText(edit)); + } + + /** + * hide the soft keyboard for the passed EditText + */ + public static void hideSoftInput(EditText edit) { + if (edit == null) { + return; + } + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.hideSoftInputFromWindow(edit.getWindowToken(), 0); + } + } + + /** + * show the soft keyboard for the passed EditText + */ + public static void showSoftInput(EditText edit) { + if (edit == null) { + return; + } + + edit.requestFocus(); + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.showSoftInput(edit, InputMethodManager.SHOW_IMPLICIT); + } + } + + private static InputMethodManager getInputMethodManager(EditText edit) { + Context context = edit.getContext(); + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java new file mode 100644 index 000000000000..5a7566a967cf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java @@ -0,0 +1,106 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.util.SparseArray; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES; + +public class Emoticons { + public static final int EMOTICON_COLOR = 0xFF21759B; + private static final boolean HAS_EMOJI = SDK_INT >= VERSION_CODES.JELLY_BEAN; + private static final Map wpSmilies; + public static final SparseArray wpSmiliesCodePointToText; + + static { + Map smilies = new HashMap(); + smilies.put("icon_mrgreen.gif", HAS_EMOJI ? "\uD83D\uDE00" : ":mrgreen:" ); + smilies.put("icon_neutral.gif", HAS_EMOJI ? "\uD83D\uDE14" : ":|" ); + smilies.put("icon_twisted.gif", HAS_EMOJI ? "\uD83D\uDE16" : ":twisted:" ); + smilies.put("icon_arrow.gif", HAS_EMOJI ? "\u27A1" : ":arrow:" ); + smilies.put("icon_eek.gif", HAS_EMOJI ? "\uD83D\uDE32" : "8-O" ); + smilies.put("icon_smile.gif", HAS_EMOJI ? "\uD83D\uDE0A" : ":)" ); + smilies.put("icon_confused.gif", HAS_EMOJI ? "\uD83D\uDE15" : ":?" ); + smilies.put("icon_cool.gif", HAS_EMOJI ? "\uD83D\uDE0A" : "8)" ); + smilies.put("icon_evil.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":evil:" ); + smilies.put("icon_biggrin.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":D" ); + smilies.put("icon_idea.gif", HAS_EMOJI ? "\uD83D\uDCA1" : ":idea:" ); + smilies.put("icon_redface.gif", HAS_EMOJI ? "\uD83D\uDE33" : ":oops:" ); + smilies.put("icon_razz.gif", HAS_EMOJI ? "\uD83D\uDE1D" : ":P" ); + smilies.put("icon_rolleyes.gif", HAS_EMOJI ? "\uD83D\uDE0F" : ":roll:" ); + smilies.put("icon_wink.gif", HAS_EMOJI ? "\uD83D\uDE09" : ";)" ); + smilies.put("icon_cry.gif", HAS_EMOJI ? "\uD83D\uDE22" : ":'(" ); + smilies.put("icon_surprised.gif", HAS_EMOJI ? "\uD83D\uDE32" : ":o" ); + smilies.put("icon_lol.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":lol:" ); + smilies.put("icon_mad.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":x" ); + smilies.put("icon_sad.gif", HAS_EMOJI ? "\uD83D\uDE1E" : ":(" ); + smilies.put("icon_exclaim.gif", HAS_EMOJI ? "\u2757" : ":!:" ); + smilies.put("icon_question.gif", HAS_EMOJI ? "\u2753" : ":?:" ); + + wpSmilies = Collections.unmodifiableMap(smilies); + + wpSmiliesCodePointToText = new SparseArray(20); + wpSmiliesCodePointToText.put(10145, ":arrow:"); + wpSmiliesCodePointToText.put(128161, ":idea:"); + wpSmiliesCodePointToText.put(128512, ":mrgreen:"); + wpSmiliesCodePointToText.put(128515, ":D"); + wpSmiliesCodePointToText.put(128522, ":)"); + wpSmiliesCodePointToText.put(128521, ";)"); + wpSmiliesCodePointToText.put(128532, ":|"); + wpSmiliesCodePointToText.put(128533, ":?"); + wpSmiliesCodePointToText.put(128534, ":twisted:"); + wpSmiliesCodePointToText.put(128542, ":("); + wpSmiliesCodePointToText.put(128545, ":evil:"); + wpSmiliesCodePointToText.put(128546, ":'("); + wpSmiliesCodePointToText.put(128562, ":o"); + wpSmiliesCodePointToText.put(128563, ":oops:"); + wpSmiliesCodePointToText.put(128527, ":roll:"); + wpSmiliesCodePointToText.put(10071, ":!:"); + wpSmiliesCodePointToText.put(10067, ":?:"); + } + + public static String lookupImageSmiley(String url){ + return lookupImageSmiley(url, ""); + } + + public static String lookupImageSmiley(String url, String ifNone){ + String file = url.substring(url.lastIndexOf("/") + 1); + if (wpSmilies.containsKey(file)) { + return wpSmilies.get(file); + } + return ifNone; + } + + public static Spanned replaceEmoticonsWithEmoji(SpannableStringBuilder html){ + ImageSpan imgs[] = html.getSpans(0, html.length(), ImageSpan.class); + for (ImageSpan img : imgs) { + String emoticon = Emoticons.lookupImageSmiley(img.getSource()); + if (!emoticon.equals("")) { + int start = html.getSpanStart(img); + html.replace(start, html.getSpanEnd(img), emoticon); + html.setSpan(new ForegroundColorSpan(EMOTICON_COLOR), start, + start + emoticon.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + html.removeSpan(img); + } + } + return html; + } + + public static String replaceEmoticonsWithEmoji(final String text) { + if (text != null && text.contains("icon_")) { + final SpannableStringBuilder html = (SpannableStringBuilder)replaceEmoticonsWithEmoji((SpannableStringBuilder) Html.fromHtml(text)); + // Html.toHtml() is used here rather than toString() since the latter strips html + return Html.toHtml(html); + } else { + return text; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java new file mode 100644 index 000000000000..28282ed5fadf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import java.text.DecimalFormat; +import java.text.NumberFormat; + +public class FormatUtils { + /* + * NumberFormat isn't synchronized, so a separate instance must be created for each thread + * http://developer.android.com/reference/java/text/NumberFormat.html + */ + private static final ThreadLocal IntegerInstance = new ThreadLocal() { + @Override + protected NumberFormat initialValue() { + return NumberFormat.getIntegerInstance(); + } + }; + + private static final ThreadLocal DecimalInstance = new ThreadLocal() { + @Override + protected DecimalFormat initialValue() { + return (DecimalFormat) DecimalFormat.getInstance(); + } + }; + + /* + * returns the passed integer formatted with thousands-separators based on the current locale + */ + public static final String formatInt(int value) { + return IntegerInstance.get().format(value).toString(); + } + + public static final String formatDecimal(int value) { + return DecimalInstance.get().format(value).toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java new file mode 100644 index 000000000000..e861a88b8a88 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java @@ -0,0 +1,116 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.location.Address; +import android.location.Geocoder; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +public final class GeocoderUtils { + private GeocoderUtils() { + throw new AssertionError(); + } + + public static Geocoder getGeocoder(Context context) { + // first make sure a Geocoder service exists on this device (requires API 9) + if (!Geocoder.isPresent()) { + return null; + } + + Geocoder gcd; + + try { + gcd = new Geocoder(context, Locale.getDefault()); + } catch (NullPointerException cannotIstantiateEx) { + AppLog.e(AppLog.T.UTILS, "Cannot instantiate Geocoder", cannotIstantiateEx); + return null; + } + + return gcd; + } + + public static Address getAddressFromCoords(Context context, double latitude, double longitude) { + Address address = null; + List
addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocation(latitude, longitude, 1); + } catch (IOException e) { + // may get "Unable to parse response from server" IOException here if Geocoder + // service is hit too frequently + AppLog.e(AppLog.T.UTILS, + "Unable to parse response from server. Is Geocoder service hitting the server too frequently?", + e + ); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static Address getAddressFromLocationName(Context context, String locationName) { + int maxResults = 1; + Address address = null; + List
addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocationName(locationName, maxResults); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Failed to get coordinates from location", e); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static String getLocationNameFromAddress(Address address) { + String locality = "", adminArea = "", country = ""; + if (address.getLocality() != null) { + locality = address.getLocality(); + } + + if (address.getAdminArea() != null) { + adminArea = address.getAdminArea(); + } + + if (address.getCountryName() != null) { + country = address.getCountryName(); + } + + return ((locality.equals("")) ? locality : locality + ", ") + + ((adminArea.equals("")) ? adminArea : adminArea + " ") + country; + } + + public static double[] getCoordsFromAddress(Address address) { + double[] coordinates = new double[2]; + + if (address.hasLatitude() && address.hasLongitude()) { + coordinates[0] = address.getLatitude(); + coordinates[1] = address.getLongitude(); + } + + return coordinates; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java new file mode 100644 index 000000000000..c10ce69c81e8 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java @@ -0,0 +1,22 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +public class GravatarUtils { + /* + * see https://en.gravatar.com/site/implement/images/ + */ + public static String gravatarUrlFromEmail(final String email, int size) { + if (TextUtils.isEmpty(email)) + return ""; + + String url = "http://gravatar.com/avatar/" + + StringUtils.getMd5Hash(email) + + "?d=mm"; + + if (size > 0) + url += "&s=" + Integer.toString(size); + + return url; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java new file mode 100644 index 000000000000..c79fe0ecb079 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -0,0 +1,138 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Resources; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.QuoteSpan; + +import org.apache.commons.lang.StringEscapeUtils; + +public class HtmlUtils { + /* + * removes html from the passed string - relies on Html.fromHtml which handles invalid HTML, + * but it's very slow, so avoid using this where performance is important + */ + public static String stripHtml(final String text) { + if (TextUtils.isEmpty(text)) { + return ""; + } + return Html.fromHtml(text).toString().trim(); + } + + /* + * this is much faster than stripHtml() but should only be used when we know the html is valid + * since the regex will be unpredictable with invalid html + */ + public static String fastStripHtml(String str) { + if (TextUtils.isEmpty(str)) { + return str; + } + + // insert a line break before P tags unless the only one is at the start + if (str.lastIndexOf(" 0) { + str = str.replaceAll("", "\n

"); + } + + // convert BR tags to line breaks + if (str.contains("", "\n"); + } + + // use regex to strip tags, then convert entities in the result + return trimStart(fastUnescapeHtml(str.replaceAll("<(.|\n)*?>", ""))); + } + + /* + * same as apache.commons.lang.StringUtils.stripStart() but also removes non-breaking + * space (160) chars + */ + private static String trimStart(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return ""; + } + int start = 0; + while (start != strLen && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) { + start++; + } + return str.substring(start); + } + + /* + * convert html entities to actual Unicode characters - relies on commons apache lang + */ + public static String fastUnescapeHtml(final String text) { + if (text == null || !text.contains("&")) { + return text; + } + return StringEscapeUtils.unescapeHtml(text); + } + + /* + * converts an R.color.xxx resource to an HTML hex color + */ + public static String colorResToHtmlColor(Context context, int resId) { + try { + return String.format("#%06X", 0xFFFFFF & context.getResources().getColor(resId)); + } catch (Resources.NotFoundException e) { + return "#000000"; + } + } + + /* + * remove blocks from the passed string - added to project after noticing + * comments on posts that use the "Sociable" plugin ( http://wordpress.org/plugins/sociable/ ) + * may have a script block which contains followed by a CDATA section followed by , + * all of which will show up if we don't strip it here (example: http://cl.ly/image/0J0N3z3h1i04 ) + * first seen at http://houseofgeekery.com/2013/11/03/13-terrible-x-men-we-wont-see-in-the-movies/ + */ + public static String stripScript(final String text) { + if (text == null) { + return null; + } + + StringBuilder sb = new StringBuilder(text); + int start = sb.indexOf(" -1) { + int end = sb.indexOf("", start); + if (end == -1) { + return sb.toString(); + } + sb.delete(start, end + 9); + start = sb.indexOf(",

    ,
    tags and replacing Emoticons with Emojis + */ + public static SpannableStringBuilder fromHtml(String source, WPImageGetter wpImageGetter) { + SpannableStringBuilder html; + try { + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, new WPHtmlTagHandler()); + } catch (RuntimeException runtimeException) { + // In case our tag handler fails + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, null); + } + Emoticons.replaceEmoticonsWithEmoji(html); + QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class); + for (QuoteSpan span : spans) { + html.setSpan(new WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags(span)); + html.setSpan(new ForegroundColorSpan(0xFF666666), html.getSpanStart(span), html.getSpanEnd(span), + html.getSpanFlags(span)); + html.removeSpan(span); + } + return html; + } + + public static Spanned fromHtml(String source) { + return fromHtml(source, null); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java new file mode 100644 index 000000000000..31dadc911124 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java @@ -0,0 +1,554 @@ +package org.wordpress.android.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.widget.ImageView; + +import org.apache.http.HttpEntity; +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 java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; + +public class ImageUtils { + public static int[] getImageSize(Uri uri, Context context){ + String path = null; + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + if (uri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(uri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + path = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(path)) { + //The file isn't ContentResolver, or it can't be access by ContentResolver. Try to access the file directly. + path = uri.toString().replace("content://media", ""); + path = path.replace("file://", ""); + } + + BitmapFactory.decodeFile(path, options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + return new int[]{imageWidth, imageHeight}; + } + + // Read the orientation from ContentResolver. If it fails, read from EXIF. + public static int getImageOrientation(Context ctx, String filePath) { + Uri curStream; + int orientation = 0; + + // Remove file protocol + filePath = filePath.replace("file://", ""); + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + try { + Cursor cur = ctx.getContentResolver().query(curStream, new String[]{MediaStore.Images.Media.ORIENTATION}, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + orientation = cur.getInt(cur.getColumnIndex(MediaStore.Images.Media.ORIENTATION)); + } + cur.close(); + } + } catch (Exception errReadingContentResolver) { + AppLog.e(AppLog.T.UTILS, errReadingContentResolver); + } + + if (orientation == 0) { + orientation = getExifOrientation(filePath); + } + + return orientation; + } + + + public static int getExifOrientation(String path) { + ExifInterface exif; + try { + exif = new ExifInterface(path); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, e); + return 0; + } + + int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + + switch (exifOrientation) { + case ExifInterface.ORIENTATION_NORMAL: + return 0; + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } + + public static Bitmap downloadBitmap(String url) { + final DefaultHttpClient client = new DefaultHttpClient(); + + final HttpGet getRequest = new HttpGet(url); + + try { + HttpResponse response = client.execute(getRequest); + final int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error " + statusCode + + " while retrieving bitmap from " + url); + return null; + } + + final HttpEntity entity = response.getEntity(); + if (entity != null) { + InputStream inputStream = null; + try { + inputStream = entity.getContent(); + return BitmapFactory.decodeStream(inputStream); + } finally { + if (inputStream != null) { + inputStream.close(); + } + entity.consumeContent(); + } + } + } catch (Exception e) { + // Could provide a more explicit error message for IOException or + // IllegalStateException + getRequest.abort(); + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error while retrieving bitmap from " + url); + } + return null; + } + + /** From http://developer.android.com/training/displaying-bitmaps/load-bitmap.html **/ + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + // Calculate ratios of height and width to requested height and width + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + return inSampleSize; + } + + + public interface BitmapWorkerCallback { + public void onBitmapReady(String filePath, ImageView imageView, Bitmap bitmap); + } + + public static class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private final BitmapWorkerCallback callback; + private int targetWidth; + private int targetHeight; + private String path; + + public BitmapWorkerTask(ImageView imageView, int width, int height, BitmapWorkerCallback callback) { + // Use a WeakReference to ensure the ImageView can be garbage collected + imageViewReference = new WeakReference(imageView); + this.callback = callback; + targetWidth = width; + targetHeight = height; + } + + // Decode image in background. + @Override + protected Bitmap doInBackground(String... params) { + path = params[0]; + + BitmapFactory.Options bfo = new BitmapFactory.Options(); + bfo.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, bfo); + + bfo.inSampleSize = calculateInSampleSize(bfo, targetWidth, targetHeight); + bfo.inJustDecodeBounds = false; + + // get proper rotation + int bitmapWidth = 0; + int bitmapHeight = 0; + try { + File f = new File(path); + ExifInterface exif = new ExifInterface(f.getPath()); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + int angle = 0; + if (orientation == ExifInterface.ORIENTATION_NORMAL) { // no need to rotate + return BitmapFactory.decodeFile(path, bfo); + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_90) { + angle = 90; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) { + angle = 180; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) { + angle = 270; + } + + Matrix mat = new Matrix(); + mat.postRotate(angle); + + try { + Bitmap bmp = BitmapFactory.decodeStream(new FileInputStream(f), null, bfo); + if (bmp == null) { + AppLog.e(AppLog.T.UTILS, "can't decode bitmap: " + f.getPath()); + return null; + } + bitmapWidth = bmp.getWidth(); + bitmapHeight = bmp.getHeight(); + return Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), mat, true); + } catch (OutOfMemoryError oom) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + oom); + } + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Error in setting image", e); + } + + return null; + } + + // Once complete, see if ImageView is still around and set bitmap. + @Override + protected void onPostExecute(Bitmap bitmap) { + if (imageViewReference == null || bitmap == null) + return; + + final ImageView imageView = imageViewReference.get(); + + if (callback != null) + callback.onBitmapReady(path, imageView, bitmap); + + } + } + + + public static String getTitleForWPImageSpan(Context ctx, String filePath) { + if (filePath == null) + return null; + + Uri curStream; + String title; + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + if (filePath.contains("video")) { + return "Video"; + } else { + String[] projection = new String[] { MediaStore.Images.Thumbnails.DATA }; + + Cursor cur; + try { + cur = ctx.getContentResolver().query(curStream, projection, null, null, null); + } catch (Exception e1) { + AppLog.e(AppLog.T.UTILS, e1); + return null; + } + File jpeg; + if (cur != null) { + String thumbData = ""; + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + thumbData = cur.getString(dataColumn); + } + cur.close(); + if (thumbData == null) { + return null; + } + jpeg = new File(thumbData); + } else { + String path = filePath.toString().replace("file://", ""); + jpeg = new File(path); + } + title = jpeg.getName(); + return title; + } + } + + /** + * Resizes an image to be placed in the Post Content Editor + * + * @return resized bitmap + */ + public static Bitmap getWPImageSpanThumbnailFromFilePath(Context context, String filePath, int targetWidth) { + if (filePath == null || context == null) { + return null; + } + + Uri curUri; + if (!filePath.contains("content://")) { + curUri = Uri.parse("content://media" + filePath); + } else { + curUri = Uri.parse(filePath); + } + + if (filePath.contains("video")) { + // Load the video thumbnail from the MediaStore + int videoId = 0; + try { + videoId = Integer.parseInt(curUri.getLastPathSegment()); + } catch (NumberFormatException e) { + } + ContentResolver crThumb = context.getContentResolver(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 1; + Bitmap videoThumbnail = MediaStore.Video.Thumbnails.getThumbnail(crThumb, videoId, MediaStore.Video.Thumbnails.MINI_KIND, + options); + if (videoThumbnail != null) { + return getScaledBitmapAtLongestSide(videoThumbnail, targetWidth); + } else { + return null; + } + } else { + // Create resized bitmap + int rotation = getImageOrientation(context, filePath); + byte[] bytes = createThumbnailFromUri(context, curUri, targetWidth, null, rotation); + + if (bytes != null && bytes.length > 0) { + try { + Bitmap resizedBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (resizedBitmap != null) { + return getScaledBitmapAtLongestSide(resizedBitmap, targetWidth); + } + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + } + } + + return null; + } + + /* + Resize a bitmap to the targetSize on its longest side. + */ + public static Bitmap getScaledBitmapAtLongestSide(Bitmap bitmap, int targetSize) { + if (bitmap.getWidth() <= targetSize && bitmap.getHeight() <= targetSize) { + // Do not resize. + return bitmap; + } + + int targetWidth, targetHeight; + if (bitmap.getHeight() > bitmap.getWidth()) { + // Resize portrait bitmap + targetHeight = targetSize; + float percentage = (float) targetSize / bitmap.getHeight(); + targetWidth = (int)(bitmap.getWidth() * percentage); + } else { + // Resize landscape or square image + targetWidth = targetSize; + float percentage = (float) targetSize / bitmap.getWidth(); + targetHeight = (int)(bitmap.getHeight() * percentage); + } + + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true); + } + + /** + * nbradbury - 21-Feb-2014 - similar to createThumbnail but more efficient since it doesn't + * require passing the full-size image as an array of bytes[] + */ + public static byte[] createThumbnailFromUri(Context context, + Uri imageUri, + int maxWidth, + String fileExtension, + int rotation) { + if (context == null || imageUri == null || maxWidth <= 0) + return null; + + String filePath = null; + if (imageUri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(imageUri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + filePath = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(filePath)) { + //access the file directly + filePath = imageUri.toString().replace("content://media", ""); + filePath = filePath.replace("file://", ""); + } + + // get just the image bounds + BitmapFactory.Options optBounds = new BitmapFactory.Options(); + optBounds.inJustDecodeBounds = true; + + try { + BitmapFactory.decodeFile(filePath, optBounds); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + // determine correct scale value (should be power of 2) + // http://stackoverflow.com/questions/477572/android-strange-out-of-memory-issue/3549021#3549021 + int scale = 1; + if (maxWidth > 0 && optBounds.outWidth > maxWidth) { + double d = Math.pow(2, (int) Math.round(Math.log(maxWidth / (double) optBounds.outWidth) / Math.log(0.5))); + scale = (int) d; + } + + BitmapFactory.Options optActual = new BitmapFactory.Options(); + optActual.inSampleSize = scale; + + // Get the roughly resized bitmap + Bitmap bmpResized; + try { + bmpResized = BitmapFactory.decodeFile(filePath, optActual); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + if (bmpResized == null) + return null; + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + // Now calculate exact scale in order to resize accurately + float percentage = (float) maxWidth / bmpResized.getWidth(); + float proportionateHeight = bmpResized.getHeight() * percentage; + int finalHeight = (int) Math.rint(proportionateHeight); + + float scaleWidth = ((float) maxWidth) / bmpResized.getWidth(); + float scaleHeight = ((float) finalHeight) / bmpResized.getHeight(); + + float scaleBy = Math.min(scaleWidth, scaleHeight); + + // Resize the bitmap to exact size + Matrix matrix = new Matrix(); + matrix.postScale(scaleBy, scaleBy); + + // apply rotation + if (rotation != 0) { + matrix.setRotate(rotation); + } + + Bitmap.CompressFormat fmt; + if (fileExtension != null && fileExtension.equalsIgnoreCase("png")) { + fmt = Bitmap.CompressFormat.PNG; + } else { + fmt = Bitmap.CompressFormat.JPEG; + } + + final Bitmap bmpRotated; + try { + bmpRotated = Bitmap.createBitmap(bmpResized, 0, 0, bmpResized.getWidth(), bmpResized.getHeight(), matrix, + true); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + bmpRotated.compress(fmt, 100, stream); + bmpResized.recycle(); + bmpRotated.recycle(); + + return stream.toByteArray(); + } + + public static Bitmap getCircularBitmap(final Bitmap bitmap) { + if (bitmap==null) + return null; + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawOval(rectF, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + // outline + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawOval(rectF, paint); + + return output; + } + + public static Bitmap getRoundedEdgeBitmap(final Bitmap bitmap, int radius) { + if (bitmap == null) { + return null; + } + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawRoundRect(rectF, radius, radius, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawRoundRect(rectF, radius, radius, paint); + + return output; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java new file mode 100644 index 000000000000..199fba703db0 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java @@ -0,0 +1,236 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +public class JSONUtil { + private static String QUERY_SEPERATOR="."; + private static String QUERY_ARRAY_INDEX_START="["; + private static String QUERY_ARRAY_INDEX_END="]"; + private static String QUERY_ARRAY_FIRST="first"; + private static String QUERY_ARRAY_LAST="last"; + + private static final String JSON_NULL_STR = "null"; + + private static final String TAG="JSONUtil"; + /** + * Given a JSONObject and a key path (e.g property.child) and a default it will + * traverse the object graph and pull out the desired property + */ + public static U queryJSON(JSONObject source, String query, U defaultObject) { + int nextSeperator = query.indexOf(QUERY_SEPERATOR); + int nextIndexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + if (nextSeperator == -1 && nextIndexStart == -1) { + // last item let's get it + try { + if (!source.has(query)) { + return defaultObject; + } + Object result = source.get(query); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + int endQuery; + if (nextSeperator == -1 || nextIndexStart == -1) { + endQuery = Math.max(nextSeperator, nextIndexStart); + } else { + endQuery = Math.min(nextSeperator, nextIndexStart); + } + String nextQuery = query.substring(endQuery); + String key = query.substring(0, endQuery); + try { + if (source == null) { + return defaultObject; + } + if (nextQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(key), nextQuery.substring(1), defaultObject); + } else if (nextQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(key), nextQuery, defaultObject); + } else if (!nextQuery.equals("")) { + return defaultObject; + } + Object result = source.get(key); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Given a JSONArray and a query (e.g. [0].property) it will traverse the array and + * pull out the requested property. + * + * Acceptable indexes include negative numbers to reference items from the end of + * the list as well as "last" and "first" as more explicit references to "0" and "-1" + */ + public static U queryJSON(JSONArray source, String query, U defaultObject){ + // query must start with [ have an index and then have ] + int indexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + int indexEnd = query.indexOf(QUERY_ARRAY_INDEX_END); + if (indexStart == -1 || indexEnd == -1 || indexStart > indexEnd) { + return defaultObject; + } + // get "index" from "[index]" + String indexStr = query.substring(indexStart + 1, indexEnd); + int index; + if (indexStr.equals(QUERY_ARRAY_FIRST)) { + index = 0; + } else if (indexStr.equals(QUERY_ARRAY_LAST)) { + index = -1; + } else { + index = Integer.parseInt(indexStr); + } + if (index < 0) { + index = source.length() + index; + } + // copy remaining query + String remainingQuery = query.substring(indexEnd + 1); + try { + if (remainingQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(index), remainingQuery, defaultObject); + } else if (remainingQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(index), remainingQuery.substring(1), defaultObject); + } else if (!remainingQuery.equals("")) { + // TODO throw an exception since the query isn't valid? + AppLog.w(T.UTILS, String.format("Incorrect query for next object %s", remainingQuery)); + return defaultObject; + } + Object result = source.get(index); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to "+defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Convert a JSONArray (expected to contain strings) in a string list + */ + public static ArrayList fromJSONArrayToStringList(JSONArray jsonArray) { + ArrayList stringList = new ArrayList(); + for (int i = 0; i < jsonArray.length(); i++) { + try { + stringList.add(jsonArray.getString(i)); + } catch (JSONException e) { + AppLog.e(T.UTILS, e); + } + } + return stringList; + } + + /** + * Convert a string list in a JSONArray + */ + public static JSONArray fromStringListToJSONArray(ArrayList stringList) { + JSONArray jsonArray = new JSONArray(); + if (stringList != null) { + for (int i = 0; i < stringList.size(); i++) { + jsonArray.put(stringList.get(i)); + } + } + return jsonArray; + } + + /* + * wrapper for JSONObject.optString() which handles "null" values + */ + public static String getString(JSONObject json, String name) { + String value = json.optString(name); + // return empty string for "null" + if (JSON_NULL_STR.equals(value)) + return ""; + return value; + } + + /* + * use with strings that contain HTML entities + */ + public static String getStringDecoded(JSONObject json, String name) { + String value = getString(json, name); + return HtmlUtils.fastUnescapeHtml(value); + } + + /* + * replacement for JSONObject.optBoolean() - optBoolean() only checks for "true" and "false", + * but our API sometimes uses "0" to denote false + */ + public static boolean getBool(JSONObject json, String name) { + String value = getString(json, name); + if (TextUtils.isEmpty(value)) + return false; + if (value.equals("0")) + return false; + if (value.equalsIgnoreCase("false")) + return false; + return true; + } + + /* + * returns the JSONObject child of the passed parent that matches the passed query + * this is basically an "optJSONObject" that supports nested queries, for example: + * + * getJSONChild("meta/data/site") + * + * would find this: + * + * "meta": { + * "data": { + * "site": { + * "ID": 3584907, + * "name": "WordPress.com News", + * } + * } + * } + */ + public static JSONObject getJSONChild(final JSONObject jsonParent, final String query) { + if (jsonParent == null || TextUtils.isEmpty(query)) + return null; + String[] names = query.split("/"); + JSONObject jsonChild = null; + for (int i = 0; i < names.length; i++) { + if (jsonChild == null) { + jsonChild = jsonParent.optJSONObject(names[i]); + } else { + jsonChild = jsonChild.optJSONObject(names[i]); + } + if (jsonChild == null) + return null; + } + return jsonChild; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java new file mode 100644 index 000000000000..d60e9da6c6b2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java @@ -0,0 +1,36 @@ +package org.wordpress.android.util; + +import android.view.View; +import android.widget.ListView; + +public class ListScrollPositionManager { + private int mSelectedPosition; + private int mListViewScrollStateIndex; + private int mListViewScrollStateOffset; + private ListView mListView; + private boolean mSetSelection; + + public ListScrollPositionManager(ListView listView, boolean setSelection) { + mListView = listView; + mSetSelection = setSelection; + } + + public void saveScrollOffset() { + mListViewScrollStateIndex = mListView.getFirstVisiblePosition(); + View view = mListView.getChildAt(0); + mListViewScrollStateOffset = 0; + if (view != null) { + mListViewScrollStateOffset = view.getTop(); + } + if (mSetSelection) { + mSelectedPosition = mListView.getCheckedItemPosition(); + } + } + + public void restoreScrollOffset() { + mListView.setSelectionFromTop(mListViewScrollStateIndex, mListViewScrollStateOffset); + if (mSetSelection) { + mListView.setItemChecked(mSelectedPosition, true); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java new file mode 100644 index 000000000000..12439fd28c87 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java @@ -0,0 +1,132 @@ +//This Handy-Dandy class acquired and tweaked from http://stackoverflow.com/a/3145655/309558 +package org.wordpress.android.util; + +import java.util.Timer; +import java.util.TimerTask; + +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; + +public class LocationHelper { + Timer timer1; + LocationManager lm; + LocationResult locationResult; + boolean gps_enabled = false; + boolean network_enabled = false; + + public boolean getLocation(Context context, LocationResult result) { + locationResult = result; + if (lm == null) + lm = (LocationManager) context + .getSystemService(Context.LOCATION_SERVICE); + + // exceptions will be thrown if provider is not permitted. + try { + gps_enabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch (Exception ex) { + } + try { + network_enabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } catch (Exception ex) { + } + + // don't start listeners if no provider is enabled + if (!gps_enabled && !network_enabled) + return false; + + if (gps_enabled) + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListenerGps); + + if (network_enabled) + lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListenerNetwork); + + timer1 = new Timer(); + timer1.schedule(new GetLastLocation(), 30000); + return true; + } + + LocationListener locationListenerGps = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerNetwork); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + LocationListener locationListenerNetwork = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerGps); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + class GetLastLocation extends TimerTask { + @Override + public void run() { + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + + Location net_loc = null, gps_loc = null; + if (gps_enabled) + gps_loc = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (network_enabled) + net_loc = lm + .getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + + // if there are both values use the latest one + if (gps_loc != null && net_loc != null) { + if (gps_loc.getTime() > net_loc.getTime()) + locationResult.gotLocation(gps_loc); + else + locationResult.gotLocation(net_loc); + return; + } + + if (gps_loc != null) { + locationResult.gotLocation(gps_loc); + return; + } + if (net_loc != null) { + locationResult.gotLocation(net_loc); + return; + } + locationResult.gotLocation(null); + } + } + + public static abstract class LocationResult { + public abstract void gotLocation(Location location); + } + + public void cancelTimer() { + if (timer1 != null) { + timer1.cancel(); + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java new file mode 100644 index 000000000000..981e537d257a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java @@ -0,0 +1,79 @@ +package org.wordpress.android.util; + +import java.util.Date; +import java.util.Map; + +/** + * wrappers for extracting values from a Map object + */ +public class MapUtils { + /* + * returns a String value for the passed key in the passed map + * always returns "" instead of null + */ + public static String getMapStr(final Map map, final String key) { + if (map == null || key == null || !map.containsKey(key) || map.get(key) == null) { + return ""; + } + return map.get(key).toString(); + } + + /* + * returns an int value for the passed key in the passed map + * defaultValue is returned if key doesn't exist or isn't a number + */ + public static int getMapInt(final Map map, final String key) { + return getMapInt(map, key, 0); + } + public static int getMapInt(final Map map, final String key, int defaultValue) { + try { + return Integer.parseInt(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * long version of above + */ + public static long getMapLong(final Map map, final String key) { + return getMapLong(map, key, 0); + } + public static long getMapLong(final Map map, final String key, long defaultValue) { + try { + return Long.parseLong(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * returns a date object from the passed key in the passed map + * returns null if key doesn't exist or isn't a date + */ + public static Date getMapDate(final Map map, final String key) { + if (map==null || key==null || !map.containsKey(key)) + return null; + try { + return (Date) map.get(key); + } catch (ClassCastException e) { + return null; + } + } + + /* + * returns a boolean value from the passed key in the passed map + * returns true unless key doesn't exist, or the value is "0" or "false" + */ + public static boolean getMapBool(final Map map, final String key) { + String value = getMapStr(map, key); + if (value.isEmpty()) + return false; + if (value.startsWith("0")) // handles "0" and "0.0" + return false; + if (value.equalsIgnoreCase("false")) + return false; + // all other values are assume to be true + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java new file mode 100644 index 000000000000..497d756ee377 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java @@ -0,0 +1,96 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +/** + * routines related to the Photon API + * http://developer.wordpress.com/docs/photon/ + */ +public class PhotonUtils { + private PhotonUtils() { + throw new AssertionError(); + } + + /* + * gravatars often contain the ?s= parameter which determines their size - detect this and + * replace it with a new ?s= parameter which requests the avatar at the exact size needed + */ + public static String fixAvatar(final String imageUrl, int avatarSz) { + if (TextUtils.isEmpty(imageUrl)) + return ""; + + // if this isn't a gravatar image, return as resized photon image url + if (!imageUrl.contains("gravatar.com")) + return getPhotonImageUrl(imageUrl, avatarSz, avatarSz); + + // remove all other params, then add query string for size and "mystery man" default + return UrlUtils.removeQuery(imageUrl) + String.format("?s=%d&d=mm", avatarSz); + } + + /* + * returns true if the passed url is an obvious "mshots" url + */ + public static boolean isMshotsUrl(final String imageUrl) { + return (imageUrl != null && imageUrl.contains("/mshots/")); + } + + /* + * returns a photon url for the passed image with the resize query set to the passed dimensions + */ + public static String getPhotonImageUrl(String imageUrl, int width, int height) { + if (TextUtils.isEmpty(imageUrl)) { + return ""; + } + + // make sure it's valid + int schemePos = imageUrl.indexOf("://"); + if (schemePos == -1) { + return imageUrl; + } + + // remove existing query string since it may contain params that conflict with the passed ones + imageUrl = UrlUtils.removeQuery(imageUrl); + + // don't use with GIFs - photon breaks animated GIFs, and sometimes returns a GIF that + // can't be read by BitmapFactory.decodeByteArray (used by Volley in ImageRequest.java + // to decode the downloaded image) + // ex: http://i0.wp.com/lusianne.files.wordpress.com/2013/08/193.gif?resize=768,320 + if (imageUrl.endsWith(".gif")) { + return imageUrl; + } + + // if this is an "mshots" url, skip photon and return it with a query that sets the width/height + // (these are screenshots of the blog that often appear in freshly pressed posts) + // see http://wp.tutsplus.com/tutorials/how-to-generate-website-screenshots-for-your-wordpress-site/ + // ex: http://s.wordpress.com/mshots/v1/http%3A%2F%2Fnickbradbury.com?w=600 + if (isMshotsUrl(imageUrl)) { + return imageUrl + String.format("?w=%d&h=%d", width, height); + } + + // if both width & height are passed use the "resize" param, use only "w" or "h" if just + // one of them is set, otherwise no query string + final String query; + if (width > 0 && height > 0) { + query = String.format("?resize=%d,%d", width, height); + } else if (width > 0) { + query = String.format("?w=%d", width); + } else if (height > 0) { + query = String.format("?h=%d", height); + } else { + query = ""; + } + + // return passed url+query if it's already a photon url + if (imageUrl.contains(".wp.com")) { + if (imageUrl.contains("i0.wp.com") || imageUrl.contains("i1.wp.com") || imageUrl.contains("i2.wp.com")) + return imageUrl + query; + } + + // must use https for https image urls + if (UrlUtils.isHttps(imageUrl)) { + return "https://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } else { + return "http://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java new file mode 100644 index 000000000000..251db2a3b7fb --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java @@ -0,0 +1,91 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.SystemClock; + +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +/** + * forked from android.util.TimingLogger to use AppLog instead of Log + new static interface. + */ +public class ProfilingUtils { + private static ProfilingUtils sInstance; + + private String mLabel; + private ArrayList mSplits; + private ArrayList mSplitLabels; + + public static void start(String label) { + getInstance().reset(label); + } + + public static void split(String splitLabel) { + getInstance().addSplit(splitLabel); + } + + public static void dump() { + getInstance().dumpToLog(); + } + + private static ProfilingUtils getInstance() { + if (sInstance == null) { + sInstance = new ProfilingUtils(); + } + return sInstance; + } + + public ProfilingUtils() { + reset("init"); + } + + public void reset(String label) { + mLabel = label; + reset(); + } + + public void reset() { + if (mSplits == null) { + mSplits = new ArrayList(); + mSplitLabels = new ArrayList(); + } else { + mSplits.clear(); + mSplitLabels.clear(); + } + addSplit(null); + } + + public void addSplit(String splitLabel) { + long now = SystemClock.elapsedRealtime(); + mSplits.add(now); + mSplitLabels.add(splitLabel); + } + + public void dumpToLog() { + AppLog.d(T.PROFILING, mLabel + ": begin"); + final long first = mSplits.get(0); + long now = first; + for (int i = 1; i < mSplits.size(); i++) { + now = mSplits.get(i); + final String splitLabel = mSplitLabels.get(i); + final long prev = mSplits.get(i - 1); + AppLog.d(T.PROFILING, mLabel + ": " + (now - prev) + " ms, " + splitLabel); + } + AppLog.d(T.PROFILING, mLabel + ": end, " + (now - first) + " ms"); + } + + // Returns app version name String + public static String getVersionName(Context context) { + PackageManager pm = context.getPackageManager(); + try { + PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0); + return pi.versionName == null ? "" : pi.versionName; + } catch (PackageManager.NameNotFoundException e) { + return ""; + } + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/README.md b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md new file mode 100644 index 000000000000..62a759585e63 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md @@ -0,0 +1 @@ +# org.wordpress.android.util \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java new file mode 100644 index 000000000000..8d1b4b4379c9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java @@ -0,0 +1,121 @@ +package org.wordpress.android.util; + +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteStatement; + +import java.util.ArrayList; +import java.util.List; + +public class SqlUtils { + private SqlUtils() { + throw new AssertionError(); + } + + /* + * SQLite doesn't have a boolean datatype, so booleans are stored as 0=false, 1=true + */ + public static long boolToSql(boolean value) { + return (value ? 1 : 0); + } + public static boolean sqlToBool(int value) { + return (value != 0); + } + + public static void closeStatement(SQLiteStatement stmt) { + if (stmt != null) { + stmt.close(); + } + } + + public static void closeCursor(Cursor c) { + if (c != null && !c.isClosed()) { + c.close(); + } + } + + /* + * wrapper for DatabaseUtils.longForQuery() which returns 0 if query returns no rows + */ + public static long longForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.longForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return 0; + } + } + + public static int intForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return (int)value; + } + + public static boolean boolForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return sqlToBool((int) value); + } + + /* + * wrapper for DatabaseUtils.stringForQuery(), returns "" if query returns no rows + */ + public static String stringForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.stringForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return ""; + } + } + + /* + * returns the number of rows in the passed table + */ + public static long getRowCount(SQLiteDatabase db, String tableName) { + return DatabaseUtils.queryNumEntries(db, tableName); + } + + /* + * removes all rows from the passed table + */ + public static void deleteAllRowsInTable(SQLiteDatabase db, String tableName) { + db.delete(tableName, null, null); + } + + /* + * drop all tables from the passed SQLiteDatabase - make sure to pass a + * writable database + */ + public static boolean dropAllTables(SQLiteDatabase db) throws SQLiteException { + if (db == null) { + return false; + } + + if (db.isReadOnly()) { + throw new SQLiteException("can't drop tables from a read-only database"); + } + + List tableNames = new ArrayList(); + Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null); + if (cursor.moveToFirst()) { + do { + String tableName = cursor.getString(0); + if (!tableName.equals("android_metadata") && !tableName.equals("sqlite_sequence")) { + tableNames.add(tableName); + } + } while (cursor.moveToNext()); + } + + db.beginTransaction(); + try { + for (String tableName: tableNames) { + db.execSQL("DROP TABLE IF EXISTS " + tableName); + } + db.setTransactionSuccessful(); + return true; + } finally { + db.endTransaction(); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java new file mode 100644 index 000000000000..eca31ffd169d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java @@ -0,0 +1,278 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.TextUtils; + +import org.wordpress.android.util.AppLog.T; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class StringUtils { + public static String[] mergeStringArrays(String array1[], String array2[]) { + if (array1 == null || array1.length == 0) { + return array2; + } + if (array2 == null || array2.length == 0) { + return array1; + } + List array1List = Arrays.asList(array1); + List array2List = Arrays.asList(array2); + List result = new ArrayList(array1List); + List tmp = new ArrayList(array1List); + tmp.retainAll(array2List); + result.addAll(array2List); + return ((String[]) result.toArray(new String[result.size()])); + } + + public static String convertHTMLTagsForUpload(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String convertHTMLTagsForDisplay(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String addPTags(String source) { + String[] asploded = source.split("\n\n"); + + if (asploded.length > 0) { + StringBuilder wrappedHTML = new StringBuilder(); + for (int i = 0; i < asploded.length; i++) { + String trimmed = asploded[i].trim(); + if (trimmed.length() > 0) { + trimmed = trimmed.replace("
    ", "
    ").replace("
    ", "
    ").replace("
    \n", "
    ") + .replace("\n", "
    "); + wrappedHTML.append("

    "); + wrappedHTML.append(trimmed); + wrappedHTML.append("

    "); + } + } + return wrappedHTML.toString(); + } else { + return source; + } + } + + public static BigInteger getMd5IntHash(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] messageDigest = md.digest(input.getBytes()); + BigInteger number = new BigInteger(1, messageDigest); + return number; + } catch (NoSuchAlgorithmException e) { + AppLog.e(T.UTILS, e); + return null; + } + } + + public static String getMd5Hash(String input) { + BigInteger number = getMd5IntHash(input); + String md5 = number.toString(16); + while (md5.length() < 32) { + md5 = "0" + md5; + } + return md5; + } + + public static String unescapeHTML(String html) { + if (html != null) { + return Html.fromHtml(html).toString(); + } else { + return ""; + } + } + + /* + * nbradbury - adapted from Html.escapeHtml(), which was added in API Level 16 + * TODO: not thoroughly tested yet, so marked as private - not sure I like the way + * this replaces two spaces with " " + */ + private static String escapeHtml(final String text) { + if (text == null) { + return ""; + } + + StringBuilder out = new StringBuilder(); + int length = text.length(); + + for (int i = 0; i < length; i++) { + char c = text.charAt(i); + + if (c == '<') { + out.append("<"); + } else if (c == '>') { + out.append(">"); + } else if (c == '&') { + out.append("&"); + } else if (c > 0x7E || c < ' ') { + out.append("&#").append((int) c).append(";"); + } else if (c == ' ') { + while (i + 1 < length && text.charAt(i + 1) == ' ') { + out.append(" "); + i++; + } + + out.append(' '); + } else { + out.append(c); + } + } + + return out.toString(); + } + + /* + * returns empty string if passed string is null, otherwise returns passed string + */ + public static String notNullStr(String s) { + if (s == null) { + return ""; + } + return s; + } + + /** + * returns true if two strings are equal or two strings are null + */ + public static boolean equals(String s1, String s2) { + if (s1 == null) { + return s2 == null; + } + return s1.equals(s2); + } + + /* + * capitalizes the first letter in the passed string - based on Apache commons/lang3/StringUtils + * http://svn.apache.org/viewvc/commons/proper/lang/trunk/src/main/java/org/apache/commons/lang3/StringUtils.java?revision=1497829&view=markup + */ + public static String capitalize(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return str; + } + + char firstChar = str.charAt(0); + if (Character.isTitleCase(firstChar)) { + return str; + } + + return new StringBuilder(strLen).append(Character.toTitleCase(firstChar)).append(str.substring(1)).toString(); + } + + /* + * Wrap an image URL in a photon URL + * Check out http://developer.wordpress.com/docs/photon/ + */ + public static String getPhotonUrl(String imageUrl, int size) { + imageUrl = imageUrl.replace("http://", "").replace("https://", ""); + return "http://i0.wp.com/" + imageUrl + "?w=" + size; + } + + public static String getHost(String url) { + if (TextUtils.isEmpty(url)) { + return ""; + } + + int doubleslash = url.indexOf("//"); + if (doubleslash == -1) { + doubleslash = 0; + } else { + doubleslash += 2; + } + + int end = url.indexOf('/', doubleslash); + end = (end >= 0) ? end : url.length(); + + return url.substring(doubleslash, end); + } + + public static String replaceUnicodeSurrogateBlocksWithHTMLEntities(final String inputString) { + final int length = inputString.length(); + StringBuilder out = new StringBuilder(); // Used to hold the output. + for (int offset = 0; offset < length; ) { + final int codepoint = inputString.codePointAt(offset); + final char current = inputString.charAt(offset); + if (Character.isHighSurrogate(current) || Character.isLowSurrogate(current)) { + if (Emoticons.wpSmiliesCodePointToText.get(codepoint) != null) { + out.append(Emoticons.wpSmiliesCodePointToText.get(codepoint)); + } else { + final String htmlEscapedChar = "&#x" + Integer.toHexString(codepoint) + ";"; + out.append(htmlEscapedChar); + } + } else { + out.append(current); + } + offset += Character.charCount(codepoint); + } + return out.toString(); + } + + /** + * This method ensures that the output String has only + * valid XML unicode characters as specified by the + * XML 1.0 standard. For reference, please see + * the + * standard. This method will return an empty + * String if the input is null or empty. + * + * @param in The String whose non-valid characters we want to remove. + * @return The in String, stripped of non-valid characters. + */ + public static final String stripNonValidXMLCharacters(String in) { + StringBuilder out = new StringBuilder(); // Used to hold the output. + char current; // Used to reference the current character. + + if (in == null || ("".equals(in))) { + return ""; // vacancy test. + } + for (int i = 0; i < in.length(); i++) { + current = in.charAt(i); // NOTE: No IndexOutOfBoundsException caught here; it should not happen. + if ((current == 0x9) || + (current == 0xA) || + (current == 0xD) || + ((current >= 0x20) && (current <= 0xD7FF)) || + ((current >= 0xE000) && (current <= 0xFFFD)) || + ((current >= 0x10000) && (current <= 0x10FFFF))) { + out.append(current); + } + } + return out.toString(); + } + + /* + * simple wrapper for Integer.valueOf(string) so caller doesn't need to catch NumberFormatException + */ + public static int stringToInt(String s) { + return stringToInt(s, 0); + } + public static int stringToInt(String s, int defaultValue) { + if (s == null) + return defaultValue; + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java new file mode 100644 index 000000000000..4ba0c96ed589 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java @@ -0,0 +1,17 @@ +package org.wordpress.android.util; + +import android.content.Context; + +import org.wordpress.android.util.AppLog.T; + +public class SystemServiceFactory { + public static SystemServiceFactoryAbstract sFactory; + + public static Object get(Context context, String name) { + if (sFactory == null) { + sFactory = new SystemServiceFactoryDefault(); + } + AppLog.v(T.UTILS, "instantiate SystemService using sFactory: " + sFactory.getClass()); + return sFactory.get(context, name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java new file mode 100644 index 000000000000..a9d522db4c1c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java @@ -0,0 +1,7 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public interface SystemServiceFactoryAbstract { + public Object get(Context context, String name); +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java new file mode 100644 index 000000000000..eb488dde9bf4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java @@ -0,0 +1,9 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public class SystemServiceFactoryDefault implements SystemServiceFactoryAbstract { + public Object get(Context context, String name) { + return context.getSystemService(name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java new file mode 100644 index 000000000000..9b99c6ea53e1 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java @@ -0,0 +1,37 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.view.Gravity; +import android.widget.Toast; + +/** + * Provides a simplified way to show toast messages without having to create the toast, set the + * desired gravity, etc. + */ +public class ToastUtils { + public enum Duration {SHORT, LONG} + + private ToastUtils() { + throw new AssertionError(); + } + + public static Toast showToast(Context context, int stringResId) { + return showToast(context, stringResId, Duration.SHORT); + } + + public static Toast showToast(Context context, int stringResId, Duration duration) { + return showToast(context, context.getString(stringResId), duration); + } + + public static Toast showToast(Context context, String text) { + return showToast(context, text, Duration.SHORT); + } + + public static Toast showToast(Context context, String text, Duration duration) { + Toast toast = Toast.makeText(context, text, + (duration == Duration.SHORT ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG)); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + return toast; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java new file mode 100644 index 000000000000..4438b8950158 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java @@ -0,0 +1,165 @@ +package org.wordpress.android.util; + +import android.net.Uri; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import java.io.UnsupportedEncodingException; +import java.net.IDN; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.Charset; + +public class UrlUtils { + public static String urlEncode(final String text) { + try { + return URLEncoder.encode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String urlDecode(final String text) { + try { + return URLDecoder.decode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String getDomainFromUrl(final String urlString) { + if (urlString == null) { + return ""; + } + Uri uri = Uri.parse(urlString); + return uri.getHost(); + } + + /** + * Convert IDN names to punycode if necessary + */ + public static String convertUrlToPunycodeIfNeeded(String url) { + if (!Charset.forName("US-ASCII").newEncoder().canEncode(url)) { + if (url.toLowerCase().startsWith("http://")) { + url = "http://" + IDN.toASCII(url.substring(7)); + } else if (url.toLowerCase().startsWith("https://")) { + url = "https://" + IDN.toASCII(url.substring(8)); + } else { + url = IDN.toASCII(url); + } + } + return url; + } + + public static String addUrlSchemeIfNeeded(String url, boolean isHTTPS) { + if (url == null) { + return null; + } + + if (!URLUtil.isValidUrl(url)) { + if (!(url.toLowerCase().startsWith("http://")) && !(url.toLowerCase().startsWith("https://"))) { + url = (isHTTPS ? "https" : "http") + "://" + url; + } + } + + return url; + } + + /** + * normalizes a URL, primarily for comparison purposes, for example so that + * normalizeUrl("http://google.com/") = normalizeUrl("http://google.com") + */ + public static String normalizeUrl(final String urlString) { + if (urlString == null) { + return null; + } + + // this routine is called from some performance-critical code and creating a URI from a string + // is slow, so skip it when possible - if we know it's not a relative path (and 99.9% of the + // time it won't be for our purposes) then we can normalize it without java.net.URI.normalize() + if (urlString.startsWith("http") && !urlString.contains("build/intermediates/exploded-aar/org.wordpress/graphview/3.1.1")) { + // return without a trailing slash + if (urlString.endsWith("/")) { + return urlString.substring(0, urlString.length() - 1); + } + return urlString; + } + + // url is relative, so fall back to using slower java.net.URI normalization + try { + URI uri = URI.create(urlString); + return uri.normalize().toString(); + } catch (IllegalArgumentException e) { + return urlString; + } + } + + /** + * returns the passed url without the query parameters + */ + public static String removeQuery(final String urlString) { + if (urlString == null) { + return null; + } + int pos = urlString.indexOf("?"); + if (pos == -1) { + return urlString; + } + return urlString.substring(0, pos); + } + + /** + * returns true if passed url is https: + */ + public static boolean isHttps(final String urlString) { + return (urlString != null && urlString.startsWith("https:")); + } + + /** + * returns https: version of passed http: url + */ + public static String makeHttps(final String urlString) { + if (urlString == null || !urlString.startsWith("http:")) { + return urlString; + } + return "https:" + urlString.substring(5, urlString.length()); + } + + /** + * see http://stackoverflow.com/a/8591230/1673548 + */ + public static String getUrlMimeType(final String urlString) { + if (urlString == null) { + return null; + } + + String extension = MimeTypeMap.getFileExtensionFromUrl(urlString); + if (extension == null) { + return null; + } + + MimeTypeMap mime = MimeTypeMap.getSingleton(); + String mimeType = mime.getMimeTypeFromExtension(extension); + if (mimeType == null) { + return null; + } + + return mimeType; + } + + /** + * returns false if the url is not valid or if the url host is null, else true + */ + public static boolean isValidUrlAndHostNotNull(String url) { + try { + URI uri = URI.create(url); + if (uri.getHost() == null) { + return false; + } + } catch (IllegalArgumentException e) { + return false; + } + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java new file mode 100644 index 000000000000..dae02b4f01b3 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.util.Patterns; + +import java.util.regex.Pattern; + +public class UserEmail { + /** + * Get primary account and return its name if it matches the email address pattern. + * + * @return primary account email address if it can be found or empty string else. + */ + public static String getPrimaryEmail(Context context) { + try { + AccountManager accountManager = AccountManager.get(context); + if (accountManager == null) + return ""; + Account[] accounts = accountManager.getAccounts(); + Pattern emailPattern = Patterns.EMAIL_ADDRESS; + for (Account account : accounts) { + // make sure account.name is an email address before adding to the list + if (emailPattern.matcher(account.name).matches()) { + return account.name; + } + } + return ""; + } catch (SecurityException e) { + // exception will occur if app doesn't have GET_ACCOUNTS permission + return ""; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java new file mode 100644 index 000000000000..6e695db454da --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java @@ -0,0 +1,47 @@ +package org.wordpress.android.util; + +//See: http://stackoverflow.com/a/11024200 +public class Version implements Comparable { + private String version; + + public final String get() { + return this.version; + } + + public Version(String version) { + if(version == null) + throw new IllegalArgumentException("Version can not be null"); + if(!version.matches("[0-9]+(\\.[0-9]+)*")) + throw new IllegalArgumentException("Invalid version format"); + this.version = version; + } + + @Override public int compareTo(Version that) { + if(that == null) + return 1; + String[] thisParts = this.get().split("\\."); + String[] thatParts = that.get().split("\\."); + int length = Math.max(thisParts.length, thatParts.length); + for(int i = 0; i < length; i++) { + int thisPart = i < thisParts.length ? + Integer.parseInt(thisParts[i]) : 0; + int thatPart = i < thatParts.length ? + Integer.parseInt(thatParts[i]) : 0; + if(thisPart < thatPart) + return -1; + if(thisPart > thatPart) + return 1; + } + return 0; + } + + @Override public boolean equals(Object that) { + if(this == that) + return true; + if(that == null) + return false; + if(this.getClass() != that.getClass()) + return false; + return this.compareTo((Version) that) == 0; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java new file mode 100644 index 000000000000..fa96a998a23c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java @@ -0,0 +1,59 @@ +package org.wordpress.android.util; + +import android.text.Editable; +import android.text.Html; +import android.text.style.BulletSpan; +import android.text.style.LeadingMarginSpan; + +import org.xml.sax.XMLReader; + +import java.util.Vector; + +/** + * Handle tags that the Html class doesn't understand + * Tweaked from source at http://stackoverflow.com/questions/4044509/android-how-to-use-the-html-taghandler + */ +public class WPHtmlTagHandler implements Html.TagHandler { + private int mListItemCount = 0; + private Vector mListParents = new Vector(); + + @Override + public void handleTag(final boolean opening, final String tag, Editable output, + final XMLReader xmlReader) { + if (tag.equals("ul") || tag.equals("ol") || tag.equals("dd")) { + if (opening) { + mListParents.add(tag); + } else { + mListParents.remove(tag); + } + mListItemCount = 0; + } else if (tag.equals("li") && !opening) { + handleListTag(output); + } + } + + private void handleListTag(Editable output) { + if (mListParents.lastElement().equals("ul")) { + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.setSpan(new BulletSpan(15 * mListParents.size()), start, output.length(), 0); + } else if (mListParents.lastElement().equals("ol")) { + mListItemCount++; + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.insert(start, mListItemCount + ". "); + output.setSpan(new LeadingMarginSpan.Standard(15 * mListParents.size()), start, + output.length(), 0); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java new file mode 100644 index 000000000000..60b0d605b4d4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java @@ -0,0 +1,198 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.Html; +import android.text.TextUtils; +import android.widget.TextView; + +import com.android.volley.VolleyError; +import com.android.volley.toolbox.ImageLoader; + +import org.wordpress.android.util.AppLog.T; + +import java.lang.ref.WeakReference; + +/** + * ImageGetter for Html.fromHtml() + * adapted from existing ImageGetter code in NoteCommentFragment + */ +public class WPImageGetter implements Html.ImageGetter { + private WeakReference mWeakView; + private int mMaxSize; + private ImageLoader mImageLoader; + private Drawable mLoadingDrawable; + private Drawable mFailedDrawable; + + public WPImageGetter(TextView view) { + this(view, 0); + } + + public WPImageGetter(TextView view, int maxSize) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + } + + public WPImageGetter(TextView view, int maxSize, ImageLoader imageLoader, Drawable loadingDrawable, + Drawable failedDrawable) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + mImageLoader = imageLoader; + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + } + + public void setImageLoader(ImageLoader imageLoader) { + mImageLoader = imageLoader; + } + + public void setLoadingDrawable(Drawable loadingDrawable) { + mLoadingDrawable = loadingDrawable; + } + + public void setFailedDrawable(Drawable failedDrawable) { + mFailedDrawable = failedDrawable; + } + + private TextView getView() { + return mWeakView.get(); + } + + @Override + public Drawable getDrawable(String source) { + if (mImageLoader == null || mLoadingDrawable == null || mFailedDrawable == null) { + throw new RuntimeException("Developer, you need to call setImageLoader, setLoadingDrawable and setFailedDrawable"); + } + + if (TextUtils.isEmpty(source)) { + return null; + } + + // images in reader comments may skip "http:" (no idea why) so make sure to add protocol here + if (source.startsWith("//")) { + source = "http:" + source; + } + + // use Photon if a max size is requested (otherwise the full-sized image will be downloaded + // and then resized) + if (mMaxSize > 0) { + source = PhotonUtils.getPhotonImageUrl(source, mMaxSize, 0); + } + + TextView view = getView(); + // Drawable loading = view.getContext().getResources().getDrawable(R.drawable.remote_image); FIXME: here + // Drawable failed = view.getContext().getResources().getDrawable(R.drawable.remote_failed); + final RemoteDrawable remote = new RemoteDrawable(mLoadingDrawable, mFailedDrawable); + + mImageLoader.get(source, new ImageLoader.ImageListener() { + @Override + public void onErrorResponse(VolleyError error) { + remote.displayFailed(); + TextView view = getView(); + if (view != null) { + view.invalidate(); + } + } + + @Override + public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate) { + if (response.getBitmap() != null) { + // make sure view is still valid + TextView view = getView(); + if (view == null) { + AppLog.w(T.UTILS, "WPImageGetter view is invalid"); + return; + } + + Drawable drawable = new BitmapDrawable(view.getContext().getResources(), response.getBitmap()); + final int oldHeight = remote.getBounds().height(); + int maxWidth = view.getWidth() - view.getPaddingLeft() - view.getPaddingRight(); + if (mMaxSize > 0 && (maxWidth > mMaxSize || maxWidth == 0)) { + maxWidth = mMaxSize; + } + remote.setRemoteDrawable(drawable, maxWidth); + + // image is from cache? don't need to modify view height + if (isImmediate) { + return; + } + + int newHeight = remote.getBounds().height(); + view.invalidate(); + // For ICS + view.setHeight(view.getHeight() + newHeight - oldHeight); + // Pre ICS + view.setEllipsize(null); + } + } + }); + return remote; + } + + private static class RemoteDrawable extends BitmapDrawable { + protected Drawable mRemoteDrawable; + protected Drawable mLoadingDrawable; + protected Drawable mFailedDrawable; + private boolean mDidFail = false; + + public RemoteDrawable(Drawable loadingDrawable, Drawable failedDrawable) { + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + setBounds(0, 0, mLoadingDrawable.getIntrinsicWidth(), mLoadingDrawable.getIntrinsicHeight()); + } + + public void displayFailed() { + mDidFail = true; + } + + public void setBounds(int x, int y, int width, int height) { + super.setBounds(x, y, width, height); + if (mRemoteDrawable != null) { + mRemoteDrawable.setBounds(x, y, width, height); + return; + } + if (mLoadingDrawable != null) { + mLoadingDrawable.setBounds(x, y, width, height); + mFailedDrawable.setBounds(x, y, width, height); + } + } + + public void setRemoteDrawable(Drawable remote) { + mRemoteDrawable = remote; + setBounds(0, 0, mRemoteDrawable.getIntrinsicWidth(), mRemoteDrawable.getIntrinsicHeight()); + } + + public void setRemoteDrawable(Drawable remote, int maxWidth) { + // null sentinel for now + if (remote == null) { + // throw error + return; + } + mRemoteDrawable = remote; + // determine if we need to scale the image to fit in view + int imgWidth = remote.getIntrinsicWidth(); + int imgHeight = remote.getIntrinsicHeight(); + float xScale = (float) imgWidth / (float) maxWidth; + if (xScale > 1.0f) { + setBounds(0, 0, Math.round(imgWidth / xScale), Math.round(imgHeight / xScale)); + } else { + setBounds(0, 0, imgWidth, imgHeight); + } + } + + public boolean didFail() { + return mDidFail; + } + + public void draw(Canvas canvas) { + if (mRemoteDrawable != null) { + mRemoteDrawable.draw(canvas); + } else if (didFail()) { + mFailedDrawable.draw(canvas); + } else { + mLoadingDrawable.draw(canvas); + } + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java new file mode 100644 index 000000000000..37d5dfe6dee2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java @@ -0,0 +1,44 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.style.QuoteSpan; + +/** + * Customzed QuoteSpan for use in SpannableString's + */ +public class WPQuoteSpan extends QuoteSpan { + public static final int STRIPE_COLOR = 0xFF21759B; + private static final int STRIPE_WIDTH = 5; + private static final int GAP_WIDTH = 20; + + public WPQuoteSpan(){ + super(STRIPE_COLOR); + } + + @Override + public int getLeadingMargin(boolean first) { + int margin = GAP_WIDTH * 2 + STRIPE_WIDTH; + return margin; + } + + /** + * Draw a nice thick gray bar if Ice Cream Sandwhich or newer. There's a + * bug on older devices that does not respect the increased margin. + */ + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, + CharSequence text, int start, int end, boolean first, Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + + p.setStyle(Paint.Style.FILL); + p.setColor(STRIPE_COLOR); + + c.drawRect(GAP_WIDTH + x, top, x + dir * STRIPE_WIDTH, bottom, p); + + p.setStyle(style); + p.setColor(color); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java new file mode 100644 index 000000000000..6a40c6f3807b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java @@ -0,0 +1,29 @@ +package org.wordpress.android.util; + +import android.app.Activity; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.widget.ProgressBar; + +public class WPWebChromeClient extends WebChromeClient { + private ProgressBar mProgressBar; + private Activity mActivity; + + public WPWebChromeClient(Activity activity, ProgressBar progressBar) { + mProgressBar = progressBar; + mActivity = activity; + } + + public void onProgressChanged(WebView webView, int progress) { + if (!mActivity.isFinishing()) { + mActivity.setTitle(webView.getTitle()); + } + if (progress == 100) { + mProgressBar.setVisibility(View.GONE); + } else { + mProgressBar.setVisibility(View.VISIBLE); + mProgressBar.setProgress(progress); + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java new file mode 100644 index 000000000000..3fec8d91fb8b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java @@ -0,0 +1,99 @@ +package org.wordpress.android.util.ptr; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; + +import org.wordpress.android.util.R; + +import uk.co.senab.actionbarpulltorefresh.library.DefaultHeaderTransformer; +import uk.co.senab.actionbarpulltorefresh.library.sdk.Compat; + +public class PullToRefreshHeaderTransformer extends DefaultHeaderTransformer { + private View mHeaderView; + private ViewGroup mContentLayout; + private long mAnimationDuration; + private boolean mShowProgressBarOnly; + private Animation mHeaderOutAnimation; + private OnTopScrollChangedListener mOnTopScrollChangedListener; + + public interface OnTopScrollChangedListener { + public void onTopScrollChanged(boolean scrolledOnTop); + } + + public void setShowProgressBarOnly(boolean progressBarOnly) { + mShowProgressBarOnly = progressBarOnly; + } + + @Override + public void onViewCreated(Activity activity, View headerView) { + super.onViewCreated(activity, headerView); + mHeaderView = headerView; + mContentLayout = (ViewGroup) headerView.findViewById(R.id.ptr_content); + mAnimationDuration = activity.getResources().getInteger(android.R.integer.config_shortAnimTime); + } + + @Override + public boolean hideHeaderView() { + mShowProgressBarOnly = false; + return super.hideHeaderView(); + } + + @Override + public boolean showHeaderView() { + // Workaround to avoid this bug https://github.com/chrisbanes/ActionBar-PullToRefresh/issues/265 + // Note, that also remove the alpha animation + resetContentLayoutAlpha(); + + boolean changeVis = mHeaderView.getVisibility() != View.VISIBLE; + mContentLayout.setVisibility(View.VISIBLE); + if (changeVis) { + mHeaderView.setVisibility(View.VISIBLE); + AnimatorSet animSet = new AnimatorSet(); + ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(mHeaderView, "alpha", 0f, 1f); + ObjectAnimator transAnim = ObjectAnimator.ofFloat(mContentLayout, "translationY", + -mContentLayout.getHeight(), 10f); + animSet.playTogether(transAnim, alphaAnim); + animSet.play(alphaAnim); + animSet.setDuration(mAnimationDuration); + animSet.start(); + if (mShowProgressBarOnly) { + mContentLayout.setVisibility(View.INVISIBLE); + } + } + return changeVis; + } + + @Override + public void onPulled(float percentagePulled) { + super.onPulled(percentagePulled); + } + + private void resetContentLayoutAlpha() { + Compat.setAlpha(mContentLayout, 1f); + } + + @Override + public void onReset() { + super.onReset(); + // Reset the Content Layout + if (mContentLayout != null) { + Compat.setAlpha(mContentLayout, 1f); + mContentLayout.setVisibility(View.VISIBLE); + } + } + + @Override + public void onTopScrollChanged(boolean scrolledOnTop) { + if (mOnTopScrollChangedListener != null) { + mOnTopScrollChangedListener.onTopScrollChanged(scrolledOnTop); + } + } + + public void setOnTopScrollChangedListener(OnTopScrollChangedListener listener) { + mOnTopScrollChangedListener = listener; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java new file mode 100644 index 000000000000..3c7b4661955d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java @@ -0,0 +1,142 @@ +package org.wordpress.android.util.ptr; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.preference.PreferenceManager; +import android.support.v4.content.LocalBroadcastManager; +import android.view.View; + +import org.wordpress.android.util.R; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh; +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh.SetupWizard; +import uk.co.senab.actionbarpulltorefresh.library.Options; +import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout; +import uk.co.senab.actionbarpulltorefresh.library.listeners.OnRefreshListener; +import uk.co.senab.actionbarpulltorefresh.library.viewdelegates.ViewDelegate; + +public class PullToRefreshHelper implements OnRefreshListener { + public static final String BROADCAST_ACTION_REFRESH_MENU_PRESSED = "REFRESH_MENU_PRESSED"; + private static final String REFRESH_BUTTON_HIT_COUNT = "REFRESH_BUTTON_HIT_COUNT"; + private static final Set TOAST_FREQUENCY = new HashSet(Arrays.asList(1, 5, 10, 20, 40, 80, 160, + 320, 640)); + private PullToRefreshHeaderTransformer mHeaderTransformer; + private PullToRefreshLayout mPullToRefreshLayout; + private RefreshListener mRefreshListener; + private WeakReference mActivityRef; + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener) { + init(activity, pullToRefreshLayout, listener, null); + } + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + init(activity, pullToRefreshLayout, listener, viewClass); + } + + public void init(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + mActivityRef = new WeakReference(activity); + mRefreshListener = listener; + mPullToRefreshLayout = pullToRefreshLayout; + mHeaderTransformer = new PullToRefreshHeaderTransformer(); + SetupWizard setupWizard = ActionBarPullToRefresh.from(activity).options(Options.create().headerTransformer( + mHeaderTransformer).build()).allChildrenArePullable().listener(this); + if (viewClass != null) { + setupWizard.useViewDelegate(viewClass, new ViewDelegate() { + @Override + public boolean isReadyForPull(View view, float v, float v2) { + return true; + } + } + ); + } + setupWizard.setup(mPullToRefreshLayout); + } + + public void setRefreshing(boolean refreshing) { + mHeaderTransformer.setShowProgressBarOnly(refreshing); + mPullToRefreshLayout.setRefreshing(refreshing); + } + + public boolean isRefreshing() { + return mPullToRefreshLayout.isRefreshing(); + } + + @Override + public void onRefreshStarted(View view) { + mRefreshListener.onRefreshStarted(view); + } + + public interface RefreshListener { + public void onRefreshStarted(View view); + } + + public void setEnabled(boolean enabled) { + mPullToRefreshLayout.setEnabled(enabled); + } + + public void refreshAction() { + Activity activity = mActivityRef.get(); + if (activity == null) { + return; + } + setRefreshing(true); + mRefreshListener.onRefreshStarted(mPullToRefreshLayout); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + int refreshHits = preferences.getInt(REFRESH_BUTTON_HIT_COUNT, 0); + refreshHits += 1; + if (TOAST_FREQUENCY.contains(refreshHits)) { + ToastUtils.showToast(activity, R.string.ptr_tip_message, Duration.LONG); + } + Editor editor = preferences.edit(); + editor.putInt(REFRESH_BUTTON_HIT_COUNT, refreshHits); + editor.commit(); + } + + public void registerReceiver(Context context) { + if (context == null) { + return; + } + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_ACTION_REFRESH_MENU_PRESSED); + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.registerReceiver(mReceiver, filter); + } + + public void unregisterReceiver(Context context) { + if (context == null) { + return; + } + try { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.unregisterReceiver(mReceiver); + } catch (IllegalArgumentException e) { + // exception occurs if receiver already unregistered (safe to ignore) + } + } + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + if (intent.getAction().equals(BROADCAST_ACTION_REFRESH_MENU_PRESSED)) { + refreshAction(); + } + } + }; +} diff --git a/WordPressUtils/src/main/res/values/strings.xml b/WordPressUtils/src/main/res/values/strings.xml new file mode 100644 index 000000000000..2061ba880c10 --- /dev/null +++ b/WordPressUtils/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Tip: Pull down to refresh + diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..0087cd3b18659b5577cf6ad3ef61f8eb9416ebba GIT binary patch literal 51348 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

    CflOl1i4(-2^1KXo)&I5QlgjRKFQgM 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 z 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2bdda831e5af --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jul 09 11:48:51 CEST 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-1.11-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000000..91a7e269e19d --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000000..8a0b282aa688 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000000..3519745edd00 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':WordPressUtils' \ No newline at end of file