package com.mopub.mraid; import android.annotation.TargetApi; import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.AsyncTask; import android.os.Build.VERSION_CODES; import android.os.Environment; import android.provider.CalendarContract; import android.support.annotation.NonNull; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.Toast; import com.mopub.common.Preconditions; import com.mopub.common.VisibleForTesting; import com.mopub.common.logging.MoPubLog; import com.mopub.common.util.AsyncTasks; import com.mopub.common.util.Intents; import com.mopub.common.util.Streams; import com.mopub.common.util.Utils; import com.mopub.common.util.VersionCode; import com.mopub.mobileads.factories.HttpClientFactory; import com.mopub.network.HeaderUtils; import org.apache.http.Header; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URI; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; import static android.os.Environment.MEDIA_MOUNTED; import static com.mopub.common.HttpClient.*; import static com.mopub.common.util.ResponseHeader.LOCATION; public class MraidNativeCommandHandler { interface MraidCommandFailureListener { void onFailure(MraidCommandException exception); } @VisibleForTesting static final String MIME_TYPE_HEADER = "Content-Type"; private static final int MAX_NUMBER_DAYS_IN_MONTH = 31; private static final String[] DATE_FORMATS = { "yyyy-MM-dd'T'HH:mm:ssZZZZZ", "yyyy-MM-dd'T'HH:mmZZZZZ" }; public static final String ANDROID_CALENDAR_CONTENT_TYPE = "vnd.android.cursor.item/event"; void createCalendarEvent(final Context context, final Map<String, String> params) throws MraidCommandException { if (isCalendarAvailable(context)) { try { Map<String, Object> calendarParams = translateJSParamsToAndroidCalendarEventMapping(params); Intent intent = new Intent(Intent.ACTION_INSERT).setType(ANDROID_CALENDAR_CONTENT_TYPE); for (String key : calendarParams.keySet()) { Object value = calendarParams.get(key); if (value instanceof Long) { intent.putExtra(key, ((Long) value).longValue()); } else if (value instanceof Integer) { intent.putExtra(key, ((Integer) value).intValue()); } else { intent.putExtra(key, (String) value); } } intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } catch (ActivityNotFoundException e) { MoPubLog.d("no calendar app installed"); throw new MraidCommandException( "Action is unsupported on this device - no calendar app installed"); } catch (IllegalArgumentException e) { MoPubLog.d("create calendar: invalid parameters " + e.getMessage()); throw new MraidCommandException(e); } catch (Exception e) { MoPubLog.d("could not create calendar event"); throw new MraidCommandException(e); } } else { MoPubLog.d("unsupported action createCalendarEvent for devices pre-ICS"); throw new MraidCommandException("Action is " + "unsupported on this device (need Android version Ice Cream Sandwich or " + "above)"); } } void storePicture(@NonNull final Context context, @NonNull final String imageUrl, @NonNull MraidCommandFailureListener failureListener) throws MraidCommandException { if (!isStorePictureSupported(context)) { MoPubLog.d("Error downloading file - the device does not have an SD card mounted, or " + "the Android permission is not granted."); throw new MraidCommandException("Error downloading file " + " - the device does not have an SD card mounted, " + "or the Android permission is not granted."); } if (context instanceof Activity) { showUserDialog(context, imageUrl, failureListener); } else { Toast.makeText(context, "Downloading image to Picture gallery...", Toast.LENGTH_SHORT).show(); downloadImage(context, imageUrl, failureListener); } } boolean isTelAvailable(Context context) { Intent telIntent = new Intent(Intent.ACTION_DIAL); telIntent.setData(Uri.parse("tel:")); return Intents.deviceCanHandleIntent(context, telIntent); } boolean isSmsAvailable(Context context) { Intent smsIntent = new Intent(Intent.ACTION_VIEW); smsIntent.setData(Uri.parse("sms:")); return Intents.deviceCanHandleIntent(context, smsIntent); } public static boolean isStorePictureSupported(Context context) { return MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) && context.checkCallingOrSelfPermission(WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; } static boolean isCalendarAvailable(Context context) { Intent calendarIntent = new Intent(Intent.ACTION_INSERT).setType(ANDROID_CALENDAR_CONTENT_TYPE); return VersionCode.currentApiLevel().isAtLeast(VersionCode.ICE_CREAM_SANDWICH) && Intents.deviceCanHandleIntent(context, calendarIntent); } /** * Inline video support was added in 3.1. Returns true if the activity has hardware acceleration * enabled in its foreground window and only if the View or any ParentView in the view tree * has not had hardware acceleration explicitly turned off. */ // TargetApi is needed to access hardware accelerated flags @TargetApi(11) boolean isInlineVideoAvailable(@NonNull Activity activity, @NonNull View view) { // In addition to potential hardware acceleration problems, there is a problem in the WebKit // HTML5VideoView implementation pre-Gingerbread that would result in HTML5VideoViewProxy // holding on to an instance of the WebView even after the WebView is destroyed. For // this reason, we never allow inline video on Gingerbread devices. if (VersionCode.currentApiLevel().isBelow(VersionCode.HONEYCOMB_MR1)) { return false; } // Hardware Acceleration // Hardware acceleration for the application and activity is enabled by default // in API >= 14 (Ice Cream Sandwich) // http://developer.android.com/reference/android/R.attr.html#hardwareAccelerated // http://developer.android.com/guide/topics/graphics/hardware-accel.html // HTML5 Inline Video // http://developer.android.com/about/versions/android-3.1.html // Traverse up the View tree to determine if any views are being software rendered // You can only disable hardware acceleration at the view level by setting the layer type View tempView = view; while (true) { // View#isHardwareAccelerated does not reflect the layer type used to render the view // therefore we have to check for both if (!tempView.isHardwareAccelerated() || Utils.bitMaskContainsFlag(tempView.getLayerType(), View.LAYER_TYPE_SOFTWARE)) { return false; } // If parent is not a view or parent is null then break if (!(tempView.getParent() instanceof View)) { break; } tempView = (View)tempView.getParent(); } // Has hardware acceleration been enabled in the current window? // Hardware acceleration can only be enabled for a window, not disabled // This flag is automatically set by the system if the android:hardwareAccelerated // XML attribute is set to true on an activity or on the application. // http://developer.android.com/reference/android/view/WindowManager.LayoutParams.html#FLAG_HARDWARE_ACCELERATED Window window = activity.getWindow(); if (window != null) { if (Utils.bitMaskContainsFlag(window.getAttributes().flags, WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED)) { return true; } } return false; } @TargetApi(VERSION_CODES.ICE_CREAM_SANDWICH) private Map<String, Object> translateJSParamsToAndroidCalendarEventMapping(Map<String, String> params) { Map<String, Object> validatedParamsMapping = new HashMap<String, Object>(); if (!params.containsKey("description") || !params.containsKey("start")) { throw new IllegalArgumentException("Missing start and description fields"); } validatedParamsMapping.put(CalendarContract.Events.TITLE, params.get("description")); if (params.containsKey("start") && params.get("start") != null) { Date startDateTime = parseDate(params.get("start")); if (startDateTime != null) { validatedParamsMapping.put(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startDateTime.getTime()); } else { throw new IllegalArgumentException("Invalid calendar event: start time is malformed. Date format expecting (yyyy-MM-DDTHH:MM:SS-xx:xx) or (yyyy-MM-DDTHH:MM-xx:xx) i.e. 2013-08-14T09:00:01-08:00"); } } else { throw new IllegalArgumentException("Invalid calendar event: start is null."); } if (params.containsKey("end") && params.get("end") != null) { Date endDateTime = parseDate(params.get("end")); if (endDateTime != null) { validatedParamsMapping.put(CalendarContract.EXTRA_EVENT_END_TIME, endDateTime.getTime()); } else { throw new IllegalArgumentException("Invalid calendar event: end time is malformed. Date format expecting (yyyy-MM-DDTHH:MM:SS-xx:xx) or (yyyy-MM-DDTHH:MM-xx:xx) i.e. 2013-08-14T09:00:01-08:00"); } } if (params.containsKey("location")) { validatedParamsMapping.put(CalendarContract.Events.EVENT_LOCATION, params.get("location")); } if (params.containsKey("summary")) { validatedParamsMapping.put(CalendarContract.Events.DESCRIPTION, params.get("summary")); } if (params.containsKey("transparency")) { validatedParamsMapping.put( CalendarContract.Events.AVAILABILITY, params.get("transparency").equals("transparent") ? CalendarContract.Events.AVAILABILITY_FREE : CalendarContract.Events.AVAILABILITY_BUSY ); } validatedParamsMapping.put(CalendarContract.Events.RRULE, parseRecurrenceRule(params)); return validatedParamsMapping; } private Date parseDate(String dateTime) { Date result = null; for (final String DATE_FORMAT : DATE_FORMATS) { try { result = new SimpleDateFormat(DATE_FORMAT, Locale.US).parse(dateTime); if (result != null) { break; } } catch (ParseException e) { // an exception is okay, just try the next format and find the first one that works } } return result; } private String parseRecurrenceRule(Map<String, String> params) throws IllegalArgumentException { StringBuilder rule = new StringBuilder(); if (params.containsKey("frequency")) { String frequency = params.get("frequency"); int interval = -1; if (params.containsKey("interval")) { interval = Integer.parseInt(params.get("interval")); } if ("daily".equals(frequency)) { rule.append("FREQ=DAILY;"); if (interval != -1) { rule.append("INTERVAL=" + interval + ";"); } } else if ("weekly".equals(frequency)) { rule.append("FREQ=WEEKLY;"); if (interval != -1) { rule.append("INTERVAL=" + interval + ";"); } if (params.containsKey("daysInWeek")) { String weekdays = translateWeekIntegersToDays(params.get("daysInWeek")); if (weekdays == null) { throw new IllegalArgumentException("invalid "); } rule.append("BYDAY=" + weekdays + ";"); } } else if ("monthly".equals(frequency)) { rule.append("FREQ=MONTHLY;"); if (interval != -1) { rule.append("INTERVAL=" + interval + ";"); } if (params.containsKey("daysInMonth")) { String monthDays = translateMonthIntegersToDays(params.get("daysInMonth")); if (monthDays == null) { throw new IllegalArgumentException(); } rule.append("BYMONTHDAY=" + monthDays + ";"); } } else { throw new IllegalArgumentException("frequency is only supported for daily, weekly, and monthly."); } } return rule.toString(); } private String translateWeekIntegersToDays(String expression) throws IllegalArgumentException { StringBuilder daysResult = new StringBuilder(); boolean[] daysAlreadyCounted = new boolean[7]; String[] days = expression.split(","); int dayNumber; for (final String day : days) { dayNumber = Integer.parseInt(day); dayNumber = dayNumber == 7 ? 0 : dayNumber; if (!daysAlreadyCounted[dayNumber]) { daysResult.append(dayNumberToDayOfWeekString(dayNumber) + ","); daysAlreadyCounted[dayNumber] = true; } } if (days.length == 0) { throw new IllegalArgumentException("must have at least 1 day of the week if specifying repeating weekly"); } daysResult.deleteCharAt(daysResult.length() - 1); return daysResult.toString(); } private String translateMonthIntegersToDays(String expression) throws IllegalArgumentException { StringBuilder daysResult = new StringBuilder(); boolean[] daysAlreadyCounted = new boolean[2 * MAX_NUMBER_DAYS_IN_MONTH + 1]; //for -31 to 31 String[] days = expression.split(","); int dayNumber; for (final String day : days) { dayNumber = Integer.parseInt(day); if (!daysAlreadyCounted[dayNumber + MAX_NUMBER_DAYS_IN_MONTH]) { daysResult.append(dayNumberToDayOfMonthString(dayNumber) + ","); daysAlreadyCounted[dayNumber + MAX_NUMBER_DAYS_IN_MONTH] = true; } } if (days.length == 0) { throw new IllegalArgumentException("must have at least 1 day of the month if specifying repeating weekly"); } daysResult.deleteCharAt(daysResult.length() - 1); return daysResult.toString(); } private String dayNumberToDayOfWeekString(int number) throws IllegalArgumentException { String dayOfWeek; switch (number) { case 0: dayOfWeek = "SU"; break; case 1: dayOfWeek = "MO"; break; case 2: dayOfWeek = "TU"; break; case 3: dayOfWeek = "WE"; break; case 4: dayOfWeek = "TH"; break; case 5: dayOfWeek = "FR"; break; case 6: dayOfWeek = "SA"; break; default: throw new IllegalArgumentException("invalid day of week " + number); } return dayOfWeek; } private String dayNumberToDayOfMonthString(int number) throws IllegalArgumentException { String dayOfMonth; // https://android.googlesource.com/platform/frameworks/opt/calendar/+/504844526f1b7afec048c6d2976ffb332670d5ba/src/com/android/calendarcommon2/EventRecurrence.java if (number != 0 && number >= -MAX_NUMBER_DAYS_IN_MONTH && number <= MAX_NUMBER_DAYS_IN_MONTH) { dayOfMonth = "" + number; } else { throw new IllegalArgumentException("invalid day of month " + number); } return dayOfMonth; } void downloadImage(final Context context, final String uriString, final MraidCommandFailureListener failureListener) { final DownloadImageAsyncTask downloadImageAsyncTask = new DownloadImageAsyncTask(context, new DownloadImageAsyncTask.DownloadImageAsyncTaskListener() { @Override public void onSuccess() { MoPubLog.d("Image successfully saved."); } @Override public void onFailure() { Toast.makeText(context, "Image failed to download.", Toast.LENGTH_SHORT).show(); MoPubLog.d("Error downloading and saving image file."); failureListener.onFailure(new MraidCommandException("Error " + "downloading and saving image file.")); } }); AsyncTasks.safeExecuteOnExecutor(downloadImageAsyncTask, uriString); } private void showUserDialog(final Context context, final String imageUrl, final MraidCommandFailureListener failureListener) { AlertDialog.Builder alertDialogDownloadImage = new AlertDialog.Builder(context); alertDialogDownloadImage .setTitle("Save Image") .setMessage("Download image to Picture gallery?") .setNegativeButton("Cancel", null) .setPositiveButton("Okay", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { downloadImage(context, imageUrl, failureListener); } }) .setCancelable(true) .show(); } private static class DownloadImageAsyncTask extends AsyncTask<String, Void, Boolean> { interface DownloadImageAsyncTaskListener { void onSuccess(); void onFailure(); } private final Context mContext; private final DownloadImageAsyncTaskListener mListener; public DownloadImageAsyncTask(@NonNull final Context context, @NonNull final DownloadImageAsyncTaskListener listener) { super(); mContext = context.getApplicationContext(); mListener = listener; } @Override protected Boolean doInBackground(@NonNull String[] params) { Preconditions.checkState(params.length > 0); Preconditions.checkNotNull(params[0]); final File pictureStoragePath = getPictureStoragePath(); //noinspection ResultOfMethodCallIgnored pictureStoragePath.mkdirs(); final String uriString = params[0]; URI uri = URI.create(uriString); final HttpClient httpClient = HttpClientFactory.create(); final HttpGet httpGet = initializeHttpGet(uri.toString()); InputStream pictureInputStream = null; OutputStream pictureOutputStream = null; try { final HttpResponse httpResponse = httpClient.execute(httpGet); pictureInputStream = httpResponse.getEntity().getContent(); final String redirectLocation = HeaderUtils.extractHeader(httpResponse, LOCATION); if (redirectLocation != null) { uri = URI.create(redirectLocation); } final String pictureFileName = getFileNameForUriAndHttpResponse(uri, httpResponse); final File pictureFile = new File(pictureStoragePath, pictureFileName); pictureOutputStream = new FileOutputStream(pictureFile); Streams.copyContent(pictureInputStream, pictureOutputStream); final String pictureFileFullPath = pictureFile.toString(); loadPictureIntoGalleryApp(pictureFileFullPath); return true; } catch (IOException e) { return false; } finally { Streams.closeStream(pictureInputStream); Streams.closeStream(pictureOutputStream); } } @Override protected void onPostExecute(final Boolean success) { if (success != null && success) { mListener.onSuccess(); } else { mListener.onFailure(); } } private String getFileNameForUriAndHttpResponse(final URI uri, final HttpResponse response) { final String path = uri.getPath(); if (path == null) { return null; } String filename = new File(path).getName(); Header header = response.getFirstHeader(MIME_TYPE_HEADER); if (header != null) { String[] fields = header.getValue().split(";"); for (final String field : fields) { String extension; if (field.contains("image/")) { extension = "." + field.split("/")[1]; if (!filename.endsWith(extension)) { filename += extension; } break; } } } return filename; } private File getPictureStoragePath() { return new File(Environment.getExternalStorageDirectory(), "Pictures"); } private void loadPictureIntoGalleryApp(final String filename) { MoPubMediaScannerConnectionClient mediaScannerConnectionClient = new MoPubMediaScannerConnectionClient(filename, null); final MediaScannerConnection mediaScannerConnection = new MediaScannerConnection(mContext, mediaScannerConnectionClient); mediaScannerConnectionClient.setMediaScannerConnection(mediaScannerConnection); mediaScannerConnection.connect(); } } private static class MoPubMediaScannerConnectionClient implements MediaScannerConnection.MediaScannerConnectionClient { private final String mFilename; private final String mMimeType; private MediaScannerConnection mMediaScannerConnection; private MoPubMediaScannerConnectionClient(String filename, String mimeType) { mFilename = filename; mMimeType = mimeType; } private void setMediaScannerConnection(MediaScannerConnection connection) { mMediaScannerConnection = connection; } @Override public void onMediaScannerConnected() { if (mMediaScannerConnection != null) { mMediaScannerConnection.scanFile(mFilename, mMimeType); } } @Override public void onScanCompleted(String path, Uri uri) { if (mMediaScannerConnection != null) { mMediaScannerConnection.disconnect(); } } } }