package org.wordpress.android.ui.notifications.utils;
import android.annotation.TargetApi;
import android.app.AlertDialog;
import android.app.AppOpsManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.AlignmentSpan;
import android.text.style.ImageSpan;
import android.text.style.StyleSpan;
import android.view.View;
import android.widget.TextView;
import com.android.volley.VolleyError;
import com.wordpress.rest.RestRequest;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.wordpress.android.R;
import org.wordpress.android.WordPress;
import org.wordpress.android.analytics.AnalyticsTracker;
import org.wordpress.android.datasets.NotificationsTable;
import org.wordpress.android.models.Note;
import org.wordpress.android.push.GCMMessageService;
import org.wordpress.android.ui.notifications.blocks.NoteBlock;
import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
import org.wordpress.android.util.DeviceUtils;
import org.wordpress.android.util.JSONUtils;
import org.wordpress.android.util.PackageUtils;
import org.wordpress.android.util.helpers.WPImageGetter;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class NotificationsUtils {
public static final String ARG_PUSH_AUTH_TOKEN = "arg_push_auth_token";
public static final String ARG_PUSH_AUTH_TITLE = "arg_push_auth_title";
public static final String ARG_PUSH_AUTH_MESSAGE = "arg_push_auth_message";
public static final String ARG_PUSH_AUTH_EXPIRES = "arg_push_auth_expires";
public static final String WPCOM_PUSH_DEVICE_NOTIFICATION_SETTINGS = "wp_pref_notification_settings";
public static final String WPCOM_PUSH_DEVICE_UUID = "wp_pref_notifications_uuid";
public static final String WPCOM_PUSH_DEVICE_TOKEN = "wp_pref_notifications_token";
public static final String WPCOM_PUSH_DEVICE_SERVER_ID = "wp_pref_notifications_server_id";
public static final String PUSH_AUTH_ENDPOINT = "me/two-step/push-authentication";
private static final String CHECK_OP_NO_THROW = "checkOpNoThrow";
private static final String OP_POST_NOTIFICATION = "OP_POST_NOTIFICATION";
private static final String WPCOM_SETTINGS_ENDPOINT = "/me/notifications/settings/";
public interface TwoFactorAuthCallback {
void onTokenValid(String token, String title, String message);
void onTokenInvalid();
}
public static void getPushNotificationSettings(Context context, RestRequest.Listener listener,
RestRequest.ErrorListener errorListener) {
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
String deviceID = settings.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null);
String settingsEndpoint = WPCOM_SETTINGS_ENDPOINT;
if (!TextUtils.isEmpty(deviceID)) {
settingsEndpoint += "?device_id=" + deviceID;
}
WordPress.getRestClientUtilsV1_1().get(settingsEndpoint, listener, errorListener);
}
public static void registerDeviceForPushNotifications(final Context ctx, String token) {
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ctx);
String uuid = settings.getString(WPCOM_PUSH_DEVICE_UUID, null);
if (uuid == null)
return;
String deviceName = DeviceUtils.getInstance().getDeviceName(ctx);
Map<String, String> contentStruct = new HashMap<>();
contentStruct.put("device_token", token);
contentStruct.put("device_family", "android");
contentStruct.put("device_name", deviceName);
contentStruct.put("device_model", Build.MANUFACTURER + " " + Build.MODEL);
contentStruct.put("app_version", WordPress.versionName);
contentStruct.put("version_code", String.valueOf(PackageUtils.getVersionCode(ctx)));
contentStruct.put("os_version", Build.VERSION.RELEASE);
contentStruct.put("device_uuid", uuid);
RestRequest.Listener listener = new RestRequest.Listener() {
@Override
public void onResponse(JSONObject jsonObject) {
AppLog.d(T.NOTIFS, "Register token action succeeded");
try {
String deviceID = jsonObject.getString("ID");
if (deviceID==null) {
AppLog.e(T.NOTIFS, "Server response is missing of the device_id. Registration skipped!!");
return;
}
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ctx);
SharedPreferences.Editor editor = settings.edit();
editor.putString(WPCOM_PUSH_DEVICE_SERVER_ID, deviceID);
editor.apply();
AppLog.d(T.NOTIFS, "Server response OK. The device_id: " + deviceID);
} catch (JSONException e1) {
AppLog.e(T.NOTIFS, "Server response is NOT ok, registration skipped.", e1);
}
}
};
RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
@Override
public void onErrorResponse(VolleyError volleyError) {
AppLog.e(T.NOTIFS, "Register token action failed", volleyError);
}
};
WordPress.getRestClientUtils().post("/devices/new", contentStruct, null, listener, errorListener);
}
public static void unregisterDevicePushNotifications(final Context ctx) {
RestRequest.Listener listener = new RestRequest.Listener() {
@Override
public void onResponse(JSONObject jsonObject) {
AppLog.d(T.NOTIFS, "Unregister token action succeeded");
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(ctx).edit();
editor.remove(WPCOM_PUSH_DEVICE_SERVER_ID);
editor.remove(WPCOM_PUSH_DEVICE_UUID);
editor.remove(WPCOM_PUSH_DEVICE_TOKEN);
editor.apply();
}
};
RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
@Override
public void onErrorResponse(VolleyError volleyError) {
AppLog.e(T.NOTIFS, "Unregister token action failed", volleyError);
}
};
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ctx);
String deviceID = settings.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null );
if (TextUtils.isEmpty(deviceID)) {
return;
}
WordPress.getRestClientUtils().post("/devices/" + deviceID + "/delete", listener, errorListener);
}
public static Spannable getSpannableContentForRanges(JSONObject subject) {
return getSpannableContentForRanges(subject, null, null, false);
}
/**
* Returns a spannable with formatted content based on WP.com note content 'range' data
* @param blockObject the JSON data
* @param textView the TextView that will display the spannnable
* @param onNoteBlockTextClickListener - click listener for ClickableSpans in the spannable
* @param isFooter - Set if spannable should apply special formatting
* @return Spannable string with formatted content
*/
public static Spannable getSpannableContentForRanges(JSONObject blockObject, TextView textView,
final NoteBlock.OnNoteBlockTextClickListener onNoteBlockTextClickListener,
boolean isFooter) {
if (blockObject == null) {
return new SpannableStringBuilder();
}
String text = blockObject.optString("text", "");
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(text);
boolean shouldLink = onNoteBlockTextClickListener != null;
// Add ImageSpans for note media
addImageSpansForBlockMedia(textView, blockObject, spannableStringBuilder);
// Process Ranges to add links and text formatting
JSONArray rangesArray = blockObject.optJSONArray("ranges");
if (rangesArray != null) {
for (int i = 0; i < rangesArray.length(); i++) {
JSONObject rangeObject = rangesArray.optJSONObject(i);
if (rangeObject == null) {
continue;
}
NoteBlockClickableSpan clickableSpan = new NoteBlockClickableSpan(WordPress.getContext(), rangeObject,
shouldLink, isFooter) {
@Override
public void onClick(View widget) {
if (onNoteBlockTextClickListener != null) {
onNoteBlockTextClickListener.onNoteBlockTextClicked(this);
}
}
};
int[] indices = clickableSpan.getIndices();
if (indices.length == 2 && indices[0] <= spannableStringBuilder.length() &&
indices[1] <= spannableStringBuilder.length()) {
spannableStringBuilder.setSpan(clickableSpan, indices[0], indices[1], Spanned.SPAN_INCLUSIVE_INCLUSIVE);
// Add additional styling if the range wants it
if (clickableSpan.getSpanStyle() != Typeface.NORMAL) {
StyleSpan styleSpan = new StyleSpan(clickableSpan.getSpanStyle());
spannableStringBuilder.setSpan(styleSpan, indices[0], indices[1], Spanned.SPAN_INCLUSIVE_INCLUSIVE);
}
}
}
}
return spannableStringBuilder;
}
public static int[] getIndicesForRange(JSONObject rangeObject) {
int[] indices = new int[]{0,0};
if (rangeObject == null) {
return indices;
}
JSONArray indicesArray = rangeObject.optJSONArray("indices");
if (indicesArray != null && indicesArray.length() >= 2) {
indices[0] = indicesArray.optInt(0);
indices[1] = indicesArray.optInt(1);
}
return indices;
}
/**
* Adds ImageSpans to the passed SpannableStringBuilder
*/
private static void addImageSpansForBlockMedia(TextView textView, JSONObject subject, SpannableStringBuilder spannableStringBuilder) {
if (textView == null || subject == null || spannableStringBuilder == null) return;
Context context = textView.getContext();
JSONArray mediaArray = subject.optJSONArray("media");
if (context == null || mediaArray == null) {
return;
}
Drawable loading = context.getResources().getDrawable(
org.wordpress.android.editor.R.drawable.legacy_dashicon_format_image_big_grey);
Drawable failed = context.getResources().getDrawable(R.drawable.ic_notice_grey_500_48dp);
// Note: notifications_max_image_size seems to be the max size an ImageSpan can handle,
// otherwise it would load blank white
WPImageGetter imageGetter = new WPImageGetter(
textView,
context.getResources().getDimensionPixelSize(R.dimen.notifications_max_image_size),
WordPress.sImageLoader,
loading,
failed
);
int indexAdjustment = 0;
String imagePlaceholder;
for (int i = 0; i < mediaArray.length(); i++) {
JSONObject mediaObject = mediaArray.optJSONObject(i);
if (mediaObject == null) {
continue;
}
final Drawable remoteDrawable = imageGetter.getDrawable(mediaObject.optString("url", ""));
ImageSpan noteImageSpan = new ImageSpan(remoteDrawable, mediaObject.optString("url", ""));
int startIndex = JSONUtils.queryJSON(mediaObject, "indices[0]", -1);
int endIndex = JSONUtils.queryJSON(mediaObject, "indices[1]", -1);
if (startIndex >= 0) {
startIndex += indexAdjustment;
endIndex += indexAdjustment;
if (startIndex > spannableStringBuilder.length()) {
continue;
}
// If we have a range, it means there is alt text that should be removed
if (endIndex > startIndex && endIndex <= spannableStringBuilder.length()) {
spannableStringBuilder.replace(startIndex, endIndex, "");
}
// We need an empty space to insert the ImageSpan into
imagePlaceholder = " ";
// Move the image to a new line if needed
int previousCharIndex = (startIndex > 0) ? startIndex - 1 : 0;
if (!spannableHasCharacterAtIndex(spannableStringBuilder, '\n', previousCharIndex)
|| spannableStringBuilder.getSpans(startIndex, startIndex, ImageSpan.class).length > 0) {
imagePlaceholder = "\n ";
}
int spanIndex = startIndex + imagePlaceholder.length() - 1;
// Add a newline after the image if needed
if (!spannableHasCharacterAtIndex(spannableStringBuilder, '\n', startIndex)
&& !spannableHasCharacterAtIndex(spannableStringBuilder, '\r', startIndex)) {
imagePlaceholder += "\n";
}
spannableStringBuilder.insert(startIndex, imagePlaceholder);
// Add the image span
spannableStringBuilder.setSpan(
noteImageSpan,
spanIndex,
spanIndex + 1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
// Add an AlignmentSpan to center the image
spannableStringBuilder.setSpan(
new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER),
spanIndex,
spanIndex + 1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
indexAdjustment += imagePlaceholder.length();
}
}
}
public static boolean spannableHasCharacterAtIndex(Spannable spannable, char character, int index) {
return spannable != null && index < spannable.length() && spannable.charAt(index) == character;
}
public static boolean validate2FAuthorizationTokenFromIntentExtras(Intent intent, TwoFactorAuthCallback callback) {
// Check for push authorization request
if (intent != null && intent.hasExtra(NotificationsUtils.ARG_PUSH_AUTH_TOKEN)) {
Bundle extras = intent.getExtras();
String token = extras.getString(NotificationsUtils.ARG_PUSH_AUTH_TOKEN, "");
String title = extras.getString(NotificationsUtils.ARG_PUSH_AUTH_TITLE, "");
String message = extras.getString(NotificationsUtils.ARG_PUSH_AUTH_MESSAGE, "");
long expires = extras.getLong(NotificationsUtils.ARG_PUSH_AUTH_EXPIRES, 0);
long now = System.currentTimeMillis() / 1000;
if (expires > 0 && now > expires) {
callback.onTokenInvalid();
return false;
} else {
callback.onTokenValid(token, title, message);
return true;
}
}
return false;
}
public static void showPushAuthAlert(Context context, final String token, String title, String message) {
if (context == null ||
TextUtils.isEmpty(token) ||
TextUtils.isEmpty(title) ||
TextUtils.isEmpty(message)) {
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(title).setMessage(message);
builder.setPositiveButton(R.string.mnu_comment_approve, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
sendTwoFactorAuthToken(token);
}
});
builder.setNegativeButton(R.string.ignore, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
AnalyticsTracker.track(AnalyticsTracker.Stat.PUSH_AUTHENTICATION_IGNORED);
}
});
AlertDialog dialog = builder.create();
dialog.show();
}
public static void sendTwoFactorAuthToken(String token){
// ping the push auth endpoint with the token, wp.com will take care of the rest!
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("action", "authorize_login");
tokenMap.put("push_token", token);
WordPress.getRestClientUtilsV1_1().post(PUSH_AUTH_ENDPOINT, tokenMap, null, null,
new RestRequest.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
AnalyticsTracker.track(AnalyticsTracker.Stat.PUSH_AUTHENTICATION_FAILED);
}
});
AnalyticsTracker.track(AnalyticsTracker.Stat.PUSH_AUTHENTICATION_APPROVED);
}
// Checks if global notifications toggle is enabled in the Android app settings
// See: https://code.google.com/p/android/issues/detail?id=38482#c15
@SuppressWarnings("unchecked")
@TargetApi(19)
public static boolean isNotificationsEnabled(Context context) {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
AppOpsManager mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
ApplicationInfo appInfo = context.getApplicationInfo();
String pkg = context.getApplicationContext().getPackageName();
int uid = appInfo.uid;
Class appOpsClass;
try {
appOpsClass = Class.forName(AppOpsManager.class.getName());
Method checkOpNoThrowMethod = appOpsClass.getMethod(CHECK_OP_NO_THROW, Integer.TYPE, Integer.TYPE, String.class);
Field opPostNotificationValue = appOpsClass.getDeclaredField(OP_POST_NOTIFICATION);
int value = (int) opPostNotificationValue.get(Integer.class);
return ((int) checkOpNoThrowMethod.invoke(mAppOps, value, uid, pkg) == AppOpsManager.MODE_ALLOWED);
} catch (ClassNotFoundException | NoSuchFieldException | NoSuchMethodException |
IllegalAccessException | InvocationTargetException e) {
AppLog.e(T.NOTIFS, e.getMessage());
}
}
// Default to assuming notifications are enabled
return true;
}
public static boolean buildNoteObjectFromBundleAndSaveIt(Bundle data) {
Note note = buildNoteObjectFromBundle(data);
if (note != null) {
return NotificationsTable.saveNote(note);
}
return false;
}
public static Note buildNoteObjectFromBundle(Bundle data) {
if (data == null) {
AppLog.e(T.NOTIFS, "Bundle is null! Cannot read '" + GCMMessageService.PUSH_ARG_NOTE_ID + "'.");
return null;
}
Note note;
String noteId = data.getString(GCMMessageService.PUSH_ARG_NOTE_ID, "");
String base64FullData = data.getString(GCMMessageService.PUSH_ARG_NOTE_FULL_DATA);
note = Note.buildFromBase64EncodedData(noteId, base64FullData);
if (note == null) {
// At this point we don't have the note :(
AppLog.w(T.NOTIFS, "Cannot build the Note object by using info available in the PN payload. Please see " +
"previous log messages for detailed information about the error.");
}
return note;
}
public static int findNoteInNoteArray(List<Note> notes, String noteIdToSearchFor) {
if (notes == null || TextUtils.isEmpty(noteIdToSearchFor)) return -1;
for (int i = 0; i < notes.size(); i++) {
Note note = notes.get(i);
if (noteIdToSearchFor.equals(note.getId()))
return i;
}
return -1;
}
}