diff options
Diffstat (limited to 'libs/utils/WordPressUtils')
59 files changed, 6104 insertions, 0 deletions
diff --git a/libs/utils/WordPressUtils/README.md b/libs/utils/WordPressUtils/README.md new file mode 100644 index 000000000..62a759585 --- /dev/null +++ b/libs/utils/WordPressUtils/README.md @@ -0,0 +1 @@ +# org.wordpress.android.util
\ No newline at end of file diff --git a/libs/utils/WordPressUtils/build.gradle b/libs/utils/WordPressUtils/build.gradle new file mode 100644 index 000000000..96191a5d0 --- /dev/null +++ b/libs/utils/WordPressUtils/build.gradle @@ -0,0 +1,65 @@ +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:2.2.0' + classpath 'com.novoda:bintray-release:0.3.4' + } +} + +apply plugin: 'com.android.library' +apply plugin: 'com.novoda.bintray-release' + +repositories { + jcenter() +} + +dependencies { + compile('commons-lang:commons-lang:2.6') { + exclude group: 'commons-logging' + } + compile 'com.mcxiaoke.volley:library:1.0.18' + compile 'com.android.support:support-v13:24.2.1' +} + +android { + useLibrary 'org.apache.http.legacy' + + publishNonDefault true + + compileSdkVersion 24 + buildToolsVersion "24.0.2" + + defaultConfig { + versionName "1.14.0" + minSdkVersion 14 + targetSdkVersion 24 + } +} + +android.libraryVariants.all { variant -> + task("generate${variant.name}Javadoc", type: Javadoc) { + description "Generates Javadoc for $variant.name." + source = variant.javaCompile.source + classpath = files(variant.javaCompile.classpath.files, android.getBootClasspath()) + + options { + links "http://docs.oracle.com/javase/7/docs/api/" + } + exclude '**/R.java' + } +} + +publish { + artifactId = 'utils' + userOrg = 'wordpress-mobile' + groupId = 'org.wordpress' + uploadName = 'utils' + description = 'Utils library for Android' + publishVersion = android.defaultConfig.versionName + licences = ['MIT', 'GPL'] + website = 'https://github.com/wordpress-mobile/WordPress-Utils-Android/' + dryRun = 'false' + autoPublish = 'true' +} diff --git a/libs/utils/WordPressUtils/gradle.properties-example b/libs/utils/WordPressUtils/gradle.properties-example new file mode 100644 index 000000000..5281d935c --- /dev/null +++ b/libs/utils/WordPressUtils/gradle.properties-example @@ -0,0 +1,6 @@ +ossrhUsername=hello +ossrhPassword=world + +signing.keyId=byebye +signing.password=secret +signing.secretKeyRingFile=/home/user/secret.gpg diff --git a/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/JSONUtilsTest.java b/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/JSONUtilsTest.java new file mode 100644 index 000000000..f7c747ff7 --- /dev/null +++ b/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/JSONUtilsTest.java @@ -0,0 +1,32 @@ +package org.wordpress.android.util; + +import android.test.InstrumentationTestCase; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class JSONUtilsTest extends InstrumentationTestCase { + public void testQueryJSONNullSource1() { + JSONUtils.queryJSON((JSONObject) null, "", ""); + } + + public void testQueryJSONNullSource2() { + JSONUtils.queryJSON((JSONArray) null, "", ""); + } + + public void testQueryJSONNullQuery1() { + JSONUtils.queryJSON(new JSONObject(), null, ""); + } + + public void testQueryJSONNullQuery2() { + JSONUtils.queryJSON(new JSONArray(), null, ""); + } + + public void testQueryJSONNullReturnValue1() { + JSONUtils.queryJSON(new JSONObject(), "", null); + } + + public void testQueryJSONNullReturnValue2() { + JSONUtils.queryJSON(new JSONArray(), "", null); + } +} diff --git a/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/ShortcodeUtilsTest.java b/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/ShortcodeUtilsTest.java new file mode 100644 index 000000000..c506a452e --- /dev/null +++ b/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/ShortcodeUtilsTest.java @@ -0,0 +1,25 @@ +package org.wordpress.android.util; + +import android.test.InstrumentationTestCase; + +public class ShortcodeUtilsTest extends InstrumentationTestCase { + public void testGetVideoPressShortcodeFromId() { + assertEquals("[wpvideo abcd1234]", ShortcodeUtils.getVideoPressShortcodeFromId("abcd1234")); + } + + public void testGetVideoPressShortcodeFromNullId() { + assertEquals("", ShortcodeUtils.getVideoPressShortcodeFromId(null)); + } + + public void testGetVideoPressIdFromCorrectShortcode() { + assertEquals("abcd1234", ShortcodeUtils.getVideoPressIdFromShortCode("[wpvideo abcd1234]")); + } + + public void testGetVideoPressIdFromInvalidShortcode() { + assertEquals("", ShortcodeUtils.getVideoPressIdFromShortCode("[other abcd1234]")); + } + + public void testGetVideoPressIdFromNullShortcode() { + assertEquals("", ShortcodeUtils.getVideoPressIdFromShortCode(null)); + } +} diff --git a/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/UrlUtilsTest.java b/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/UrlUtilsTest.java new file mode 100644 index 000000000..abf7a8fae --- /dev/null +++ b/libs/utils/WordPressUtils/src/androidTest/java/org/wordpress/android/util/UrlUtilsTest.java @@ -0,0 +1,108 @@ +package org.wordpress.android.util; + +import android.test.InstrumentationTestCase; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +public class UrlUtilsTest extends InstrumentationTestCase { + public void testGetDomainFromUrlWithEmptyStringDoesNotReturnNull() { + assertNotNull(UrlUtils.getHost("")); + } + + public void testGetDomainFromUrlWithNoHostDoesNotReturnNull() { + assertNotNull(UrlUtils.getHost("wordpress")); + } + + public void testGetDomainFromUrlWithHostReturnsHost() { + String url = "http://www.wordpress.com"; + String host = UrlUtils.getHost(url); + + assertTrue(host.equals("www.wordpress.com")); + } + + public void testAppendUrlParameter1() { + String url = UrlUtils.appendUrlParameter("http://wp.com/test", "preview", "true"); + assertEquals("http://wp.com/test?preview=true", url); + } + + public void testAppendUrlParameter2() { + String url = UrlUtils.appendUrlParameter("http://wp.com/test?q=pony", "preview", "true"); + assertEquals("http://wp.com/test?q=pony&preview=true", url); + } + + public void testAppendUrlParameter3() { + String url = UrlUtils.appendUrlParameter("http://wp.com/test?q=pony#unicorn", "preview", "true"); + assertEquals("http://wp.com/test?q=pony&preview=true#unicorn", url); + } + + public void testAppendUrlParameter4() { + String url = UrlUtils.appendUrlParameter("/relative/test", "preview", "true"); + assertEquals("/relative/test?preview=true", url); + } + + public void testAppendUrlParameter5() { + String url = UrlUtils.appendUrlParameter("/relative/", "preview", "true"); + assertEquals("/relative/?preview=true", url); + } + + public void testAppendUrlParameter6() { + String url = UrlUtils.appendUrlParameter("http://wp.com/test/", "preview", "true"); + assertEquals("http://wp.com/test/?preview=true", url); + } + + public void testAppendUrlParameter7() { + String url = UrlUtils.appendUrlParameter("http://wp.com/test/?q=pony", "preview", "true"); + assertEquals("http://wp.com/test/?q=pony&preview=true", url); + } + + public void testAppendUrlParameters1() { + Map<String, String> params = new HashMap<>(); + params.put("w", "200"); + params.put("h", "300"); + String url = UrlUtils.appendUrlParameters("http://wp.com/test", params); + if (!url.equals("http://wp.com/test?h=300&w=200") && !url.equals("http://wp.com/test?w=200&h=300")) { + assertTrue("failed test on url: " + url, false); + } + } + + public void testAppendUrlParameters2() { + Map<String, String> params = new HashMap<>(); + params.put("h", "300"); + params.put("w", "200"); + String url = UrlUtils.appendUrlParameters("/relative/test", params); + if (!url.equals("/relative/test?h=300&w=200") && !url.equals("/relative/test?w=200&h=300")) { + assertTrue("failed test on url: " + url, false); + } + } + + public void testHttps1() { + assertFalse(UrlUtils.isHttps(buildURL("http://wordpress.com/xmlrpc.php"))); + } + + public void testHttps2() { + assertFalse(UrlUtils.isHttps(buildURL("http://wordpress.com#.b.com/test"))); + } + + public void testHttps3() { + assertFalse(UrlUtils.isHttps(buildURL("http://wordpress.com/xmlrpc.php"))); + } + + public void testHttps4() { + assertTrue(UrlUtils.isHttps(buildURL("https://wordpress.com"))); + } + + public void testHttps5() { + assertTrue(UrlUtils.isHttps(buildURL("https://wordpress.com/test#test"))); + } + + private URL buildURL(String address) { + URL url = null; + try { + url = new URL(address); + } catch (MalformedURLException e) {} + return url; + } +} diff --git a/libs/utils/WordPressUtils/src/main/AndroidManifest.xml b/libs/utils/WordPressUtils/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44b1dcddc --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="org.wordpress.android.util"> + <uses-permission android:name="android.permission.GET_ACCOUNTS" /> +</manifest> diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ActivityUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ActivityUtils.java new file mode 100644 index 000000000..396e06c37 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ActivityUtils.java @@ -0,0 +1,16 @@ +package org.wordpress.android.util; + +import android.app.Activity; +import android.content.Context; +import android.view.inputmethod.InputMethodManager; + +public class ActivityUtils { + public static void hideKeyboard(Activity activity) { + if (activity != null && activity.getCurrentFocus() != null) { + InputMethodManager inputManager = (InputMethodManager) activity.getSystemService( + Context.INPUT_METHOD_SERVICE); + inputManager.hideSoftInputFromWindow(activity.getCurrentFocus().getWindowToken(), + InputMethodManager.HIDE_NOT_ALWAYS); + } + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtils.java new file mode 100644 index 000000000..79b2dbced --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtils.java @@ -0,0 +1,100 @@ +/* + * 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 AlertUtils { + /** + * 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 message + */ + 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 message + * @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(); + } +}
\ No newline at end of file diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java new file mode 100644 index 000000000..be60e748e --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java @@ -0,0 +1,272 @@ +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 and 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, SUGGESTION, MAIN, SETTINGS, PLANS, PEOPLE} + + public static final String TAG = "WordPress"; + public static final int HEADER_LINE_COUNT = 2; + + private static boolean mEnableRecording = false; + + private AppLog() { + throw new AssertionError(); + } + + /** + * Capture log so it can be displayed by AppLogViewerActivity + * @param enable A boolean flag to capture log. Default is false, pass true to enable recording + */ + public static void enableRecording(boolean enable) { + mEnableRecording = enable; + } + + /** + * Sends a VERBOSE log message + * @param tag Used to identify the source of a log message. + * It usually identifies the class or activity where the log call occurs. + * @param message The message you would like logged. + */ + public static void v(T tag, String message) { + message = StringUtils.notNullStr(message); + Log.v(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.v, message); + } + + /** + * Sends a DEBUG log message + * @param tag Used to identify the source of a log message. + * It usually identifies the class or activity where the log call occurs. + * @param message The message you would like logged. + */ + public static void d(T tag, String message) { + message = StringUtils.notNullStr(message); + Log.d(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.d, message); + } + + /** + * Sends a INFO log message + * @param tag Used to identify the source of a log message. + * It usually identifies the class or activity where the log call occurs. + * @param message The message you would like logged. + */ + public static void i(T tag, String message) { + message = StringUtils.notNullStr(message); + Log.i(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.i, message); + } + + /** + * Sends a WARN log message + * @param tag Used to identify the source of a log message. + * It usually identifies the class or activity where the log call occurs. + * @param message The message you would like logged. + */ + public static void w(T tag, String message) { + message = StringUtils.notNullStr(message); + Log.w(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.w, message); + } + + /** + * Sends a ERROR log message + * @param tag Used to identify the source of a log message. + * It usually identifies the class or activity where the log call occurs. + * @param message The message you would like logged. + */ + public static void e(T tag, String message) { + message = StringUtils.notNullStr(message); + Log.e(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.e, message); + } + + /** + * Send a ERROR log message and log the exception. + * @param tag Used to identify the source of a log message. + * It usually identifies the class or activity where the log call occurs. + * @param message The message you would like logged. + * @param tr An exception to log + */ + public static void e(T tag, String message, Throwable tr) { + message = StringUtils.notNullStr(message); + Log.e(TAG + "-" + tag.toString(), message, tr); + addEntry(tag, LogLevel.e, message + " - exception: " + tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getStringStackTrace(tr)); + } + + /** + * Sends a ERROR log message and the exception with StackTrace + * @param tag Used to identify the source of a log message. It usually identifies the class or activity where the log call occurs. + * @param tr An exception to log to get StackTrace + */ + 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: " + getStringStackTrace(tr)); + } + + /** + * Sends a ERROR log message + * @param tag Used to identify the source of a log message. It usually identifies the class or activity where the log call occurs. + * @param volleyErrorMsg + * @param statusCode + */ + 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 mLogLevel; + String mLogText; + T mLogTag; + + public LogEntry(LogLevel logLevel, String logText, T logTag) { + mLogLevel = logLevel; + mLogText = logText; + if (mLogText == null) { + mLogText = "null"; + } + mLogTag = logTag; + } + + private String toHtml() { + StringBuilder sb = new StringBuilder(); + sb.append("<font color=\""); + sb.append(mLogLevel.toHtmlColor()); + sb.append("\">"); + sb.append("["); + sb.append(mLogTag.name()); + sb.append("] "); + sb.append(mLogLevel.name()); + sb.append(": "); + sb.append(TextUtils.htmlEncode(mLogText).replace("\n", "<br />")); + sb.append("</font>"); + return sb.toString(); + } + } + + private static class LogEntryList extends ArrayList<LogEntry> { + private synchronized boolean addEntry(LogEntry entry) { + if (size() >= MAX_ENTRIES) + removeFirstEntry(); + return add(entry); + } + private void removeFirstEntry() { + Iterator<LogEntry> 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(level, text, tag); + mLogEntries.addEntry(entry); + } + + private static String getStringStackTrace(Throwable throwable) { + StringWriter errors = new StringWriter(); + throwable.printStackTrace(new PrintWriter(errors)); + return errors.toString(); + } + + /** + * Returns entire log as html for display (see AppLogViewerActivity) + * @param context + * @return Arraylist of Strings containing log messages + */ + public static ArrayList<String> toHtmlList(Context context) { + ArrayList<String> items = new ArrayList<String>(); + + // add version & device info - be sure to change HEADER_LINE_COUNT if additional lines are added + items.add("<strong>WordPress Android version: " + PackageUtils.getVersionName(context) + "</strong>"); + items.add("<strong>Android device name: " + DeviceUtils.getInstance().getDeviceName(context) + "</strong>"); + + Iterator<LogEntry> it = mLogEntries.iterator(); + while (it.hasNext()) { + items.add(it.next().toHtml()); + } + return items; + } + + /** + * Converts the entire log to plain text + * @param context + * @return The log as plain text + */ + public static String toPlainText(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + PackageUtils.getVersionName(context)).append("\n") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("\n\n"); + + Iterator<LogEntry> it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append(String.format("%02d - ", lineNum)) + .append(it.next().mLogText) + .append("\n"); + lineNum++; + } + return sb.toString(); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java new file mode 100644 index 000000000..c81ec64a5 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java @@ -0,0 +1,74 @@ +package org.wordpress.android.util; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +public class BlogUtils { + public static Comparator<Object> BlogNameComparator = new Comparator<Object>() { + public int compare(Object blog1, Object blog2) { + Map<String, Object> blogMap1 = (Map<String, Object>) blog1; + Map<String, Object> blogMap2 = (Map<String, Object>) blog2; + String blogName1 = getBlogNameOrHomeURLFromAccountMap(blogMap1); + String blogName2 = getBlogNameOrHomeURLFromAccountMap(blogMap2); + return blogName1.compareToIgnoreCase(blogName2); + } + }; + + /** + * Return a blog name or blog home URL if trimmed name is an empty string + */ + public static String getBlogNameOrHomeURLFromAccountMap(Map<String, Object> account) { + String blogName = getBlogNameFromAccountMap(account); + if (blogName.trim().length() == 0) { + blogName = BlogUtils.getHomeURLOrHostNameFromAccountMap(account); + } + return blogName; + } + + /** + * Return a blog name or blog url (host part only) if trimmed name is an empty string + */ + public static String getBlogNameFromAccountMap(Map<String, Object> account) { + return StringUtils.unescapeHTML(MapUtils.getMapStr(account, "blogName")); + } + + /** + * Return the blog home URL setting or the host name if home URL is an empty string. + */ + public static String getHomeURLOrHostNameFromAccountMap(Map<String, Object> account) { + String homeURL = UrlUtils.removeScheme(MapUtils.getMapStr(account, "homeURL")); + homeURL = StringUtils.removeTrailingSlash(homeURL); + + if (homeURL.length() == 0) { + return UrlUtils.getHost(MapUtils.getMapStr(account, "url")); + } + + return homeURL; + } + + public static String[] getBlogNamesFromAccountMapList(List<Map<String, Object>> accounts) { + List<String> blogNames = new ArrayList<>(); + for (Map<String, Object> account : accounts) { + blogNames.add(getBlogNameOrHomeURLFromAccountMap(account)); + } + return blogNames.toArray(new String[blogNames.size()]); + } + + public static String[] getHomeURLOrHostNamesFromAccountMapList(List<Map<String, Object>> accounts) { + List<String> urls = new ArrayList<>(); + for (Map<String, Object> account : accounts) { + urls.add(getHomeURLOrHostNameFromAccountMap(account)); + } + return urls.toArray(new String[urls.size()]); + } + + public static String[] getBlogIdsFromAccountMapList(List<Map<String, Object>> accounts) { + List<String> ids = new ArrayList<>(); + for (Map<String, Object> account : accounts) { + ids.add(MapUtils.getMapStr(account, "blogId")); + } + return ids.toArray(new String[ids.size()]); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DateTimeUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DateTimeUtils.java new file mode 100644 index 000000000..2a796f3ee --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DateTimeUtils.java @@ -0,0 +1,246 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.format.DateUtils; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +public class DateTimeUtils { + private DateTimeUtils() { + throw new AssertionError(); + } + + // See http://drdobbs.com/java/184405382 + private static final ThreadLocal<DateFormat> ISO8601_FORMAT = new ThreadLocal<DateFormat>() { + @Override + protected DateFormat initialValue() { + return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US); + } + }; + + /** + * Converts a date to a relative time span ("8h", "3d", etc.) - similar to + * DateUtils.getRelativeTimeSpanString but returns shorter result + */ + public static String javaDateToTimeSpan(final Date date, Context context) { + if (date == null) { + return ""; + } + + long passedTime = date.getTime(); + long currentTime = System.currentTimeMillis(); + + // return "now" if less than a minute has elapsed + long secondsSince = (currentTime - passedTime) / 1000; + if (secondsSince < 60) { + return context.getString(R.string.timespan_now); + } + + // less than an hour (ex: 12m) + long minutesSince = secondsSince / 60; + if (minutesSince < 60) { + return Long.toString(minutesSince) + "m"; + } + + // less than a day (ex: 17h) + long hoursSince = minutesSince / 60; + if (hoursSince < 24) { + return Long.toString(hoursSince) + "h"; + } + + // less than a week (ex: 5d) + long daysSince = hoursSince / 24; + if (daysSince < 7) { + return Long.toString(daysSince) + "d"; + } + + // less than a year old, so return day/month without year (ex: Jan 30) + if (daysSince < 365) { + return DateUtils.formatDateTime(context, passedTime, DateUtils.FORMAT_NO_YEAR | + DateUtils.FORMAT_ABBREV_ALL); + } + + // date is older, so include year (ex: Jan 30, 2013) + return DateUtils.formatDateTime(context, passedTime, DateUtils.FORMAT_ABBREV_ALL); + } + + /** + * Given an ISO 8601-formatted date as a String, returns a {@link Date}. + */ + public static Date dateFromIso8601(final String strDate) { + try { + DateFormat formatter = ISO8601_FORMAT.get(); + return formatter.parse(strDate); + } catch (ParseException e) { + return null; + } + } + + /** + * Given an ISO 8601-formatted date as a String, returns a {@link Date} in UTC. + */ + public static Date dateUTCFromIso8601(String iso8601date) { + try { + iso8601date = iso8601date.replace("Z", "+0000").replace("+00:00", "+0000"); + DateFormat formatter = ISO8601_FORMAT.get(); + formatter.setTimeZone(TimeZone.getTimeZone("UTC")); + return formatter.parse(iso8601date); + } catch (ParseException e) { + return null; + } + } + + /** + * Given a {@link Date}, returns an ISO 8601-formatted String. + */ + public static String iso8601FromDate(Date date) { + if (date == null) { + return ""; + } + DateFormat formatter = ISO8601_FORMAT.get(); + return formatter.format(date); + } + + /** + * Given a {@link Date}, returns an ISO 8601-formatted String in UTC. + */ + public static String iso8601UTCFromDate(Date date) { + if (date == null) { + return ""; + } + TimeZone tz = TimeZone.getTimeZone("UTC"); + DateFormat formatter = ISO8601_FORMAT.get(); + formatter.setTimeZone(tz); + + String iso8601date = formatter.format(date); + + // Use "+00:00" notation rather than "+0000" to be consistent with the WP.COM API + return iso8601date.replace("+0000", "+00:00"); + } + + /** + * Returns the current UTC date + */ + public static Date nowUTC() { + Date dateTimeNow = new Date(); + return localDateToUTC(dateTimeNow); + } + + public static Date localDateToUTC(Date dtLocal) { + if (dtLocal == null) { + return null; + } + TimeZone tz = TimeZone.getDefault(); + int currentOffsetFromUTC = tz.getRawOffset() + (tz.inDaylightTime(dtLocal) ? tz.getDSTSavings() : 0); + return new Date(dtLocal.getTime() - currentOffsetFromUTC); + } + + // Routines to return a diff between two dates - always return a positive number + + public static int daysBetween(Date dt1, Date dt2) { + long hrDiff = hoursBetween(dt1, dt2); + if (hrDiff == 0) { + return 0; + } + return (int) (hrDiff / 24); + } + + public static int hoursBetween(Date dt1, Date dt2) { + long minDiff = minutesBetween(dt1, dt2); + if (minDiff == 0) { + return 0; + } + return (int) (minDiff / 60); + } + + public static int minutesBetween(Date dt1, Date dt2) { + long msDiff = millisecondsBetween(dt1, dt2); + if (msDiff == 0) { + return 0; + } + return (int) (msDiff / 60000); + } + + public static int secondsBetween(Date dt1, Date dt2) { + long msDiff = millisecondsBetween(dt1, dt2); + if (msDiff == 0) { + return 0; + } + return (int) (msDiff / 1000); + } + + public static long millisecondsBetween(Date dt1, Date dt2) { + if (dt1 == null || dt2 == null) { + return 0; + } + return Math.abs(dt1.getTime() - dt2.getTime()); + } + + public static boolean isSameYear(Date dt1, Date dt2) { + if (dt1 == null || dt2 == null) { + return false; + } + return dt1.getYear() == dt2.getYear(); + } + + public static boolean isSameMonthAndYear(Date dt1, Date dt2) { + if (dt1 == null || dt2 == null) { + return false; + } + return dt1.getYear() == dt2.getYear() && dt1.getMonth() == dt2.getMonth(); + } + + // Routines involving Unix timestamps (GMT assumed) + + /** + * Given an ISO 8601-formatted date as a String, returns the corresponding UNIX timestamp. + */ + public static long timestampFromIso8601(final String strDate) { + return (timestampFromIso8601Millis(strDate) / 1000); + } + + /** + * Given an ISO 8601-formatted date as a String, returns the corresponding timestamp in milliseconds. + */ + public static long timestampFromIso8601Millis(final String strDate) { + Date date = dateFromIso8601(strDate); + if (date == null) { + return 0; + } + return (date.getTime()); + } + + /** + * Given a UNIX timestamp, returns the corresponding {@link Date}. + */ + public static Date dateFromTimestamp(long timestamp) { + return new java.util.Date(timestamp * 1000); + } + + /** + * Given a UNIX timestamp, returns an ISO 8601-formatted date as a String. + */ + public static String iso8601FromTimestamp(long timestamp) { + return iso8601FromDate(dateFromTimestamp(timestamp)); + } + + /** + * Given a UNIX timestamp, returns an ISO 8601-formatted date in UTC as a String. + */ + public static String iso8601UTCFromTimestamp(long timestamp) { + return iso8601UTCFromDate(dateFromTimestamp(timestamp)); + } + + /** + * Given a UNIX timestamp, returns a relative time span ("8h", "3d", etc.). + */ + public static String timeSpanFromTimestamp(long timestamp, Context context) { + Date dateGMT = dateFromTimestamp(timestamp); + return javaDateToTimeSpan(dateGMT, context); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java new file mode 100644 index 000000000..639d5479c --- /dev/null +++ b/libs/utils/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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java new file mode 100644 index 000000000..40a017e9a --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java @@ -0,0 +1,91 @@ +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 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 float spToPx(Context context, float sp){ + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + final float scale = displayMetrics.scaledDensity; + return sp * scale; + } + + 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 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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java new file mode 100644 index 000000000..66a0c77fd --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java @@ -0,0 +1,75 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.TextView; + +/** + * EditText utils + */ +public class EditTextUtils { + private EditTextUtils() { + throw new AssertionError(); + } + + /** + * returns non-null text string from passed TextView + */ + public static String getText(TextView textView) { + return (textView != null) ? textView.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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/EmoticonsUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/EmoticonsUtils.java new file mode 100644 index 000000000..45661d980 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/EmoticonsUtils.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 EmoticonsUtils { + public static final int EMOTICON_COLOR = 0xFF21759B; + private static final boolean HAS_EMOJI = SDK_INT >= VERSION_CODES.JELLY_BEAN; + private static final Map<String, String> wpSmilies; + public static final SparseArray<String> wpSmiliesCodePointToText; + + static { + Map<String, String> smilies = new HashMap<String, String>(); + 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<String>(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 = EmoticonsUtils.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; + } + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java new file mode 100644 index 000000000..28282ed5f --- /dev/null +++ b/libs/utils/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<NumberFormat> IntegerInstance = new ThreadLocal<NumberFormat>() { + @Override + protected NumberFormat initialValue() { + return NumberFormat.getIntegerInstance(); + } + }; + + private static final ThreadLocal<DecimalFormat> DecimalInstance = new ThreadLocal<DecimalFormat>() { + @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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java new file mode 100644 index 000000000..372473e15 --- /dev/null +++ b/libs/utils/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, LanguageUtils.getCurrentDeviceLanguage(context)); + } 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<Address> 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<Address> 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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java new file mode 100644 index 000000000..1fbfb3e56 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java @@ -0,0 +1,84 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +/** + * see https://en.gravatar.com/site/implement/images/ + */ +public class GravatarUtils { + + // by default tell gravatar to respond to non-existent images with a 404 - this means + // it's up to the caller to catch the 404 and provide a suitable default image + private static final DefaultImage DEFAULT_GRAVATAR = DefaultImage.STATUS_404; + + public static enum DefaultImage { + MYSTERY_MAN, + STATUS_404, + IDENTICON, + MONSTER, + WAVATAR, + RETRO, + BLANK; + + @Override + public String toString() { + switch (this) { + case MYSTERY_MAN: + return "mm"; + case STATUS_404: + return "404"; + case IDENTICON: + return "identicon"; + case MONSTER: + return "monsterid"; + case WAVATAR: + return "wavatar"; + case RETRO: + return "retro"; + default: + return "blank"; + } + } + } + + /* + * 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 fixGravatarUrl(final String imageUrl, int avatarSz) { + return fixGravatarUrl(imageUrl, avatarSz, DEFAULT_GRAVATAR); + } + public static String fixGravatarUrl(final String imageUrl, int avatarSz, DefaultImage defaultImage) { + if (TextUtils.isEmpty(imageUrl)) { + return ""; + } + + // if this isn't a gravatar image, return as resized photon image url + if (!imageUrl.contains("gravatar.com")) { + return PhotonUtils.getPhotonImageUrl(imageUrl, avatarSz, avatarSz); + } + + // remove all other params, then add query string for size and default image + return UrlUtils.removeQuery(imageUrl) + "?s=" + avatarSz + "&d=" + defaultImage.toString(); + } + + public static String gravatarFromEmail(final String email, int size) { + return gravatarFromEmail(email, size, DEFAULT_GRAVATAR); + } + public static String gravatarFromEmail(final String email, int size, DefaultImage defaultImage) { + return "http://gravatar.com/avatar/" + + StringUtils.getMd5Hash(StringUtils.notNullStr(email)) + + "?d=" + defaultImage.toString() + + "&size=" + Integer.toString(size); + } + + public static String blavatarFromUrl(final String url, int size) { + return blavatarFromUrl(url, size, DEFAULT_GRAVATAR); + } + public static String blavatarFromUrl(final String url, int size, DefaultImage defaultImage) { + return "http://gravatar.com/blavatar/" + + StringUtils.getMd5Hash(UrlUtils.getHost(url)) + + "?d=" + defaultImage.toString() + + "&size=" + Integer.toString(size); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/HTTPUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/HTTPUtils.java new file mode 100644 index 000000000..9773d45d7 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/HTTPUtils.java @@ -0,0 +1,31 @@ +package org.wordpress.android.util; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Map; + +public class HTTPUtils { + public static final int REQUEST_TIMEOUT_MS = 30000; + + /** + * Builds an HttpURLConnection from a URL and header map. Will force HTTPS usage if given an Authorization header. + * @throws IOException + */ + public static HttpURLConnection setupUrlConnection(String url, Map<String, String> headers) throws IOException { + // Force HTTPS usage if an authorization header was specified + if (headers.keySet().contains("Authorization")) { + url = UrlUtils.makeHttps(url); + } + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setReadTimeout(REQUEST_TIMEOUT_MS); + conn.setConnectTimeout(REQUEST_TIMEOUT_MS); + + for (Map.Entry<String, String> entry : headers.entrySet()) { + conn.setRequestProperty(entry.getKey(), entry.getValue()); + } + + return conn; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java new file mode 100644 index 000000000..b5319372a --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -0,0 +1,156 @@ +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; +import org.wordpress.android.util.helpers.WPHtmlTagHandler; +import org.wordpress.android.util.helpers.WPImageGetter; +import org.wordpress.android.util.helpers.WPQuoteSpan; + +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 + * @param text String containing html + * @return String without HTML + */ + 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 + * @param str String containing only valid html + * @return String without 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("<p") > 0) { + str = str.replaceAll("<p(.|\n)*?>", "\n<p>"); + } + + // convert BR tags to line breaks + if (str.contains("<br")) { + str = str.replaceAll("<br(.|\n)*?>", "\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 + * @param text String to be decoded to Unicode + * @return String containing unicode characters + */ + 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 + * @param context Android Context + * @param resId Android R.color.xxx + * @return A String HTML hex color code + */ + 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 {@code <script>..</script>} 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 {@code <!--//-->} followed by a CDATA section followed by {@code <!]]>,} + * all of which will show up if we don't strip it here. + * @see <a href="http://wordpress.org/plugins/sociable/">Wordpress Sociable Plugin</a> + * @return String without {@code <script>..</script>}, {@code <!--//-->} blocks followed by a CDATA section followed by {@code <!]]>,} + * @param text String containing script tags + */ + public static String stripScript(final String text) { + if (text == null) { + return null; + } + + StringBuilder sb = new StringBuilder(text); + int start = sb.indexOf("<script"); + + while (start > -1) { + int end = sb.indexOf("</script>", start); + if (end == -1) { + return sb.toString(); + } + sb.delete(start, end + 9); + start = sb.indexOf("<script", start); + } + + return sb.toString(); + } + + /** + * An alternative to Html.fromHtml() supporting {@code <ul>}, {@code <ol>}, {@code <blockquote>} + * tags and replacing EmoticonsUtils with Emojis + * @param source + * @param wpImageGetter + */ + 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); + } + EmoticonsUtils.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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java new file mode 100644 index 000000000..2fd4449b8 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java @@ -0,0 +1,649 @@ +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.Point; +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.util.Log; +import android.webkit.MimeTypeMap; +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.FileOutputStream; +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 = null; + try { + cur = context.getContentResolver().query(uri, projection, null, null, null); + if (cur != null && cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + path = cur.getString(dataColumn); + } + } catch (IllegalStateException stateException) { + Log.d(ImageUtils.class.getName(), "IllegalStateException querying content:" + uri); + } finally { + SqlUtils.closeCursor(cur); + } + } + + 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<String, Void, Bitmap> { + private final WeakReference<ImageView> 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>(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); + } + + /** + * Given the path to an image, resize the image down to within a maximum width + * @param path the path to the original image + * @param maxWidth the maximum allowed width + * @return the path to the resized image + */ + public static String createResizedImageWithMaxWidth(Context context, String path, int maxWidth) { + File file = new File(path); + if (!file.exists()) { + return path; + } + + String mimeType = MediaUtils.getMediaFileMimeType(file); + if (mimeType.equals("image/gif")) { + // Don't rescale gifs to maintain their quality + return path; + } + + String fileName = MediaUtils.getMediaFileName(file, mimeType); + String fileExtension = MimeTypeMap.getFileExtensionFromUrl(fileName).toLowerCase(); + + int[] dimensions = getImageSize(Uri.fromFile(file), context); + int orientation = getImageOrientation(context, path); + + if (dimensions[0] <= maxWidth) { + // Image width is within limits; don't resize + return path; + } + + // Create resized image + byte[] bytes = ImageUtils.createThumbnailFromUri(context, Uri.parse(path), maxWidth, fileExtension, orientation); + + if (bytes != null) { + try { + File resizedImageFile = File.createTempFile("wp-image-", fileExtension); + FileOutputStream out = new FileOutputStream(resizedImageFile); + out.write(bytes); + out.close(); + + String tempFilePath = resizedImageFile.getPath(); + + if (!TextUtils.isEmpty(tempFilePath)) { + return tempFilePath; + } else { + AppLog.e(AppLog.T.POSTS, "Failed to create resized image"); + } + } catch (IOException e) { + AppLog.e(AppLog.T.POSTS, "Failed to create image temp file"); + } + } + + return path; + } + + /** + * 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 = null; + try { + cur = context.getContentResolver().query(imageUri, projection, null, null, null); + if (cur != null && cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + filePath = cur.getString(dataColumn); + } + } catch (IllegalStateException stateException) { + Log.d(ImageUtils.class.getName(), "IllegalStateException querying content:" + imageUri); + } finally { + SqlUtils.closeCursor(cur); + } + } + + 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 + final 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; + } catch (NullPointerException e) { + // See: https://github.com/wordpress-mobile/WordPress-Android/issues/1844 + AppLog.e(AppLog.T.UTILS, "Bitmap.createBitmap has thrown a NPE internally. This should never happen: " + e); + return null; + } + + if (bmpRotated == null) { + // Fix an issue where bmpRotated is null even if the documentation doesn't say Bitmap.createBitmap can return null. + // See: https://github.com/wordpress-mobile/WordPress-Android/issues/1848 + 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); + + return output; + } + + /** + * Returns the passed bitmap with rounded corners + * @param bitmap - the bitmap to modify + * @param radius - the radius of the corners + * @param borderColor - the border to apply (use Color.TRANSPARENT for none) + */ + public static Bitmap getRoundedEdgeBitmap(final Bitmap bitmap, int radius, int borderColor) { + 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); + + if (borderColor != Color.TRANSPARENT) { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(borderColor); + canvas.drawRoundRect(rectF, radius, radius, paint); + } + + return output; + } + + /** + * Get the maximum size a thumbnail can be to fit in either portrait or landscape orientations. + */ + public static int getMaximumThumbnailWidthForEditor(Context context) { + int maximumThumbnailWidthForEditor; + Point size = DisplayUtils.getDisplayPixelSize(context); + int screenWidth = size.x; + int screenHeight = size.y; + maximumThumbnailWidthForEditor = (screenWidth > screenHeight) ? screenHeight : screenWidth; + // 48dp of padding on each side so you can still place the cursor next to the image. + int padding = DisplayUtils.dpToPx(context, 48) * 2; + maximumThumbnailWidthForEditor -= padding; + return maximumThumbnailWidthForEditor; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtils.java new file mode 100644 index 000000000..196a7b1f3 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtils.java @@ -0,0 +1,251 @@ +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 JSONUtils { + 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 = "JSONUtils"; + + /** + * 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> U queryJSON(JSONObject source, String query, U defaultObject) { + if (source == null) { + AppLog.e(T.UTILS, "Parameter source is null, can't query a null object"); + return defaultObject; + } + if (query == null) { + AppLog.e(T.UTILS, "Parameter query is null"); + return 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 { + 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; + } + } + 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 (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) { + 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> U queryJSON(JSONArray source, String query, U defaultObject) { + if (source == null) { + AppLog.e(T.UTILS, "Parameter source is null, can't query a null object"); + return defaultObject; + } + if (query == null) { + AppLog.e(T.UTILS, "Parameter query is null"); + return 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) { + return defaultObject; + } + } + + /** + * Convert a JSONArray (expected to contain strings) in a string list + */ + public static ArrayList<String> fromJSONArrayToStringList(JSONArray jsonArray) { + ArrayList<String> stringList = new ArrayList<String>(); + 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<String> 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; + if (value.equalsIgnoreCase("no")) + 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; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/LanguageUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/LanguageUtils.java new file mode 100644 index 000000000..515b044b3 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/LanguageUtils.java @@ -0,0 +1,52 @@ +package org.wordpress.android.util; + +import android.content.Context; + +import java.util.Locale; + +/** + * Methods for dealing with i18n messages + */ +public class LanguageUtils { + + public static Locale getCurrentDeviceLanguage(Context context) { + //better use getConfiguration as it has the latest locale configuration change. + //Otherwise Locale.getDefault().getLanguage() gets + //the config upon application launch. + Locale deviceLocale = context != null ? context.getResources().getConfiguration().locale : Locale.getDefault(); + return deviceLocale; + } + + public static String getCurrentDeviceLanguageCode(Context context) { + String deviceLanguageCode = getCurrentDeviceLanguage(context).toString(); + return deviceLanguageCode; + } + + public static String getPatchedCurrentDeviceLanguage(Context context) { + return patchDeviceLanguageCode(getCurrentDeviceLanguageCode(context)); + } + + /** + * Patches a deviceLanguageCode if any of deprecated values iw, id, or yi + */ + public static String patchDeviceLanguageCode(String deviceLanguageCode){ + String patchedCode = deviceLanguageCode; + /* + <p>Note that Java uses several deprecated two-letter codes. The Hebrew ("he") language + * code is rewritten as "iw", Indonesian ("id") as "in", and Yiddish ("yi") as "ji". This + * rewriting happens even if you construct your own {@code Locale} object, not just for + * instances returned by the various lookup methods. + */ + if (deviceLanguageCode != null) { + if (deviceLanguageCode.startsWith("iw")) + patchedCode = deviceLanguageCode.replace("iw", "he"); + else if (deviceLanguageCode.startsWith("in")) + patchedCode = deviceLanguageCode.replace("in", "id"); + else if (deviceLanguageCode.startsWith("ji")) + patchedCode = deviceLanguageCode.replace("ji", "yi"); + } + + return patchedCode; + } + +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java new file mode 100644 index 000000000..c6e72dc5b --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java @@ -0,0 +1,107 @@ +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; + } + } + + /* + * float version of above + */ + public static float getMapFloat(final Map<?, ?> map, final String key) { + return getMapFloat(map, key, 0); + } + public static float getMapFloat(final Map<?, ?> map, final String key, float defaultValue) { + try { + return Float.parseFloat(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * double version of above + */ + public static double getMapDouble(final Map<?, ?> map, final String key) { + return getMapDouble(map, key, 0); + } + public static double getMapDouble(final Map<?, ?> map, final String key, double defaultValue) { + try { + return Double.parseDouble(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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MediaUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MediaUtils.java new file mode 100644 index 000000000..a96dadc74 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/MediaUtils.java @@ -0,0 +1,334 @@ +package org.wordpress.android.util; + +import android.app.Activity; +import android.content.Context; +import android.content.CursorLoader; +import android.database.Cursor; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import org.wordpress.android.util.AppLog.T; + +import java.io.DataInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +public class MediaUtils { + private static final int DEFAULT_MAX_IMAGE_WIDTH = 1024; + + public static boolean isValidImage(String url) { + if (url == null) { + return false; + } + url = url.toLowerCase(); + return url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".gif"); + } + + public static boolean isDocument(String url) { + if (url == null) { + return false; + } + url = url.toLowerCase(); + return url.endsWith(".doc") || url.endsWith(".docx") || url.endsWith(".odt") || url.endsWith(".pdf"); + } + + public static boolean isPowerpoint(String url) { + if (url == null) { + return false; + } + url = url.toLowerCase(); + return url.endsWith(".ppt") || url.endsWith(".pptx") || url.endsWith(".pps") || url.endsWith(".ppsx") || + url.endsWith(".key"); + } + + public static boolean isSpreadsheet(String url) { + if (url == null) { + return false; + } + url = url.toLowerCase(); + return url.endsWith(".xls") || url.endsWith(".xlsx"); + } + + public static boolean isVideo(String url) { + if (url == null) { + return false; + } + url = url.toLowerCase(); + return url.endsWith(".ogv") || url.endsWith(".mp4") || url.endsWith(".m4v") || url.endsWith(".mov") || + url.endsWith(".wmv") || url.endsWith(".avi") || url.endsWith(".mpg") || url.endsWith(".3gp") || + url.endsWith(".3g2") || url.contains("video"); + } + + public static boolean isAudio(String url) { + if (url == null) { + return false; + } + url = url.toLowerCase(); + return url.endsWith(".mp3") || url.endsWith(".ogg") || url.endsWith(".wav") || url.endsWith(".wma") || + url.endsWith(".aiff") || url.endsWith(".aif") || url.endsWith(".aac") || url.endsWith(".m4a"); + } + + /** + * E.g. Jul 2, 2013 @ 21:57 + */ + public static String getDate(long ms) { + Date date = new Date(ms); + SimpleDateFormat sdf = new SimpleDateFormat("MMM d, yyyy '@' HH:mm", Locale.ENGLISH); + + // The timezone on the website is at GMT + sdf.setTimeZone(TimeZone.getTimeZone("GMT")); + + return sdf.format(date); + } + + public static boolean isLocalFile(String state) { + if (state == null) { + return false; + } + + return (state.equals("queued") || state.equals("uploading") || state.equals("retry") + || state.equals("failed")); + } + + public static Uri getLastRecordedVideoUri(Activity activity) { + String[] proj = { MediaStore.Video.Media._ID }; + Uri contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + String sortOrder = MediaStore.Video.VideoColumns.DATE_TAKEN + " DESC"; + CursorLoader loader = new CursorLoader(activity, contentUri, proj, null, null, sortOrder); + Cursor cursor = loader.loadInBackground(); + cursor.moveToFirst(); + + return Uri.parse(contentUri.toString() + "/" + cursor.getLong(0)); + } + + /** + * Get image width setting from the image width site setting string. This string can be an int, in this case it's + * the maximum image width defined by the site. + * Examples: + * "1000" will return 1000 + * "Original Size" will return Integer.MAX_VALUE + * "Largeur originale" will return Integer.MAX_VALUE + * null will return Integer.MAX_VALUE + * @param imageWidthSiteSettingString Image width site setting string + * @return Integer.MAX_VALUE if image width is not defined or invalid, maximum image width in other cases. + */ + public static int getImageWidthSettingFromString(String imageWidthSiteSettingString) { + if (imageWidthSiteSettingString == null) { + return Integer.MAX_VALUE; + } + try { + return Integer.valueOf(imageWidthSiteSettingString); + } catch (NumberFormatException e) { + return Integer.MAX_VALUE; + } + } + + /** + * Calculate and return the maximum allowed image width by comparing the width of the image at its full size with + * the maximum upload width set in the blog settings + * @param imageWidth the image's natural (full) width + * @param imageWidthSiteSettingString the maximum upload width set in the site settings + * @return maximum allowed image width + */ + public static int getMaximumImageWidth(int imageWidth, String imageWidthSiteSettingString) { + int imageWidthBlogSetting = getImageWidthSettingFromString(imageWidthSiteSettingString); + int imageWidthPictureSetting = imageWidth == 0 ? Integer.MAX_VALUE : imageWidth; + + if (Math.min(imageWidthPictureSetting, imageWidthBlogSetting) == Integer.MAX_VALUE) { + // Default value in case of errors reading the picture size or the blog settings is set to Original size + return DEFAULT_MAX_IMAGE_WIDTH; + } else { + return Math.min(imageWidthPictureSetting, imageWidthBlogSetting); + } + } + + public static int getMaximumImageWidth(Context context, Uri curStream, String imageWidthBlogSettingString) { + int[] dimensions = ImageUtils.getImageSize(curStream, context); + return getMaximumImageWidth(dimensions[0], imageWidthBlogSettingString); + } + + public static boolean isInMediaStore(Uri mediaUri) { + // Check if the image is externally hosted (Picasa/Google Photos for example) + if (mediaUri != null && mediaUri.toString().startsWith("content://media/")) { + return true; + } else { + return false; + } + } + + public static Uri downloadExternalMedia(Context context, Uri imageUri) { + if (context == null || imageUri == null) { + return null; + } + File cacheDir = null; + + String mimeType = context.getContentResolver().getType(imageUri); + boolean isVideo = (mimeType != null && mimeType.contains("video")); + + // If the device has an SD card + if (android.os.Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED)) { + String mediaFolder = isVideo ? "video" : "images"; + cacheDir = new File(android.os.Environment.getExternalStorageDirectory() + "/WordPress/" + mediaFolder); + } else { + if (context.getApplicationContext() != null) { + cacheDir = context.getApplicationContext().getCacheDir(); + } + } + + if (cacheDir != null && !cacheDir.exists()) { + cacheDir.mkdirs(); + } + try { + InputStream input; + // Download the file + if (imageUri.toString().startsWith("content://")) { + input = context.getContentResolver().openInputStream(imageUri); + if (input == null) { + AppLog.e(T.UTILS, "openInputStream returned null"); + return null; + } + } else { + input = new URL(imageUri.toString()).openStream(); + } + + String fileName = "wp-" + System.currentTimeMillis(); + if (isVideo) { + fileName += "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + } + + File f = new File(cacheDir, fileName); + + OutputStream output = new FileOutputStream(f); + + byte data[] = new byte[1024]; + int count; + while ((count = input.read(data)) != -1) { + output.write(data, 0, count); + } + + output.flush(); + output.close(); + input.close(); + + return Uri.fromFile(f); + } catch (FileNotFoundException e) { + AppLog.e(T.UTILS, e); + } catch (MalformedURLException e) { + AppLog.e(T.UTILS, e); + } catch (IOException e) { + AppLog.e(T.UTILS, e); + } + + return null; + } + + public static String getMimeTypeOfInputStream(InputStream stream) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(stream, null, options); + return options.outMimeType; + } + + public static String getMediaFileMimeType(File mediaFile) { + String originalFileName = mediaFile.getName().toLowerCase(); + String mimeType = UrlUtils.getUrlMimeType(originalFileName); + + if (TextUtils.isEmpty(mimeType)) { + try { + String filePathForGuessingMime; + if (mediaFile.getPath().contains("://")) { + filePathForGuessingMime = Uri.encode(mediaFile.getPath(), ":/"); + } else { + filePathForGuessingMime = "file://"+ Uri.encode(mediaFile.getPath(), "/"); + } + URL urlForGuessingMime = new URL(filePathForGuessingMime); + URLConnection uc = urlForGuessingMime.openConnection(); + String guessedContentType = uc.getContentType(); //internally calls guessContentTypeFromName(url.getFile()); and guessContentTypeFromStream(is); + // check if returned "content/unknown" + if (!TextUtils.isEmpty(guessedContentType) && !guessedContentType.equals("content/unknown")) { + mimeType = guessedContentType; + } + } catch (MalformedURLException e) { + AppLog.e(AppLog.T.API, "MalformedURLException while trying to guess the content type for the file here " + mediaFile.getPath() + " with URLConnection", e); + } + catch (IOException e) { + AppLog.e(AppLog.T.API, "Error while trying to guess the content type for the file here " + mediaFile.getPath() +" with URLConnection", e); + } + } + + // No mimeType yet? Try to decode the image and get the mimeType from there + if (TextUtils.isEmpty(mimeType)) { + try { + DataInputStream inputStream = new DataInputStream(new FileInputStream(mediaFile)); + String mimeTypeFromStream = getMimeTypeOfInputStream(inputStream); + if (!TextUtils.isEmpty(mimeTypeFromStream)) { + mimeType = mimeTypeFromStream; + } + inputStream.close(); + } catch (FileNotFoundException e) { + AppLog.e(AppLog.T.API, "FileNotFoundException while trying to guess the content type for the file " + mediaFile.getPath(), e); + } catch (IOException e) { + AppLog.e(AppLog.T.API, "IOException while trying to guess the content type for the file " + mediaFile.getPath(), e); + } + } + + if (TextUtils.isEmpty(mimeType)) { + mimeType = ""; + } else { + if (mimeType.equalsIgnoreCase("video/mp4v-es")) { //Fixes #533. See: http://tools.ietf.org/html/rfc3016 + mimeType = "video/mp4"; + } + } + + return mimeType; + } + + public static String getMediaFileName(File mediaFile, String mimeType) { + String originalFileName = mediaFile.getName().toLowerCase(); + String extension = MimeTypeMap.getFileExtensionFromUrl(originalFileName); + if (!TextUtils.isEmpty(extension)) //File name already has the extension in it + return originalFileName; + + if (!TextUtils.isEmpty(mimeType)) { //try to get the extension from mimeType + String fileExtension = getExtensionForMimeType(mimeType); + if (!TextUtils.isEmpty(fileExtension)) { + originalFileName += "." + fileExtension; + } + } else { + //No mimetype and no extension!! + AppLog.e(AppLog.T.API, "No mimetype and no extension for " + mediaFile.getPath()); + } + + return originalFileName; + } + + public static String getExtensionForMimeType(String mimeType) { + if (TextUtils.isEmpty(mimeType)) + return ""; + + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String fileExtensionFromMimeType = mimeTypeMap.getExtensionFromMimeType(mimeType); + if (TextUtils.isEmpty(fileExtensionFromMimeType)) { + // We're still without an extension - split the mime type and retrieve it + String[] split = mimeType.split("/"); + fileExtensionFromMimeType = split.length > 1 ? split[1] : split[0]; + } + + return fileExtensionFromMimeType.toLowerCase(); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/NetworkUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/NetworkUtils.java new file mode 100644 index 000000000..240c93f50 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/NetworkUtils.java @@ -0,0 +1,89 @@ +package org.wordpress.android.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.provider.Settings; + +/** + * requires android.permission.ACCESS_NETWORK_STATE + */ + +public class NetworkUtils { + public static final int TYPE_UNKNOWN = -1; + + /** + * returns information on the active network connection + */ + private static NetworkInfo getActiveNetworkInfo(Context context) { + if (context == null) { + return null; + } + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) { + return null; + } + // note that this may return null if no network is currently active + return cm.getActiveNetworkInfo(); + } + + /** + * returns the ConnectivityManager.TYPE_xxx if there's an active connection, otherwise + * returns TYPE_UNKNOWN + */ + private static int getActiveNetworkType(Context context) { + NetworkInfo info = getActiveNetworkInfo(context); + if (info == null || !info.isConnected()) { + return TYPE_UNKNOWN; + } + return info.getType(); + } + + /** + * returns true if a network connection is available + */ + public static boolean isNetworkAvailable(Context context) { + NetworkInfo info = getActiveNetworkInfo(context); + return (info != null && info.isConnected()); + } + + /** + * returns true if the user is connected to WiFi + */ + public static boolean isWiFiConnected(Context context) { + return (getActiveNetworkType(context) == ConnectivityManager.TYPE_WIFI); + } + + /** + * returns true if airplane mode has been enabled + */ + @TargetApi(VERSION_CODES.JELLY_BEAN_MR1) + @SuppressWarnings("deprecation") + public static boolean isAirplaneModeOn(Context context) { + // prior to JellyBean 4.2 this was Settings.System.AIRPLANE_MODE_ON, JellyBean 4.2 + // moved it to Settings.Global + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return Settings.System.getInt(context.getContentResolver(), Settings.System.AIRPLANE_MODE_ON, 0) != 0; + } else { + return Settings.Global.getInt(context.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0) != 0; + } + } + + /** + * returns true if there's an active network connection, otherwise displays a toast error + * and returns false + */ + public static boolean checkConnection(Context context) { + if (context == null) { + return false; + } + if (isNetworkAvailable(context)) { + return true; + } + ToastUtils.showToast(context, R.string.no_network_message); + return false; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PackageUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PackageUtils.java new file mode 100644 index 000000000..52900a0bf --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PackageUtils.java @@ -0,0 +1,45 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +public class PackageUtils { + /** + * Return true if Debug build. false otherwise. + */ + public static boolean isDebugBuild() { + return BuildConfig.DEBUG; + } + + public static PackageInfo getPackageInfo(Context context) { + try { + PackageManager manager = context.getPackageManager(); + return manager.getPackageInfo(context.getPackageName(), 0); + } catch (PackageManager.NameNotFoundException e) { + return null; + } + } + + /** + * Return version code, or 0 if it can't be read + */ + public static int getVersionCode(Context context) { + PackageInfo packageInfo = getPackageInfo(context); + if (packageInfo != null) { + return packageInfo.versionCode; + } + return 0; + } + + /** + * Return version name, or the string "0" if it can't be read + */ + public static String getVersionName(Context context) { + PackageInfo packageInfo = getPackageInfo(context); + if (packageInfo != null) { + return packageInfo.versionName; + } + return "0"; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PermissionUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PermissionUtils.java new file mode 100644 index 000000000..bf30103d2 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PermissionUtils.java @@ -0,0 +1,97 @@ +package org.wordpress.android.util; + +import android.Manifest.permission; +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.pm.PackageManager; +import android.support.v13.app.FragmentCompat; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; + +import java.util.ArrayList; +import java.util.List; + +public class PermissionUtils { + /** + * Check for permissions, request them if they're not granted. + * + * @return true if permissions are already granted, else request them and return false. + */ + private static boolean checkAndRequestPermissions(Activity activity, int requestCode, String[] permissionList) { + List<String> toRequest = new ArrayList<>(); + for (String permission : permissionList) { + if (ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED) { + toRequest.add(permission); + } + } + if (toRequest.size() > 0) { + String[] requestedPermissions = toRequest.toArray(new String[toRequest.size()]); + ActivityCompat.requestPermissions(activity, requestedPermissions, requestCode); + return false; + } + return true; + } + + /** + * Check for permissions, request them if they're not granted. + * + * @return true if permissions are already granted, else request them and return false. + */ + private static boolean checkAndRequestPermissions(Fragment fragment, int requestCode, String[] permissionList) { + List<String> toRequest = new ArrayList<>(); + for (String permission : permissionList) { + Context context = fragment.getActivity(); + if (context != null && ContextCompat.checkSelfPermission(context, permission) != PackageManager + .PERMISSION_GRANTED) { + toRequest.add(permission); + } + } + if (toRequest.size() > 0) { + String[] requestedPermissions = toRequest.toArray(new String[toRequest.size()]); + FragmentCompat.requestPermissions(fragment, requestedPermissions, requestCode); + return false; + } + return true; + } + + public static boolean checkAndRequestCameraAndStoragePermissions(Activity activity, int requestCode) { + return checkAndRequestPermissions(activity, requestCode, new String[]{ + permission.WRITE_EXTERNAL_STORAGE, + permission.CAMERA + }); + } + + public static boolean checkAndRequestCameraAndStoragePermissions(Fragment fragment, int requestCode) { + return checkAndRequestPermissions(fragment, requestCode, new String[]{ + permission.WRITE_EXTERNAL_STORAGE, + permission.CAMERA + }); + } + + public static boolean checkAndRequestStoragePermission(Activity activity, int requestCode) { + return checkAndRequestPermissions(activity, requestCode, new String[]{ + permission.WRITE_EXTERNAL_STORAGE + }); + } + + public static boolean checkAndRequestStoragePermission(Fragment fragment, int requestCode) { + return checkAndRequestPermissions(fragment, requestCode, new String[]{ + permission.WRITE_EXTERNAL_STORAGE + }); + } + + public static boolean checkLocationPermissions(Activity activity, int requestCode) { + return checkAndRequestPermissions(activity, requestCode, new String[]{ + permission.ACCESS_FINE_LOCATION, + permission.ACCESS_COARSE_LOCATION + }); + } + + public static boolean checkLocationPermissions(Fragment fragment, int requestCode) { + return checkAndRequestPermissions(fragment, requestCode, new String[]{ + permission.ACCESS_FINE_LOCATION, + permission.ACCESS_COARSE_LOCATION + }); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java new file mode 100644 index 000000000..85a2adc93 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java @@ -0,0 +1,104 @@ +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(); + } + + /* + * 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 - note that the passed quality parameter will only affect JPEGs + */ + public enum Quality { + HIGH, + MEDIUM, + LOW + } + public static String getPhotonImageUrl(String imageUrl, int width, int height) { + return getPhotonImageUrl(imageUrl, width, height, Quality.MEDIUM); + } + public static String getPhotonImageUrl(String imageUrl, int width, int height, Quality quality) { + if (TextUtils.isEmpty(imageUrl)) { + return ""; + } + + // make sure it's valid + int schemePos = imageUrl.indexOf("://"); + if (schemePos == -1) { + return imageUrl; + } + + // we have encountered some image urls that incorrectly have a # fragment part, which + // must be removed before removing the query string + int fragmentPos = imageUrl.indexOf("#"); + if (fragmentPos > 0) { + imageUrl = imageUrl.substring(0, fragmentPos); + } + + // remove existing query string since it may contain params that conflict with the passed ones + imageUrl = UrlUtils.removeQuery(imageUrl); + + // if this is an "mshots" url, skip photon and return it with a query that sets the width/height + if (isMshotsUrl(imageUrl)) { + return imageUrl + "?w=" + width + "&h=" + height; + } + + // strip=all removes EXIF and other non-visual data from JPEGs + String query = "?strip=all"; + + switch (quality) { + case HIGH: + query += "&quality=100"; + break; + case LOW: + query += "&quality=35"; + break; + default: // medium + query += "&quality=65"; + break; + } + + // if both width & height are passed use the "resize" param, use only "w" or "h" if just + // one of them is set + if (width > 0 && height > 0) { + query += "&resize=" + width + "," + height; + } else if (width > 0) { + query += "&w=" + width; + } else if (height > 0) { + query += "&h=" + height; + } + + // 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; + } + + // use wordpress.com as the host if image is on wordpress.com since it supports the same + // query params and, more importantly, can handle images in private blogs + if (imageUrl.contains("wordpress.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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java new file mode 100644 index 000000000..4660a3500 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java @@ -0,0 +1,87 @@ +package org.wordpress.android.util; + +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<Long> mSplits; + private ArrayList<String> 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(); + } + + public static void stop() { + getInstance().reset(null); + } + + 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<Long>(); + mSplitLabels = new ArrayList<String>(); + } else { + mSplits.clear(); + mSplitLabels.clear(); + } + addSplit(null); + } + + public void addSplit(String splitLabel) { + if (mLabel == null) { + return; + } + long now = SystemClock.elapsedRealtime(); + mSplits.add(now); + mSplitLabels.add(splitLabel); + } + + public void dumpToLog() { + if (mLabel == null) { + return; + } + 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"); + } +} + diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ServiceUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ServiceUtils.java new file mode 100644 index 000000000..6bcfde06b --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ServiceUtils.java @@ -0,0 +1,16 @@ +package org.wordpress.android.util; + +import android.app.ActivityManager; +import android.content.Context; + +public class ServiceUtils { + public static boolean isServiceRunning(Context context, Class<?> serviceClass) { + ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { + if (serviceClass.getName().equals(service.service.getClassName())) { + return true; + } + } + return false; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ShortcodeUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ShortcodeUtils.java new file mode 100644 index 000000000..09480f156 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ShortcodeUtils.java @@ -0,0 +1,31 @@ +package org.wordpress.android.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ShortcodeUtils { + public static String getVideoPressShortcodeFromId(String videoPressId) { + if (videoPressId == null || videoPressId.isEmpty()) { + return ""; + } + + return "[wpvideo " + videoPressId + "]"; + } + + public static String getVideoPressIdFromShortCode(String shortcode) { + String videoPressId = ""; + + if (shortcode != null) { + String videoPressShortcodeRegex = "^\\[wpvideo (.*)]$"; + + Pattern pattern = Pattern.compile(videoPressShortcodeRegex); + Matcher matcher = pattern.matcher(shortcode); + + if (matcher.find()) { + videoPressId = matcher.group(1); + } + } + + return videoPressId; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java new file mode 100644 index 000000000..38b4b74a9 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java @@ -0,0 +1,142 @@ +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 org.wordpress.android.util.AppLog.T; + +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<String> tableNames = new ArrayList<String>(); + 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(); + closeCursor(cursor); + } + } + + /* + * Android's CursorWindow has a max size of 2MB per row which can be exceeded + * with a very large text column, causing an IllegalStateException when the + * row is read - prevent this by limiting the amount of text that's stored in + * the text column. + * https://github.com/android/platform_frameworks_base/blob/b77bc869241644a662f7e615b0b00ecb5aee373d/core/res/res/values/config.xml#L1268 + * https://github.com/android/platform_frameworks_base/blob/3bdbf644d61f46b531838558fabbd5b990fc4913/core/java/android/database/CursorWindow.java#L103 + */ + // Max 512K characters (a UTF-8 char is 4 bytes max, so a 512K characters string is always < 2Mb) + private static final int MAX_TEXT_LEN = 1024 * 1024 / 2; + public static String maxSQLiteText(final String text) { + if (text.length() <= MAX_TEXT_LEN) { + return text; + } + AppLog.w(T.UTILS, "sqlite > max text exceeded, storing truncated text"); + return text.substring(0, MAX_TEXT_LEN); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java new file mode 100644 index 000000000..b15e8d824 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java @@ -0,0 +1,327 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.support.annotation.StringRes; +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; +import java.util.Locale; + +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<String> array1List = Arrays.asList(array1); + List<String> array2List = Arrays.asList(array2); + List<String> result = new ArrayList<String>(array1List); + List<String> tmp = new ArrayList<String>(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("<b>", "<strong>"); + source = source.replace("</b>", "</strong>"); + + // italics + source = source.replace("<i>", "<em>"); + source = source.replace("</i>", "</em>"); + + return source; + } + + public static String convertHTMLTagsForDisplay(String source) { + // bold + source = source.replace("<strong>", "<b>"); + source = source.replace("</strong>", "</b>"); + + // italics + source = source.replace("<em>", "<i>"); + source = source.replace("</em>", "</i>"); + + 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("<br />", "<br>").replace("<br/>", "<br>").replace("<br>\n", "<br>") + .replace("\n", "<br>"); + wrappedHTML.append("<p>"); + wrappedHTML.append(trimmed); + wrappedHTML.append("</p>"); + } + } + 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(); + } + + public static String removeTrailingSlash(final String str) { + if (TextUtils.isEmpty(str) || !str.endsWith("/")) { + return str; + } + + return str.substring(0, str.length() - 1); + } + + /* + * 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 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 (EmoticonsUtils.wpSmiliesCodePointToText.get(codepoint) != null) { + out.append(EmoticonsUtils.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(); + } + + /** + * Used to convert a language code ([lc]_[rc] where lc is language code (en, fr, es, etc...) + * and rc is region code (zh-CN, zh-HK, zh-TW, etc...) to a displayable string with the languages + * name. + * + * The input string must be between 2 and 6 characters, inclusive. An empty string is returned + * if that is not the case. + * + * If the input string is recognized by {@link Locale} the result of this method is the given + * + * @return non-null + */ + public static String getLanguageString(String languagueCode, Locale displayLocale) { + if (languagueCode == null || languagueCode.length() < 2 || languagueCode.length() > 6) { + return ""; + } + + Locale languageLocale = new Locale(languagueCode.substring(0, 2)); + return languageLocale.getDisplayLanguage(displayLocale) + languagueCode.substring(2); + } + + /** + * 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 + * <a href="http://www.w3.org/TR/2000/REC-xml-20001006#NT-Char">the + * standard</a>. 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; + } + } + + public static long stringToLong(String s) { + return stringToLong(s, 0L); + } + + public static long stringToLong(String s, long defaultValue) { + if (s == null) + return defaultValue; + try { + return Long.valueOf(s); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * Formats the string for the given quantity, using the given arguments. + * We need this because our translation platform doesn't support Android plurals. + * + * @param zero The desired string identifier to get when quantity is exactly 0 + * @param one The desired string identifier to get when quantity is exactly 1 + * @param other The desired string identifier to get when quantity is not (0 or 1) + * @param quantity The number used to get the correct string + */ + public static String getQuantityString(Context context, @StringRes int zero, @StringRes int one, + @StringRes int other, int quantity) { + if (quantity == 0) { + return context.getString(zero); + } + if (quantity == 1) { + return context.getString(one); + } + return String.format(context.getString(other), quantity); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java new file mode 100644 index 000000000..e3fee7fc3 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java @@ -0,0 +1,14 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public class SystemServiceFactory { + private static SystemServiceFactoryAbstract sFactory; + + public static Object get(Context context, String name) { + if (sFactory == null) { + sFactory = new SystemServiceFactoryDefault(); + } + return sFactory.get(context, name); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java new file mode 100644 index 000000000..a9d522db4 --- /dev/null +++ b/libs/utils/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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java new file mode 100644 index 000000000..eb488dde9 --- /dev/null +++ b/libs/utils/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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java new file mode 100644 index 000000000..9b99c6ea5 --- /dev/null +++ b/libs/utils/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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java new file mode 100644 index 000000000..cad48ef8e --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java @@ -0,0 +1,257 @@ +package org.wordpress.android.util; + +import android.net.Uri; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import org.wordpress.android.util.AppLog.T; + +import java.io.UnsupportedEncodingException; +import java.net.IDN; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; + +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; + } + } + + /** + * @param urlString url to get host from + * @return host of uri if available. Empty string otherwise. + */ + public static String getHost(final String urlString) { + if (urlString != null) { + Uri uri = Uri.parse(urlString); + if (uri.getHost() != null) { + return uri.getHost(); + } + } + return ""; + } + + /** + * 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; + } + + /** + * Remove leading double slash, and inherit protocol scheme + */ + public static String removeLeadingDoubleSlash(String url, String scheme) { + if (url != null && url.startsWith("//")) { + url = url.substring(2); + if (scheme != null) { + if (scheme.endsWith("://")){ + url = scheme + url; + } else { + AppLog.e(T.UTILS, "Invalid scheme used: " + scheme); + } + } + } + return url; + } + + /** + * Add scheme prefix to an URL. This method must be called on all user entered or server fetched URLs to ensure + * http client will work as expected. + * + * @param url url entered by the user or fetched from a server + * @param addHttps true and the url is not starting with http://, it will make the url starts with https:// + * @return url prefixed by http:// or https:// + */ + public static String addUrlSchemeIfNeeded(String url, boolean addHttps) { + if (url == null) { + return null; + } + + // Remove leading double slash (eg. //example.com), needed for some wporg instances configured to + // switch between http or https + url = removeLeadingDoubleSlash(url, (addHttps ? "https" : "http") + "://"); + + // If the URL is a valid http or https URL, we're good to go + if (URLUtil.isHttpUrl(url) || URLUtil.isHttpsUrl(url)) { + return url; + } + + // Else, remove the old scheme and prefix it by https:// or http:// + return (addHttps ? "https" : "http") + "://" + removeScheme(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 scheme + */ + public static String removeScheme(final String urlString) { + if (urlString == null) { + return null; + } + + int doubleslash = urlString.indexOf("//"); + if (doubleslash == -1) { + doubleslash = 0; + } else { + doubleslash += 2; + } + + return urlString.substring(doubleslash, urlString.length()); + } + + /** + * returns the passed url without the query parameters + */ + public static String removeQuery(final String urlString) { + if (urlString == null) { + return null; + } + return Uri.parse(urlString).buildUpon().clearQuery().toString(); + } + + /** + * returns true if passed url is https: + */ + public static boolean isHttps(final String urlString) { + return (urlString != null && urlString.startsWith("https:")); + } + + public static boolean isHttps(URL url) { + return url != null && "https".equals(url.getProtocol()); + } + + public static boolean isHttps(URI uri) { + if (uri == null) return false; + + String protocol = uri.getScheme(); + return protocol != null && protocol.equals("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; + } + + // returns true if the passed url is for an image + public static boolean isImageUrl(String url) { + if (TextUtils.isEmpty(url)) return false; + + String cleanedUrl = removeQuery(url.toLowerCase()); + + return cleanedUrl.endsWith("jpg") || cleanedUrl.endsWith("jpeg") || + cleanedUrl.endsWith("gif") || cleanedUrl.endsWith("png"); + } + + public static String appendUrlParameter(String url, String paramName, String paramValue) { + Map<String, String> parameters = new HashMap<>(); + parameters.put(paramName, paramValue); + return appendUrlParameters(url, parameters); + } + + public static String appendUrlParameters(String url, Map<String, String> parameters) { + Uri.Builder uriBuilder = Uri.parse(url).buildUpon(); + for (Map.Entry<String, String> parameter : parameters.entrySet()) { + uriBuilder.appendQueryParameter(parameter.getKey(), parameter.getValue()); + } + return uriBuilder.build().toString(); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmailUtils.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmailUtils.java new file mode 100644 index 000000000..385960558 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmailUtils.java @@ -0,0 +1,38 @@ +package org.wordpress.android.util; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.util.Patterns; + +import org.wordpress.android.util.AppLog.T; + +import java.util.regex.Pattern; + +public class UserEmailUtils { + /** + * 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 + AppLog.e(T.UTILS, "SecurityException - missing GET_ACCOUNTS permission"); + return ""; + } + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/ListScrollPositionManager.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/ListScrollPositionManager.java new file mode 100644 index 000000000..914373c8f --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/ListScrollPositionManager.java @@ -0,0 +1,58 @@ +package org.wordpress.android.util.helpers; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.preference.PreferenceManager; +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); + } + } + + public void saveToPreferences(Context context, String uniqueId) { + saveScrollOffset(); + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); + Editor editor = settings.edit(); + editor.putInt("scroll-position-manager-index-" + uniqueId, mListViewScrollStateIndex); + editor.putInt("scroll-position-manager-offset-" + uniqueId, mListViewScrollStateOffset); + editor.putInt("scroll-position-manager-selected-position-" + uniqueId, mSelectedPosition); + editor.apply(); + } + + public void restoreFromPreferences(Context context, String uniqueId) { + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); + mListViewScrollStateIndex = settings.getInt("scroll-position-manager-index-" + uniqueId, 0); + mListViewScrollStateOffset = settings.getInt("scroll-position-manager-offset-" + uniqueId, 0); + mSelectedPosition = settings.getInt("scroll-position-manager-selected-position-" + uniqueId, 0); + restoreScrollOffset(); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/LocationHelper.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/LocationHelper.java new file mode 100644 index 000000000..ff472c2ed --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/LocationHelper.java @@ -0,0 +1,144 @@ +//This Handy-Dandy class acquired and tweaked from http://stackoverflow.com/a/3145655/309558 +package org.wordpress.android.util.helpers; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; + +import java.util.Timer; +import java.util.TimerTask; + +public class LocationHelper { + Timer mTimer; + LocationManager mLocationManager; + LocationResult mLocationResult; + boolean mGpsEnabled = false; + boolean mNetworkEnabled = false; + + @SuppressLint("MissingPermission") + public boolean getLocation(Activity activity, LocationResult result) { + mLocationResult = result; + if (mLocationManager == null) { + mLocationManager = (LocationManager) activity.getSystemService(Context.LOCATION_SERVICE); + } + + // exceptions will be thrown if provider is not permitted. + try { + mGpsEnabled = mLocationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch (Exception ex) { + } + try { + mNetworkEnabled = mLocationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } catch (Exception ex) { + } + + // don't start listeners if no provider is enabled + if (!mGpsEnabled && !mNetworkEnabled) { + return false; + } + + if (mGpsEnabled) { + mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListenerGps); + } + + if (mNetworkEnabled) { + mLocationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListenerNetwork); + } + + mTimer = new Timer(); + mTimer.schedule(new GetLastLocation(), 30000); + return true; + } + + LocationListener locationListenerGps = new LocationListener() { + @SuppressLint("MissingPermission") + public void onLocationChanged(Location location) { + mTimer.cancel(); + mLocationResult.gotLocation(location); + mLocationManager.removeUpdates(this); + mLocationManager.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() { + @SuppressLint("MissingPermission") + public void onLocationChanged(Location location) { + mTimer.cancel(); + mLocationResult.gotLocation(location); + mLocationManager.removeUpdates(this); + mLocationManager.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 + @SuppressLint("MissingPermission") + public void run() { + mLocationManager.removeUpdates(locationListenerGps); + mLocationManager.removeUpdates(locationListenerNetwork); + + Location net_loc = null, gps_loc = null; + if (mGpsEnabled) { + gps_loc = mLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + } + if (mNetworkEnabled) { + net_loc = mLocationManager.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()) { + mLocationResult.gotLocation(gps_loc); + } else { + mLocationResult.gotLocation(net_loc); + } + return; + } + + if (gps_loc != null) { + mLocationResult.gotLocation(gps_loc); + return; + } + if (net_loc != null) { + mLocationResult.gotLocation(net_loc); + return; + } + mLocationResult.gotLocation(null); + } + } + + public static abstract class LocationResult { + public abstract void gotLocation(Location location); + } + + @SuppressLint("MissingPermission") + public void cancelTimer() { + if (mTimer != null) { + mTimer.cancel(); + mLocationManager.removeUpdates(locationListenerGps); + mLocationManager.removeUpdates(locationListenerNetwork); + } + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaFile.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaFile.java new file mode 100644 index 000000000..b57ad0165 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaFile.java @@ -0,0 +1,339 @@ +package org.wordpress.android.util.helpers; + +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import org.wordpress.android.util.MapUtils; +import org.wordpress.android.util.StringUtils; + +import java.util.Date; +import java.util.Map; + +public class MediaFile { + protected int id; + protected long postID; + protected String filePath = null; //path of the file into disk + protected String fileName = null; //name of the file into the server + protected String title = null; + protected String description = null; + protected String caption = null; + protected int horizontalAlignment; //0 = none, 1 = left, 2 = center, 3 = right + protected boolean verticalAligment = false; //false = bottom, true = top + protected int width = 500, height; + protected String mimeType = ""; + protected String videoPressShortCode = null; + protected boolean featured = false; + protected boolean isVideo = false; + protected boolean featuredInPost; + protected String fileURL = null; // url of the file to download + protected String thumbnailURL = null; // url of the thumbnail to download + private String blogId; + private long dateCreatedGmt; + private String uploadState = null; + private String mediaId; + + public static String VIDEOPRESS_SHORTCODE_ID = "videopress_shortcode"; + + public MediaFile(String blogId, Map<?, ?> resultMap, boolean isDotCom) { + setBlogId(blogId); + setMediaId(MapUtils.getMapStr(resultMap, "attachment_id")); + setPostID(MapUtils.getMapLong(resultMap, "parent")); + setTitle(MapUtils.getMapStr(resultMap, "title")); + setCaption(MapUtils.getMapStr(resultMap, "caption")); + setDescription(MapUtils.getMapStr(resultMap, "description")); + setVideoPressShortCode(MapUtils.getMapStr(resultMap, VIDEOPRESS_SHORTCODE_ID)); + + // get the file name from the link + String link = MapUtils.getMapStr(resultMap, "link"); + setFileName(new String(link).replaceAll("^.*/([A-Za-z0-9_-]+)\\.\\w+$", "$1")); + + String fileType = new String(link).replaceAll(".*\\.(\\w+)$", "$1").toLowerCase(); + String fileMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileType); + setMimeType(fileMimeType); + + // make the file urls be https://... so that we can get these images with oauth when the blogs are private + // assume no https for images in self-hosted blogs + String fileUrl = MapUtils.getMapStr(resultMap, "link"); + if (isDotCom) { + fileUrl = fileUrl.replace("http:", "https:"); + } + setFileURL(fileUrl); + + String thumbnailURL = MapUtils.getMapStr(resultMap, "thumbnail"); + if (thumbnailURL.startsWith("http")) { + if (isDotCom) { + thumbnailURL = thumbnailURL.replace("http:", "https:"); + } + setThumbnailURL(thumbnailURL); + } + + Date date = MapUtils.getMapDate(resultMap, "date_created_gmt"); + if (date != null) { + setDateCreatedGMT(date.getTime()); + } + + Object meta = resultMap.get("metadata"); + if (meta != null && meta instanceof Map) { + Map<?, ?> metadata = (Map<?, ?>) meta; + setWidth(MapUtils.getMapInt(metadata, "width")); + setHeight(MapUtils.getMapInt(metadata, "height")); + } + } + + public MediaFile() { + // default constructor + } + + public MediaFile(MediaFile mediaFile) { + this.id = mediaFile.id; + this.postID = mediaFile.postID; + this.filePath = mediaFile.filePath; + this.fileName = mediaFile.fileName; + this.title = mediaFile.title; + this.description = mediaFile.description; + this.caption = mediaFile.caption; + this.horizontalAlignment = mediaFile.horizontalAlignment; + this.verticalAligment = mediaFile.verticalAligment; + this.width = mediaFile.width; + this.height = mediaFile.height; + this.mimeType = mediaFile.mimeType; + this.videoPressShortCode = mediaFile.videoPressShortCode; + this.featured = mediaFile.featured; + this.isVideo = mediaFile.isVideo; + this.featuredInPost = mediaFile.featuredInPost; + this.fileURL = mediaFile.fileURL; + this.thumbnailURL = mediaFile.thumbnailURL; + this.blogId = mediaFile.blogId; + this.dateCreatedGmt = mediaFile.dateCreatedGmt; + this.uploadState = mediaFile.uploadState; + this.mediaId = mediaFile.mediaId; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getMediaId() { + return mediaId; + } + + public void setMediaId(String id) { + mediaId = id; + } + + public boolean isFeatured() { + return featured; + } + + public void setFeatured(boolean featured) { + this.featured = featured; + } + + public long getPostID() { + return postID; + } + + public void setPostID(long postID) { + this.postID = postID; + } + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getCaption() { + return caption; + } + + public void setCaption(String caption) { + this.caption = caption; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getFileURL() { + return fileURL; + } + + public void setFileURL(String fileURL) { + this.fileURL = fileURL; + } + + public String getThumbnailURL() { + return thumbnailURL; + } + + public void setThumbnailURL(String thumbnailURL) { + this.thumbnailURL = thumbnailURL; + } + + public boolean isVerticalAlignmentOnTop() { + return verticalAligment; + } + + public void setVerticalAlignmentOnTop(boolean verticalAligment) { + this.verticalAligment = verticalAligment; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getMimeType() { + return StringUtils.notNullStr(mimeType); + } + + public void setMimeType(String type) { + mimeType = StringUtils.notNullStr(type); + } + + public String getVideoPressShortCode() { + return videoPressShortCode; + } + + public void setVideoPressShortCode(String videoPressShortCode) { + this.videoPressShortCode = videoPressShortCode; + } + + public int getHorizontalAlignment() { + return horizontalAlignment; + } + + public void setHorizontalAlignment(int horizontalAlignment) { + this.horizontalAlignment = horizontalAlignment; + } + + public boolean isVideo() { + return isVideo; + } + + public void setVideo(boolean isVideo) { + this.isVideo = isVideo; + } + + public boolean isFeaturedInPost() { + return featuredInPost; + } + + public void setFeaturedInPost(boolean featuredInPost) { + this.featuredInPost = featuredInPost; + } + + public String getBlogId() { + return blogId; + } + + public void setBlogId(String blogId) { + this.blogId = blogId; + } + + public void setDateCreatedGMT(long date_created_gmt) { + this.dateCreatedGmt = date_created_gmt; + } + + public long getDateCreatedGMT() { + return dateCreatedGmt; + } + + public void setUploadState(String uploadState) { + this.uploadState = uploadState; + } + + public String getUploadState() { + return uploadState; + } + + /** + * Outputs the Html for an image + * If a fullSizeUrl exists, a link will be created to it from the resizedPictureUrl + */ + public String getImageHtmlForUrls(String fullSizeUrl, String resizedPictureURL, boolean shouldAddImageWidthCSS) { + String alignment = ""; + switch (getHorizontalAlignment()) { + case 0: + alignment = "alignnone"; + break; + case 1: + alignment = "alignleft"; + break; + case 2: + alignment = "aligncenter"; + break; + case 3: + alignment = "alignright"; + break; + } + + String alignmentCSS = "class=\"" + alignment + " size-full\" "; + + if (shouldAddImageWidthCSS) { + alignmentCSS += "style=\"max-width: " + getWidth() + "px\" "; + } + + // Check if we uploaded a featured picture that is not added to the Post content (normal case) + if ((fullSizeUrl != null && fullSizeUrl.equalsIgnoreCase("")) || + (resizedPictureURL != null && resizedPictureURL.equalsIgnoreCase(""))) { + return ""; // Not featured in Post. Do not add to the content. + } + + if (fullSizeUrl == null && resizedPictureURL != null) { + fullSizeUrl = resizedPictureURL; + } else if (fullSizeUrl != null && resizedPictureURL == null) { + resizedPictureURL = fullSizeUrl; + } + + String mediaTitle = StringUtils.notNullStr(getTitle()); + + String content = String.format("<a href=\"%s\"><img title=\"%s\" %s alt=\"image\" src=\"%s\" /></a>", + fullSizeUrl, mediaTitle, alignmentCSS, resizedPictureURL); + + if (!TextUtils.isEmpty(getCaption())) { + content = String.format("[caption id=\"\" align=\"%s\" width=\"%d\"]%s%s[/caption]", + alignment, getWidth(), content, TextUtils.htmlEncode(getCaption())); + } + + return content; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaGallery.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaGallery.java new file mode 100644 index 000000000..ab7326a17 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaGallery.java @@ -0,0 +1,87 @@ + +package org.wordpress.android.util.helpers; + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * A model representing a Media Gallery. + * A unique id is not used on the website, but only in this app. + * It is used to uniquely determining the instance of the object, as it is + * passed between post and media gallery editor. + */ +public class MediaGallery implements Serializable { + private static final long serialVersionUID = 2359176987182027508L; + + private long uniqueId; + private boolean isRandom; + private String type; + private int numColumns; + private ArrayList<String> ids; + + public MediaGallery(boolean isRandom, String type, int numColumns, ArrayList<String> ids) { + this.isRandom = isRandom; + this.type = type; + this.numColumns = numColumns; + this.ids = ids; + this.uniqueId = System.currentTimeMillis(); + } + + public MediaGallery() { + isRandom = false; + type = ""; + numColumns = 3; + ids = new ArrayList<String>(); + this.uniqueId = System.currentTimeMillis(); + } + + public boolean isRandom() { + return isRandom; + } + + public void setRandom(boolean isRandom) { + this.isRandom = isRandom; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public int getNumColumns() { + return numColumns; + } + + public void setNumColumns(int numColumns) { + this.numColumns = numColumns; + } + + public ArrayList<String> getIds() { + return ids; + } + + public String getIdsStr() { + String ids_str = ""; + if (ids.size() > 0) { + for (String id : ids) { + ids_str += id + ","; + } + ids_str = ids_str.substring(0, ids_str.length() - 1); + } + return ids_str; + } + + public void setIds(ArrayList<String> ids) { + this.ids = ids; + } + + /** + * An id to uniquely identify a media gallery object, so that the same object can be edited in the post editor + */ + public long getUniqueId() { + return uniqueId; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaGalleryImageSpan.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaGalleryImageSpan.java new file mode 100644 index 000000000..588b98141 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/MediaGalleryImageSpan.java @@ -0,0 +1,21 @@ +package org.wordpress.android.util.helpers; + +import android.content.Context; +import android.text.style.ImageSpan; + +public class MediaGalleryImageSpan extends ImageSpan { + private MediaGallery mMediaGallery; + + public MediaGalleryImageSpan(Context context, MediaGallery mediaGallery, int placeHolder) { + super(context, placeHolder); + setMediaGallery(mediaGallery); + } + + public MediaGallery getMediaGallery() { + return mMediaGallery; + } + + public void setMediaGallery(MediaGallery mediaGallery) { + this.mMediaGallery = mediaGallery; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/SwipeToRefreshHelper.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/SwipeToRefreshHelper.java new file mode 100644 index 000000000..e6f4bf323 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/SwipeToRefreshHelper.java @@ -0,0 +1,72 @@ +package org.wordpress.android.util.helpers; + +import android.app.Activity; +import android.content.Context; +import android.content.res.TypedArray; +import android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener; +import android.util.TypedValue; + +import org.wordpress.android.util.R; +import org.wordpress.android.util.widgets.CustomSwipeRefreshLayout; + +public class SwipeToRefreshHelper implements OnRefreshListener { + private CustomSwipeRefreshLayout mSwipeRefreshLayout; + private RefreshListener mRefreshListener; + private boolean mRefreshing; + + public interface RefreshListener { + public void onRefreshStarted(); + } + + public SwipeToRefreshHelper(Context context, CustomSwipeRefreshLayout swipeRefreshLayout, RefreshListener listener) { + init(context, swipeRefreshLayout, listener); + } + + public void init(Context context, CustomSwipeRefreshLayout swipeRefreshLayout, RefreshListener listener) { + mRefreshListener = listener; + mSwipeRefreshLayout = swipeRefreshLayout; + mSwipeRefreshLayout.setOnRefreshListener(this); + final TypedArray styleAttrs = obtainStyledAttrsFromThemeAttr(context, R.attr.swipeToRefreshStyle, + R.styleable.RefreshIndicator); + int color = styleAttrs.getColor(R.styleable.RefreshIndicator_refreshIndicatorColor, context.getResources() + .getColor(android.R.color.holo_blue_dark)); + mSwipeRefreshLayout.setColorSchemeColors(color, color, color, color); + } + + public void setRefreshing(boolean refreshing) { + mRefreshing = refreshing; + // Delayed refresh, it fixes https://code.google.com/p/android/issues/detail?id=77712 + // 50ms seems a good compromise (always worked during tests) and fast enough so user can't notice the delay + if (refreshing) { + mSwipeRefreshLayout.postDelayed(new Runnable() { + @Override + public void run() { + // use mRefreshing so if the refresh takes less than 50ms, loading indicator won't show up. + mSwipeRefreshLayout.setRefreshing(mRefreshing); + } + }, 50); + } else { + mSwipeRefreshLayout.setRefreshing(false); + } + } + + public boolean isRefreshing() { + return mSwipeRefreshLayout.isRefreshing(); + } + + @Override + public void onRefresh() { + mRefreshListener.onRefreshStarted(); + } + + public void setEnabled(boolean enabled) { + mSwipeRefreshLayout.setEnabled(enabled); + } + + public static TypedArray obtainStyledAttrsFromThemeAttr(Context context, int themeAttr, int[] styleAttrs) { + TypedValue outValue = new TypedValue(); + context.getTheme().resolveAttribute(themeAttr, outValue, true); + int styleResId = outValue.resourceId; + return context.obtainStyledAttributes(styleResId, styleAttrs); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/Version.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/Version.java new file mode 100644 index 000000000..b35f84757 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/Version.java @@ -0,0 +1,47 @@ +package org.wordpress.android.util.helpers; + +//See: http://stackoverflow.com/a/11024200 +public class Version implements Comparable<Version> { + 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; + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPHtmlTagHandler.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPHtmlTagHandler.java new file mode 100644 index 000000000..da333b24e --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPHtmlTagHandler.java @@ -0,0 +1,59 @@ +package org.wordpress.android.util.helpers; + +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<String> mListParents = new Vector<String>(); + + @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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPImageGetter.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPImageGetter.java new file mode 100644 index 000000000..b03d74045 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPImageGetter.java @@ -0,0 +1,177 @@ +package org.wordpress.android.util.helpers; + +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; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.PhotonUtils; + +import java.lang.ref.WeakReference; + +/** + * ImageGetter for Html.fromHtml() + * adapted from existing ImageGetter code in NoteCommentFragment + */ +public class WPImageGetter implements Html.ImageGetter { + private final WeakReference<TextView> mWeakView; + private final 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<TextView>(view); + mMaxSize = maxSize; + } + + public WPImageGetter(TextView view, int maxSize, ImageLoader imageLoader, Drawable loadingDrawable, + Drawable failedDrawable) { + mWeakView = new WeakReference<TextView>(view); + mMaxSize = maxSize; + mImageLoader = imageLoader; + mLoadingDrawable = loadingDrawable; + 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); + } + + 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) { + AppLog.w(T.UTILS, "WPImageGetter null bitmap"); + } + + TextView view = getView(); + if (view == null) { + AppLog.w(T.UTILS, "WPImageGetter view is invalid"); + return; + } + + int maxWidth = view.getWidth() - view.getPaddingLeft() - view.getPaddingRight(); + if (mMaxSize > 0 && (maxWidth > mMaxSize || maxWidth == 0)) { + maxWidth = mMaxSize; + } + + Drawable drawable = new BitmapDrawable(view.getContext().getResources(), response.getBitmap()); + remote.setRemoteDrawable(drawable, maxWidth); + + // force textView to resize correctly if image isn't cached by resetting the content + // to itself - this way the textView will use the cached image, and resizing to + // accommodate the image isn't necessary + if (!isImmediate) { + view.setText(view.getText()); + } + } + }); + + return remote; + } + + public static class RemoteDrawable extends BitmapDrawable { + Drawable mRemoteDrawable; + final Drawable mLoadingDrawable; + final 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, 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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPImageSpan.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPImageSpan.java new file mode 100644 index 000000000..fa0a0b4aa --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPImageSpan.java @@ -0,0 +1,140 @@ +//Add WordPress image fields to ImageSpan object + +package org.wordpress.android.util.helpers; + +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.style.ImageSpan; + +public class WPImageSpan extends ImageSpan implements Parcelable { + protected Uri mImageSource = null; + protected boolean mNetworkImageLoaded = false; + protected MediaFile mMediaFile; + protected int mStartPosition, mEndPosition; + + protected WPImageSpan() { + super((Bitmap) null); + } + + public WPImageSpan(Context context, Bitmap b, Uri src) { + super(context, b); + this.mImageSource = src; + mMediaFile = new MediaFile(); + } + + public WPImageSpan(Context context, int resId, Uri src) { + super(context, resId); + this.mImageSource = src; + mMediaFile = new MediaFile(); + } + + public void setPosition(int start, int end) { + mStartPosition = start; + mEndPosition = end; + } + + public int getStartPosition() { + return mStartPosition >= 0 ? mStartPosition : 0; + } + + public int getEndPosition() { + return mEndPosition < getStartPosition() ? getStartPosition() : mEndPosition; + } + + public MediaFile getMediaFile() { + return mMediaFile; + } + + public void setMediaFile(MediaFile mMediaFile) { + this.mMediaFile = mMediaFile; + } + + public void setImageSource(Uri mImageSource) { + this.mImageSource = mImageSource; + } + + public Uri getImageSource() { + return mImageSource; + } + + public boolean isNetworkImageLoaded() { + return mNetworkImageLoaded; + } + + public void setNetworkImageLoaded(boolean networkImageLoaded) { + this.mNetworkImageLoaded = networkImageLoaded; + } + + protected void setupFromParcel(Parcel in) { + MediaFile mediaFile = new MediaFile(); + + boolean[] booleans = new boolean[2]; + in.readBooleanArray(booleans); + setNetworkImageLoaded(booleans[0]); + mediaFile.setVideo(booleans[1]); + + setImageSource(Uri.parse(in.readString())); + mediaFile.setMediaId(in.readString()); + mediaFile.setBlogId(in.readString()); + mediaFile.setPostID(in.readLong()); + mediaFile.setCaption(in.readString()); + mediaFile.setDescription(in.readString()); + mediaFile.setTitle(in.readString()); + mediaFile.setMimeType(in.readString()); + mediaFile.setFileName(in.readString()); + mediaFile.setThumbnailURL(in.readString()); + mediaFile.setVideoPressShortCode(in.readString()); + mediaFile.setFileURL(in.readString()); + mediaFile.setFilePath(in.readString()); + mediaFile.setDateCreatedGMT(in.readLong()); + mediaFile.setWidth(in.readInt()); + mediaFile.setHeight(in.readInt()); + setPosition(in.readInt(), in.readInt()); + + setMediaFile(mediaFile); + } + + public static final Parcelable.Creator<WPImageSpan> CREATOR + = new Parcelable.Creator<WPImageSpan>() { + public WPImageSpan createFromParcel(Parcel in) { + WPImageSpan imageSpan = new WPImageSpan(); + imageSpan.setupFromParcel(in); + return imageSpan; + } + + public WPImageSpan[] newArray(int size) { + return new WPImageSpan[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeBooleanArray(new boolean[] {mNetworkImageLoaded, mMediaFile.isVideo()}); + parcel.writeString(mImageSource.toString()); + parcel.writeString(mMediaFile.getMediaId()); + parcel.writeString(mMediaFile.getBlogId()); + parcel.writeLong(mMediaFile.getPostID()); + parcel.writeString(mMediaFile.getCaption()); + parcel.writeString(mMediaFile.getDescription()); + parcel.writeString(mMediaFile.getTitle()); + parcel.writeString(mMediaFile.getMimeType()); + parcel.writeString(mMediaFile.getFileName()); + parcel.writeString(mMediaFile.getThumbnailURL()); + parcel.writeString(mMediaFile.getVideoPressShortCode()); + parcel.writeString(mMediaFile.getFileURL()); + parcel.writeString(mMediaFile.getFilePath()); + parcel.writeLong(mMediaFile.getDateCreatedGMT()); + parcel.writeInt(mMediaFile.getWidth()); + parcel.writeInt(mMediaFile.getHeight()); + parcel.writeInt(getStartPosition()); + parcel.writeInt(getEndPosition()); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPQuoteSpan.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPQuoteSpan.java new file mode 100644 index 000000000..33cdc0093 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPQuoteSpan.java @@ -0,0 +1,44 @@ +package org.wordpress.android.util.helpers; + +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/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPUnderlineSpan.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPUnderlineSpan.java new file mode 100644 index 000000000..4b6805ccf --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPUnderlineSpan.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * + * 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.helpers; + +import android.os.Parcel; +import android.text.style.UnderlineSpan; + +/** + * WPUnderlineSpan is used as an alternative class to UnderlineSpan. UnderlineSpan is used by EditText auto + * correct, so it can get mixed up with our formatting. + */ +public class WPUnderlineSpan extends UnderlineSpan { + public WPUnderlineSpan() { + super(); + } + + public WPUnderlineSpan(Parcel src) { + super(src); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPWebChromeClient.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPWebChromeClient.java new file mode 100644 index 000000000..1418e79ea --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/helpers/WPWebChromeClient.java @@ -0,0 +1,45 @@ +package org.wordpress.android.util.helpers; + +import android.app.Activity; +import android.text.TextUtils; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.widget.ProgressBar; + +public class WPWebChromeClient extends WebChromeClient { + private final ProgressBar mProgressBar; + private final Activity mActivity; + private final boolean mAutoUpdateActivityTitle; + + public WPWebChromeClient(Activity activity, ProgressBar progressBar) { + mActivity = activity; + mProgressBar = progressBar; + mAutoUpdateActivityTitle = true; + } + + public WPWebChromeClient(Activity activity, + ProgressBar progressBar, + boolean autoUpdateActivityTitle) { + mActivity = activity; + mProgressBar = progressBar; + mAutoUpdateActivityTitle = autoUpdateActivityTitle; + } + + public void onProgressChanged(WebView webView, int progress) { + if (mActivity != null + && !mActivity.isFinishing() + && mAutoUpdateActivityTitle + && !TextUtils.isEmpty(webView.getTitle())) { + mActivity.setTitle(webView.getTitle()); + } + if (mProgressBar != null) { + if (progress == 100) { + mProgressBar.setVisibility(View.GONE); + } else { + mProgressBar.setVisibility(View.VISIBLE); + mProgressBar.setProgress(progress); + } + } + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/AutoResizeTextView.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/AutoResizeTextView.java new file mode 100644 index 000000000..b0b4dc017 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/AutoResizeTextView.java @@ -0,0 +1,299 @@ +package org.wordpress.android.util.widgets; + +import android.content.Context; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.TextView; + +/** + * Text view that auto adjusts text size to fit within the view. + * If the text size equals the minimum text size and still does not + * fit, append with an ellipsis. + * + * See http://stackoverflow.com/a/5535672 + * + */ +public class AutoResizeTextView extends TextView { + // Minimum text size for this text view + private static final float MIN_TEXT_SIZE = 20; + + // Interface for resize notifications + public interface OnTextResizeListener { + void onTextResize(TextView textView, float oldSize, float newSize); + } + + // Our ellipse string - Unicode Character 'HORIZONTAL ELLIPSIS' + private static final String M_ELLIPSIS = "\u2026"; + + // Registered resize listener + private OnTextResizeListener mTextResizeListener; + + // Flag for text and/or size changes to force a resize + private boolean mNeedsResize = false; + + // Text size that is set from code. This acts as a starting point for resizing + private float mTextSize; + + // Temporary upper bounds on the starting text size + private float mMaxTextSize = 0; + + // Lower bounds for text size + private float mMinTextSize = MIN_TEXT_SIZE; + + // Text view line spacing multiplier + private float mSpacingMult = 1.0f; + + // Text view additional line spacing + private float mSpacingAdd = 0.0f; + + // Add ellipsis to text that overflows at the smallest text size + private boolean mAddEllipsis = true; + + // Default constructor override + public AutoResizeTextView(Context context) { + this(context, null); + } + + // Default constructor when inflating from XML file + public AutoResizeTextView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + // Default constructor override + public AutoResizeTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mTextSize = getTextSize(); + } + + /** + * When text changes, set the force resize flag to true and reset the text size. + */ + @Override + protected void onTextChanged(final CharSequence text, final int start, final int before, final int after) { + mNeedsResize = true; + // Since this view may be reused, it is good to reset the text size + resetTextSize(); + } + + /** + * If the text view size changed, set the force resize flag to true + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + if (w != oldw || h != oldh) { + mNeedsResize = true; + } + } + + /** + * Register listener to receive resize notifications + * @param listener + */ + public void setOnResizeListener(OnTextResizeListener listener) { + mTextResizeListener = listener; + } + + /** + * Override the set text size to update our internal reference values + */ + @Override + public void setTextSize(float size) { + super.setTextSize(size); + mTextSize = getTextSize(); + } + + /** + * Override the set text size to update our internal reference values + */ + @Override + public void setTextSize(int unit, float size) { + super.setTextSize(unit, size); + mTextSize = getTextSize(); + } + + /** + * Override the set line spacing to update our internal reference values + */ + @Override + public void setLineSpacing(float add, float mult) { + super.setLineSpacing(add, mult); + mSpacingMult = mult; + mSpacingAdd = add; + } + + /** + * Set the upper text size limit and invalidate the view + * @param maxTextSize + */ + public void setMaxTextSize(float maxTextSize) { + mMaxTextSize = maxTextSize; + requestLayout(); + invalidate(); + } + + /** + * Return upper text size limit + * @return + */ + public float getMaxTextSize() { + return mMaxTextSize; + } + + /** + * Set the lower text size limit and invalidate the view + * @param minTextSize + */ + public void setMinTextSize(float minTextSize) { + mMinTextSize = minTextSize; + requestLayout(); + invalidate(); + } + + /** + * Return lower text size limit + * @return + */ + public float getMinTextSize() { + return mMinTextSize; + } + + /** + * Set flag to add ellipsis to text that overflows at the smallest text size + * @param addEllipsis + */ + public void setAddEllipsis(boolean addEllipsis) { + mAddEllipsis = addEllipsis; + } + + /** + * Return flag to add ellipsis to text that overflows at the smallest text size + * @return + */ + public boolean getAddEllipsis() { + return mAddEllipsis; + } + + /** + * Reset the text to the original size + */ + private void resetTextSize() { + if (mTextSize > 0) { + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + mMaxTextSize = mTextSize; + } + } + + /** + * Resize text after measuring + */ + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (changed || mNeedsResize) { + int widthLimit = (right - left) - getCompoundPaddingLeft() - getCompoundPaddingRight(); + int heightLimit = (bottom - top) - getCompoundPaddingBottom() - getCompoundPaddingTop(); + resizeText(widthLimit, heightLimit); + } + super.onLayout(changed, left, top, right, bottom); + } + + /** + * Resize the text size with default width and height + */ + public void resizeText() { + int heightLimit = getHeight() - getPaddingBottom() - getPaddingTop(); + int widthLimit = getWidth() - getPaddingLeft() - getPaddingRight(); + resizeText(widthLimit, heightLimit); + } + + /** + * Resize the text size with specified width and height + * @param width + * @param height + */ + public void resizeText(int width, int height) { + CharSequence text = getText(); + // Do not resize if the view does not have dimensions or there is no text + if (text == null || text.length() == 0 || height <= 0 || width <= 0 || mTextSize == 0) { + return; + } + + // Get the text view's paint object + TextPaint textPaint = getPaint(); + + // Store the current text size + float oldTextSize = textPaint.getTextSize(); + // If there is a max text size set, use the lesser of that and the default text size + float targetTextSize = mMaxTextSize > 0 ? Math.min(mTextSize, mMaxTextSize) : mTextSize; + + // Get the required text height + int textHeight = getTextHeight(text, textPaint, width, targetTextSize); + + // Until we either fit within our text view or we had reached our min text size, incrementally try smaller sizes + while (textHeight > height && targetTextSize > mMinTextSize) { + targetTextSize = Math.max(targetTextSize - 2, mMinTextSize); + textHeight = getTextHeight(text, textPaint, width, targetTextSize); + } + + // If we had reached our minimum text size and still don't fit, append an ellipsis + if (mAddEllipsis && targetTextSize == mMinTextSize && textHeight > height) { + // Draw using a static layout + // modified: use a copy of TextPaint for measuring + TextPaint paint = new TextPaint(textPaint); + // Draw using a static layout + StaticLayout layout = new StaticLayout(text, paint, width, Layout.Alignment.ALIGN_NORMAL, + mSpacingMult, mSpacingAdd, false); + // Check that we have a least one line of rendered text + if (layout.getLineCount() > 0) { + // Since the line at the specific vertical position would be cut off, + // we must trim up to the previous line + int lastLine = layout.getLineForVertical(height) - 1; + // If the text would not even fit on a single line, clear it + if (lastLine < 0) { + setText(""); + } else { + // Otherwise, trim to the previous line and add an ellipsis + int start = layout.getLineStart(lastLine); + int end = layout.getLineEnd(lastLine); + float lineWidth = layout.getLineWidth(lastLine); + float ellipseWidth = paint.measureText(M_ELLIPSIS); + + // Trim characters off until we have enough room to draw the ellipsis + while (width < lineWidth + ellipseWidth) { + lineWidth = paint.measureText(text.subSequence(start, --end + 1).toString()); + } + setText(text.subSequence(0, end) + M_ELLIPSIS); + } + } + } + + // Some devices try to auto adjust line spacing, so force default line spacing + // and invalidate the layout as a side effect + setTextSize(TypedValue.COMPLEX_UNIT_PX, targetTextSize); + setLineSpacing(mSpacingAdd, mSpacingMult); + + // Notify the listener if registered + if (mTextResizeListener != null) { + mTextResizeListener.onTextResize(this, oldTextSize, targetTextSize); + } + + // Reset force resize flag + mNeedsResize = false; + } + + // Set the text size of the text paint object and use a static layout to render text off screen before measuring + private int getTextHeight(CharSequence source, TextPaint paint, int width, float textSize) { + // modified: make a copy of the original TextPaint object for measuring + // (apparently the object gets modified while measuring, see also the + // docs for TextView.getPaint() (which states to access it read-only) + TextPaint paintCopy = new TextPaint(paint); + // Update the text paint object + paintCopy.setTextSize(textSize); + // Measure using a static layout + StaticLayout layout = new StaticLayout(source, paintCopy, width, Layout.Alignment.ALIGN_NORMAL, + mSpacingMult, mSpacingAdd, true); + return layout.getHeight(); + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/CustomSwipeRefreshLayout.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/CustomSwipeRefreshLayout.java new file mode 100644 index 000000000..356268922 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/CustomSwipeRefreshLayout.java @@ -0,0 +1,33 @@ +package org.wordpress.android.util.widgets; + +import android.content.Context; +import android.support.v4.widget.SwipeRefreshLayout; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +public class CustomSwipeRefreshLayout extends SwipeRefreshLayout { + public CustomSwipeRefreshLayout(Context context) { + super(context); + } + + public CustomSwipeRefreshLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + try{ + return super.onTouchEvent(event); + } catch(IllegalArgumentException e) { + // Fix for https://github.com/wordpress-mobile/WordPress-Android/issues/2373 + // Catch IllegalArgumentException which can be fired by the underlying SwipeRefreshLayout.onTouchEvent() + // method. + // When android support-v4 fixes it, we'll have to remove that custom layout completely. + AppLog.e(T.UTILS, e); + return true; + } + } +} diff --git a/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/WPEditText.java b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/WPEditText.java new file mode 100644 index 000000000..0468cf807 --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/java/org/wordpress/android/util/widgets/WPEditText.java @@ -0,0 +1,62 @@ +package org.wordpress.android.util.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.widget.EditText; + +/* + * @deprecated This custom EditText is used solely by the "legacy" editor in WP Android. + * It will be removed when we drop the legacy editor and should not be used in new code. + */ +@Deprecated +public class WPEditText extends EditText { + private EditTextImeBackListener mOnImeBack; + private OnSelectionChangedListener onSelectionChangedListener; + + public WPEditText(Context context) { + super(context); + } + + public WPEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public WPEditText(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (onSelectionChangedListener != null) { + onSelectionChangedListener.onSelectionChanged(); + } + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK + && event.getAction() == KeyEvent.ACTION_UP) { + if (mOnImeBack != null) + mOnImeBack.onImeBack(this, this.getText().toString()); + } + + return super.onKeyPreIme(keyCode, event); + } + + public void setOnEditTextImeBackListener(EditTextImeBackListener listener) { + mOnImeBack = listener; + } + + public interface EditTextImeBackListener { + public abstract void onImeBack(WPEditText ctrl, String text); + } + + public void setOnSelectionChangedListener(OnSelectionChangedListener listener) { + onSelectionChangedListener = listener; + } + + public interface OnSelectionChangedListener { + public abstract void onSelectionChanged(); + } +} diff --git a/libs/utils/WordPressUtils/src/main/res/values/attrs.xml b/libs/utils/WordPressUtils/src/main/res/values/attrs.xml new file mode 100644 index 000000000..dd1fa5cbf --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/res/values/attrs.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <attr name="swipeToRefreshStyle" format="reference"/> + <declare-styleable name="RefreshIndicator"> + <attr name="refreshIndicatorColor" format="reference|color"/> + </declare-styleable> +</resources> diff --git a/libs/utils/WordPressUtils/src/main/res/values/strings.xml b/libs/utils/WordPressUtils/src/main/res/values/strings.xml new file mode 100644 index 000000000..34d25dada --- /dev/null +++ b/libs/utils/WordPressUtils/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="no_network_message">There is no network available</string> + <string name="timespan_now">Now</string> +</resources> |