package co.tinode.tindroid;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.drawable.AnimationDrawable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Patterns;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.View;
import android.webkit.MimeTypeMap;
import android.widget.Button;
import android.widget.CheckedTextView;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.Toast;

import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.text.DateFormat;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.exifinterface.media.ExifInterface;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import co.tinode.tindroid.account.ContactsManager;
import co.tinode.tindroid.account.Utils;
import co.tinode.tindroid.db.BaseDb;
import co.tinode.tindroid.media.VxCard;
import co.tinode.tindroid.widgets.LetterTileDrawable;
import co.tinode.tindroid.widgets.OnlineDrawable;
import co.tinode.tindroid.widgets.RoundImageDrawable;
import co.tinode.tinodesdk.ComTopic;
import co.tinode.tinodesdk.MeTopic;
import co.tinode.tinodesdk.NotConnectedException;
import co.tinode.tinodesdk.PromisedReply;
import co.tinode.tinodesdk.ServerResponseException;
import co.tinode.tinodesdk.Tinode;
import co.tinode.tinodesdk.Topic;
import co.tinode.tinodesdk.model.Acs;
import co.tinode.tinodesdk.model.Credential;
import co.tinode.tinodesdk.model.PrivateType;
import co.tinode.tinodesdk.model.ServerMessage;

/**
 * Static utilities for UI support.
 */
public class UiUtils {
    static final int ACTION_UPDATE_SELF_SUB = 0;
    static final int ACTION_UPDATE_SUB = 1;
    static final int ACTION_UPDATE_AUTH = 2;
    static final int ACTION_UPDATE_ANON = 3;

    static final int ACTIVITY_RESULT_SELECT_PICTURE = 1;

    static final int READ_EXTERNAL_STORAGE_PERMISSION = 100;
    static final int CONTACTS_PERMISSION_ID = 101;

    static final String PREF_TYPING_NOTIF = "pref_typingNotif";
    static final String PREF_READ_RCPT = "pref_readReceipts";

    // Maximum length of user name or topic title.
    static final int MAX_TITLE_LENGTH = 60;

    private static final String TAG = "UiUtils";
    private static final int AVATAR_SIZE = 128;
    static final int MAX_BITMAP_SIZE = 1024;
    private static final int MIN_TAG_LENGTH = 4;

    private static final int COLOR_GREEN_BORDER = 0xFF4CAF50;
    private static final int COLOR_RED_BORDER = 0xFFE57373;
    private static final int COLOR_GRAY_BORDER = 0xFF9E9E9E;
    // private static final int COLOR_BLUE_BORDER = 0xFF2196F3;
    private static final int COLOR_YELLOW_BORDER = 0xFFFFCA28;
    // Logo LayerDrawable IDs
    private static final int LOGO_LAYER_AVATAR = 0;
    private static final int LOGO_LAYER_ONLINE = 1;
    private static final int LOGO_LAYER_TYPING = 2;
    // If StoredMessage activity is visible, this is the current topic in that activity.
    private static String sVisibleTopic = null;

    static void setupToolbar(final Activity activity, final VxCard pub,
                             final String topicName, final boolean online, final Date lastSeen) {
        if (activity == null || activity.isDestroyed() || activity.isFinishing()) {
            return;
        }

        final Toolbar toolbar = activity.findViewById(R.id.toolbar);
        if (toolbar == null) {
            return;
        }

        activity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                if (!TextUtils.isEmpty(topicName)) {
                    final String title = pub != null && pub.fn != null ?
                            pub.fn : activity.getString(R.string.placeholder_contact_title);
                    toolbar.setTitle(title);
                    if (lastSeen != null && !online) {
                        toolbar.setSubtitle(relativeDateFormat(activity, lastSeen));
                    } else {
                        toolbar.setSubtitle(null);
                    }
                    constructToolbarLogo(activity, pub != null ? pub.getBitmap() : null,
                            pub != null ? pub.fn  : null, topicName, online);
                } else {
                    toolbar.setTitle(R.string.app_name);
                    toolbar.setSubtitle(null);
                    toolbar.setLogo(null);
                }
            }
        });
    }

    // Date format relative to present.
    @NonNull
    private static CharSequence relativeDateFormat(Context context, Date then) {
        if (then == null) {
            return context.getString(R.string.never);
        }
        long thenMillis = then.getTime();
        if (thenMillis == 0) {
            return context.getString(R.string.never);
        }
        long nowMillis = System.currentTimeMillis();
        if (nowMillis - thenMillis < DateUtils.MINUTE_IN_MILLIS) {
            return context.getString(R.string.just_now);
        }

        return DateUtils.getRelativeTimeSpanString(thenMillis, nowMillis,
                DateUtils.MINUTE_IN_MILLIS,
                DateUtils.FORMAT_ABBREV_ALL);
    }

    // 0. [Avatar or LetterTileDrawable] 1. [Online indicator] 2. [Typing indicator]
    private static void constructToolbarLogo(final Activity activity, Bitmap avatar, String name,
                                             String uid, boolean online) {
        final Toolbar toolbar = activity.findViewById(R.id.toolbar);
        if (toolbar == null) {
            return;
        }

        Drawable avatarDrawable;
        if (avatar != null) {
            avatarDrawable = new RoundImageDrawable(activity.getResources(), avatar);
        } else {
            avatarDrawable = new LetterTileDrawable(activity)
                    .setLetterAndColor(name, uid)
                    .setContactTypeAndColor(Topic.getTopicTypeByName(uid) == Topic.TopicType.P2P ?
                            LetterTileDrawable.ContactType.PERSON : LetterTileDrawable.ContactType.GROUP);
        }
        AnimationDrawable typing = (AnimationDrawable)
                activity.getResources().getDrawable(R.drawable.typing_indicator);
        typing.setOneShot(false);
        typing.setVisible(false, true);
        typing.setAlpha(0);
        LayerDrawable layers = new LayerDrawable(
                new Drawable[]{
                        avatarDrawable,
                        new OnlineDrawable(online),
                        typing});
        layers.setId(0, LOGO_LAYER_AVATAR);
        layers.setId(1, LOGO_LAYER_ONLINE);
        layers.setId(2, LOGO_LAYER_TYPING);
        toolbar.setLogo(layers);
        Rect b = toolbar.getLogo().getBounds();
        if (!b.isEmpty()) {
            typing.setBounds(b.right - b.width() / 4, b.bottom - b.height() / 4, b.right, b.bottom);
        }
    }

    @SuppressWarnings("SameParameterValue")
    static Timer toolbarTypingIndicator(final Activity activity, Timer timer, int duration) {
        if (timer != null) {
            timer.cancel();
        }

        final Toolbar toolbar = activity.findViewById(R.id.toolbar);
        if (toolbar == null) {
            return null;
        }
        Drawable logo = toolbar.getLogo();
        if (!(logo instanceof LayerDrawable)) {
            return null;
        }

        Rect b = logo.getBounds();
        if (b.isEmpty()) {
            return null;
        }

        final AnimationDrawable typing = (AnimationDrawable) ((LayerDrawable) logo)
                .findDrawableByLayerId(LOGO_LAYER_TYPING);
        typing.setBounds(b.right - b.width() / 4, b.bottom - b.height() / 4, b.right, b.bottom);
        typing.setVisible(true, false);
        typing.setAlpha(255);
        typing.start();

        timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                if (activity.isDestroyed() || activity.isFinishing()) {
                    return;
                }

                activity.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        typing.setVisible(false, true);
                        typing.setAlpha(0);
                    }
                });
            }
        }, duration);
        return timer;
    }

    static void toolbarSetOnline(final Activity activity, boolean online, Date lastSeen) {
        final Toolbar toolbar = activity.findViewById(R.id.toolbar);
        if (toolbar == null) {
            return;
        }

        Drawable logo = toolbar.getLogo();
        if (!(logo instanceof LayerDrawable)) {
            return;
        }

        ((OnlineDrawable) ((LayerDrawable) logo).findDrawableByLayerId(LOGO_LAYER_ONLINE)).setOnline(online);
        if (online) {
            toolbar.setSubtitle(null);
        } else if (lastSeen != null) {
            toolbar.setSubtitle(relativeDateFormat(activity, lastSeen));
        }
    }

    public static String getVisibleTopic() {
        return sVisibleTopic;
    }

    static void setVisibleTopic(String topic) {
        sVisibleTopic = topic;
    }

    /**
     * Login successful. Show contacts activity
     */
    static void onLoginSuccess(Activity activity, final Button button, final String uid) {
        if (button != null) {
            activity.runOnUiThread(new Runnable() {
                public void run() {
                    button.setEnabled(true);
                }
            });
        }

        Account acc = Utils.getSavedAccount(AccountManager.get(activity), uid);
        if (acc != null) {
            requestImmediateContactsSync(acc);
            ContentResolver.setSyncAutomatically(acc, Utils.SYNC_AUTHORITY, true);
            TindroidApp.startWatchingContacts(activity, acc);
        }

        Intent intent = new Intent(activity, ChatsActivity.class);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        activity.startActivity(intent);
        activity.finish();
    }

    static void doLogout(Context context) {
        TindroidApp.stopWatchingContacts();
        Cache.invalidate();

        Intent intent = new Intent(context, LoginActivity.class);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        context.startActivity(intent);
    }

    static synchronized void requestImmediateContactsSync(Account acc) {
        Bundle bundle = new Bundle();
        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
        ContentResolver.requestSync(acc, Utils.SYNC_AUTHORITY, bundle);
        ContentResolver.setSyncAutomatically(acc, Utils.SYNC_AUTHORITY, true);
    }

    static boolean isPermissionGranted(Context context, String permission) {
        return Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
                ActivityCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
    }

    static void onContactsPermissionsGranted(Activity activity) {
        Account acc = Utils.getSavedAccount(AccountManager.get(activity), Cache.getTinode().getMyId());
        if (acc == null) {
            return;
        }

        Collection<ComTopic<VxCard>> topics = Cache.getTinode().getFilteredTopics(new Tinode.TopicFilter() {
            @Override
            public boolean isIncluded(Topic topic) {
                return topic.isP2PType();
            }
        });
        ContactsManager.updateContacts(activity, acc, topics);

        TindroidApp.startWatchingContacts(activity, acc);
    }

    // Creates or updates the Android account associated with the given UID.
    static void updateAndroidAccount(final Context context, final String uid, final String secret,
                                     final String token, final Date tokenExpires) {
        final AccountManager am = AccountManager.get(context);
        final Account acc = Utils.createAccount(uid);
        // It's OK to call even if the account already exists.
        am.addAccountExplicitly(acc, secret, null);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            am.notifyAccountAuthenticated(acc);
        }
        if (!TextUtils.isEmpty(token)) {
            am.setAuthToken(acc, Utils.TOKEN_TYPE, token);
            am.setUserData(acc, Utils.TOKEN_EXPIRATION_TIME, String.valueOf(tokenExpires.getTime()));
        }
    }

    private static void setConnectedStatus(final Activity activity, final boolean online) {
        // Connected status is disabled for production builds.
        if (!BuildConfig.DEBUG) {
            return;
        }

        if (activity == null || activity.isDestroyed() || activity.isFinishing()) {
            return;
        }

        activity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                final Toolbar toolbar = activity.findViewById(R.id.toolbar);
                if (toolbar != null) {
                    Menu menu = toolbar.getMenu();
                    if (menu != null) {
                        menu.setGroupVisible(R.id.offline, !online);
                    } else {
                        Log.i(TAG, "Toolbar menu is null");
                    }
                    View line = activity.findViewById(R.id.offline_indicator);
                    if (line != null) {
                        line.setVisibility(online ? View.INVISIBLE : View.VISIBLE);
                    }
                }
            }
        });
    }

    // Date formatter for messages
    static String shortDate(Date date) {
        if (date != null) {
            Calendar now = Calendar.getInstance();
            Calendar then = Calendar.getInstance();
            then.setTime(date);

            if (then.get(Calendar.YEAR) == now.get(Calendar.YEAR)) {
                if (then.get(Calendar.MONTH) == now.get(Calendar.MONTH) &&
                        then.get(Calendar.DATE) == now.get(Calendar.DATE)) {
                    return DateFormat.getTimeInstance(DateFormat.SHORT).format(then.getTime());
                } else {
                    return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(then.getTime());
                }
            }
            return DateFormat.getInstance().format(then.getTime());
        }
        return "null date";
    }

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
    static void requestAvatar(@Nullable Fragment fragment) {
        if (fragment == null) {
            return;
        }

        final Activity activity = fragment.getActivity();
        if (activity == null || activity.isFinishing() || activity.isDestroyed()) {
            return;
        }

        if (!isPermissionGranted(activity, android.Manifest.permission.READ_EXTERNAL_STORAGE)) {
            fragment.requestPermissions(new String[]{android.Manifest.permission.READ_EXTERNAL_STORAGE},
                    READ_EXTERNAL_STORAGE_PERMISSION);
        } else {
            Intent intent = new Intent();
            intent.setType("image/*");
            intent.setAction(Intent.ACTION_GET_CONTENT);

            fragment.startActivityForResult(Intent.createChooser(intent, fragment.getString(R.string.select_image)),
                    ACTIVITY_RESULT_SELECT_PICTURE);
        }
    }

    @NonNull
    private static Bitmap scaleSquareBitmap(@NonNull Bitmap bmp) {
        int width = bmp.getWidth();
        int height = bmp.getHeight();
        if (width > height) {
            width = width * AVATAR_SIZE / height;
            height = AVATAR_SIZE;
            // Sanity check
            width = Math.min(width, MAX_BITMAP_SIZE);
        } else {
            height = height * AVATAR_SIZE / width;
            width = AVATAR_SIZE;
            height = Math.min(height, MAX_BITMAP_SIZE);
        }
        // Scale up or down.
        bmp = Bitmap.createScaledBitmap(bmp, width, height, true);
        // Chop the square from the middle.
        return Bitmap.createBitmap(bmp, (width - AVATAR_SIZE) / 2, (height - AVATAR_SIZE) / 2,
                AVATAR_SIZE, AVATAR_SIZE);
    }

    /**
     * Scale bitmap down to be under certain liner dimensions but no less than by the given amount.
     *
     * @param bmp bitmap to scale.
     * @param atLeast shrink bitmap by at least this amount (>1). Values <=1 are ignored.
     * @return scaled bitmap or original, it it does not need ot be scaled.
     */
    @NonNull
    static Bitmap scaleBitmap(@NonNull Bitmap bmp, float atLeast) {
        int width = bmp.getWidth();
        int height = bmp.getHeight();
        float factor = 1.0f;
        // Calculate scaling factor due to large linear dimensions.
        if (width >= height) {
            if (width > MAX_BITMAP_SIZE) {
                factor = (float) width / MAX_BITMAP_SIZE;
            }
        } else {
            if (height > MAX_BITMAP_SIZE) {
                factor = (float) height / MAX_BITMAP_SIZE;
            }
        }
        // Additional scaling.
        factor = Math.max(atLeast, factor);
        if (factor > 1.0) {
            height /= factor;
            width /= factor;
            return Bitmap.createScaledBitmap(bmp, width, height, true);
        }
        return bmp;
    }

    @NonNull
    static Bitmap rotateBitmap(@NonNull Bitmap bmp, int orientation) {
        Matrix matrix = new Matrix();
        switch (orientation) {
            case ExifInterface.ORIENTATION_NORMAL:
                return bmp;
            case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
                matrix.setScale(-1, 1);
                break;
            case ExifInterface.ORIENTATION_ROTATE_180:
                matrix.setRotate(180);
                break;
            case ExifInterface.ORIENTATION_FLIP_VERTICAL:
                matrix.setRotate(180);
                matrix.postScale(-1, 1);
                break;
            case ExifInterface.ORIENTATION_TRANSPOSE:
                matrix.setRotate(90);
                matrix.postScale(-1, 1);
                break;
            case ExifInterface.ORIENTATION_ROTATE_90:
                matrix.setRotate(90);
                break;
            case ExifInterface.ORIENTATION_TRANSVERSE:
                matrix.setRotate(-90);
                matrix.postScale(-1, 1);
                break;
            case ExifInterface.ORIENTATION_ROTATE_270:
                matrix.setRotate(-90);
                break;
            default:
                return bmp;
        }

        try {
            Bitmap rotated = Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), matrix, true);
            bmp.recycle();
            return rotated;
        } catch (OutOfMemoryError ex) {
            Log.e(TAG, "Out of memory while rotating bitmap");
            return bmp;
        }
    }

    private static Bitmap extractBitmap(final Activity activity, final Intent data) {
        if (data == null) {
            return null;
        }

        try {
            return MediaStore.Images.Media.getBitmap(activity.getContentResolver(),
                    data.getData());
        } catch (IOException | SecurityException ex) {
            Log.w(TAG, "Failed to get bitmap", ex);
            return null;
        }
    }

    static void acceptAvatar(final Activity activity, final ImageView avatar, final Intent data) {
        if (activity == null || avatar == null) {
            return;
        }

        final Bitmap bmp = extractBitmap(activity, data);
        if (bmp == null) {
            Toast.makeText(activity, activity.getString(R.string.image_is_unavailable), Toast.LENGTH_SHORT).show();
            return;
        }

        avatar.setImageDrawable(new RoundImageDrawable(avatar.getResources(), scaleSquareBitmap(bmp)));
    }

    // Construct avatar drawable: use bitmap if it is not null,
    // otherwise use name & address to create a LetterTileDrawable.
    static Drawable avatarDrawable(Context context, Bitmap bmp, String name, String address) {
        if (bmp != null) {
            return new RoundImageDrawable(context.getResources(), bmp);
        } else {
            LetterTileDrawable drawable = new LetterTileDrawable(context);
            drawable.setContactTypeAndColor(
                    Topic.getTopicTypeByName(address) == Topic.TopicType.P2P ?
                            LetterTileDrawable.ContactType.PERSON : LetterTileDrawable.ContactType.GROUP)
                    .setLetterAndColor(name, address)
                    .setIsCircular(true);
            return drawable;
        }
    }

    /*
     * An ImageLoader object loads and resizes an image in the background and binds it to the
     * each item layout of the ListView. ImageLoader implements memory caching for each image,
     * which substantially improves refreshes of the ListView as the user scrolls through it.
     *
     * http://developer.android.com/training/displaying-bitmaps/
     */
    static ImageLoader getImageLoaderInstance(final Fragment parent) {
        FragmentActivity activity = parent.getActivity();
        if (activity == null) {
            return null;
        }

        ImageLoader il = new ImageLoader(getListPreferredItemHeight(parent),
                activity.getSupportFragmentManager()) {
            @Override
            protected Bitmap processBitmap(Object data) {
                // This gets called in a background thread and passed the data from
                // ImageLoader.loadImage().
                return UiUtils.loadContactPhotoThumbnail(parent,
                        (String) data, getImageSize());
            }
        };
        // Set a placeholder loading image for the image loader
        il.setLoadingImage(activity, R.drawable.ic_person_circle);

        return il;
    }

    /**
     * Decodes and scales a contact's image from a file pointed to by a Uri in the contact's data,
     * and returns the result as a Bitmap. The column that contains the Uri varies according to the
     * platform version.
     *
     * @param photoData For platforms prior to Android 3.0, provide the Contact._ID column value.
     *                  For Android 3.0 and later, provide the Contact.PHOTO_THUMBNAIL_URI value.
     * @param imageSize The desired target width and height of the output image in pixels.
     * @return A Bitmap containing the contact's image, resized to fit the provided image size. If
     * no thumbnail exists, returns null.
     */
    private static Bitmap loadContactPhotoThumbnail(Fragment fragment, String photoData, int imageSize) {

        // Ensures the Fragment is still added to an activity. As this method is called in a
        // background thread, there's the possibility the Fragment is no longer attached and
        // added to an activity. If so, no need to spend resources loading the contact photo.
        if (!fragment.isAdded()) {
            return null;
        }

        Activity activity = fragment.getActivity();
        if (activity == null || activity.isFinishing() || activity.isDestroyed()) {
            return null;
        }

        // Instantiates an AssetFileDescriptor. Given a content Uri pointing to an image file, the
        // ContentResolver can return an AssetFileDescriptor for the file.

        // This "try" block catches an Exception if the file descriptor returned from the Contacts
        // Provider doesn't point to an existing file.
        Uri thumbUri = Uri.parse(photoData);
        try (AssetFileDescriptor afd = activity.getContentResolver().openAssetFileDescriptor(thumbUri, "r")) {

            // Retrieves a file descriptor from the Contacts Provider. To learn more about this
            // feature, read the reference documentation for
            // ContentResolver#openAssetFileDescriptor.

            // Gets a FileDescriptor from the AssetFileDescriptor. A BitmapFactory object can
            // decode the contents of a file pointed to by a FileDescriptor into a Bitmap.
            if (afd != null) {
                // Decodes a Bitmap from the image pointed to by the FileDescriptor, and scales it
                // to the specified width and height
                return ImageLoader.decodeSampledBitmapFromStream(
                        new BufferedInputStream(new FileInputStream(afd.getFileDescriptor())), imageSize, imageSize);
            }
        } catch (IOException e) {
            // If the file pointed to by the thumbnail URI doesn't exist, or the file can't be
            // opened in "read" mode, ContentResolver.openAssetFileDescriptor throws a
            // FileNotFoundException.
            if (BuildConfig.DEBUG) {
                Log.d(TAG, "Contact photo thumbnail not found for contact " + photoData
                        + ": " + e.toString());
            }
        }
        // If an AssetFileDescriptor was returned, try to close it
        // Closing a file descriptor might cause an IOException if the file is
        // already closed. Nothing extra is needed to handle this.

        // If the decoding failed, returns null
        return null;
    }

    @NonNull
    static ByteArrayInputStream bitmapToStream(@NonNull Bitmap bmp, String mimeType) {
        Bitmap.CompressFormat fmt;
        if ("image/jpeg".equals(mimeType)) {
            fmt = Bitmap.CompressFormat.JPEG;
        } else {
            fmt = Bitmap.CompressFormat.PNG;
        }
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        bmp.compress(fmt, 70, bos);
        return new ByteArrayInputStream(bos.toByteArray());
    }

    /**
     * Convert drawable to bitmap.
     *
     * @param drawable vector drawable to convert to bitmap
     * @return bitmap extracted from the drawable.
     */
    static Bitmap bitmapFromDrawable(Drawable drawable) {
        if (drawable instanceof BitmapDrawable) {
            return ((BitmapDrawable) drawable).getBitmap();
        }

        Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
                drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        drawable.draw(canvas);

        return bitmap;
    }

    /**
     * Identifies the start of the search string (needle) in the display name (haystack).
     * E.g. If display name was "Adam" and search query was "da" this would
     * return 1.
     *
     * @param haystack The contact display name.
     * @return The starting position of the search string in the display name, 0-based. The
     * method returns -1 if the string is not found in the display name, or if the search
     * string is empty or null.
     */
    static int indexOfSearchQuery(String haystack, String needle) {
        if (!TextUtils.isEmpty(needle)) {
            return haystack.toLowerCase(Locale.getDefault()).indexOf(
                    needle.toLowerCase(Locale.getDefault()));
        }

        return -1;
    }

    /**
     * Gets the preferred height for each item in the ListView, in pixels, after accounting for
     * screen density. ImageLoader uses this value to resize thumbnail images to match the ListView
     * item height.
     *
     * @return The preferred height in pixels, based on the current theme.
     */
    private static int getListPreferredItemHeight(Fragment fragment) {
        final TypedValue typedValue = new TypedValue();

        final Activity activity = fragment.getActivity();
        if (activity == null) {
            return -1;
        }

        // Resolve list item preferred height theme attribute into typedValue
        activity.getTheme().resolveAttribute(
                android.R.attr.listPreferredItemHeight, typedValue, true);

        // Create a new DisplayMetrics object
        final DisplayMetrics metrics = new android.util.DisplayMetrics();

        // Populate the DisplayMetrics
        activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);

        // Return theme value based on DisplayMetrics
        return (int) typedValue.getDimension(metrics);
    }

    static AccessModeLabel[] accessModeLabels(final Acs acs, final BaseDb.Status status) {
        ArrayList<AccessModeLabel> result = new ArrayList<>(2);
        if (acs != null) {
            if (acs.isModeDefined()) {
                if (!acs.isJoiner() || (!acs.isWriter() && !acs.isReader())) {
                    result.add(new AccessModeLabel(R.string.modeBlocked, COLOR_RED_BORDER));
                } else if (acs.isOwner()) {
                    result.add(new AccessModeLabel(R.string.modeOwner, COLOR_GREEN_BORDER));
                } else if (acs.isAdmin()) {
                    result.add(new AccessModeLabel(R.string.modeAdmin, COLOR_GREEN_BORDER));
                } else if (!acs.isWriter()) {
                    result.add(new AccessModeLabel(R.string.modeReadOnly, COLOR_YELLOW_BORDER));
                } else if (!acs.isReader()) {
                    result.add(new AccessModeLabel(R.string.modeWriteOnly, COLOR_YELLOW_BORDER));
                }
            } else if (!acs.isInvalid()) {
                // The mode is undefined (NONE)
                if (acs.isGivenDefined() && !acs.isWantDefined()) {
                    result.add(new AccessModeLabel(R.string.modeInvited, COLOR_GRAY_BORDER));
                } else if (!acs.isGivenDefined() && acs.isWantDefined()) {
                    result.add(new AccessModeLabel(R.string.modeRequested, COLOR_GRAY_BORDER));
                } else {
                    // Undefined state
                    result.add(new AccessModeLabel(R.string.modeUndefined, COLOR_GRAY_BORDER));
                }
            }
        }
        if (status == BaseDb.Status.QUEUED) {
            result.add(new AccessModeLabel(R.string.modePending, COLOR_GRAY_BORDER));
        }

        return !result.isEmpty() ?
                result.toArray(new AccessModeLabel[0]) : null;
    }

    static void showEditPermissions(final Activity activity, final Topic topic,
                                    @NonNull final String mode,
                                    final String uid, final int what,
                                    String skip) {
        final int[] permissionsMap = new int[]{
                R.string.permission_join,
                R.string.permission_read,
                R.string.permission_write,
                R.string.permission_notifications,
                R.string.permission_approve,
                R.string.permission_share,
                R.string.permission_delete,
                R.string.permission_owner
        };
        final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
        final LayoutInflater inflater = LayoutInflater.from(builder.getContext());
        @SuppressLint("InflateParams") final LinearLayout editor = (LinearLayout) inflater.inflate(R.layout.dialog_edit_permissions, null);
        builder
                .setView(editor)
                .setTitle(R.string.edit_permissions);

        View.OnClickListener checkListener = new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                boolean checked = !((CheckedTextView) view).isChecked();
                ((CheckedTextView) view).setChecked(checked);
            }
        };

        for (int i = 0; i < "JRWPASDO".length(); i++) {
            char c = "JRWPASDO".charAt(i);
            if (skip.indexOf(c) >= 0) {
                continue;
            }

            CheckedTextView check = (CheckedTextView) inflater.inflate(R.layout.edit_one_permission,
                    editor, false);
            check.setChecked(mode.indexOf(c) >= 0);
            check.setText(permissionsMap[i]);
            check.setTag(c);
            check.setOnClickListener(checkListener);
            editor.addView(check, editor.getChildCount());
        }

        builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                StringBuilder newAcsStr = new StringBuilder();
                for (int i = 0; i < editor.getChildCount(); i++) {
                    CheckedTextView check = (CheckedTextView) editor.getChildAt(i);
                    if (check.isChecked()) {
                        newAcsStr.append(check.getTag());
                    }
                }
                if (newAcsStr.length() == 0) {
                    newAcsStr.append('N');
                }

                PromisedReply<ServerMessage> reply = null;
                switch (what) {
                    case ACTION_UPDATE_SELF_SUB:
                        //noinspection unchecked
                        reply = topic.updateMode(null, newAcsStr.toString());
                        break;
                    case ACTION_UPDATE_SUB:
                        //noinspection unchecked
                        reply = topic.updateMode(uid, newAcsStr.toString());
                        break;
                    case ACTION_UPDATE_AUTH:
                        //noinspection unchecked
                        reply = topic.updateDefAcs(newAcsStr.toString(), null);
                        break;
                    case ACTION_UPDATE_ANON:
                        //noinspection unchecked
                        reply = topic.updateDefAcs(null, newAcsStr.toString());
                        break;
                    default:
                        Log.w(TAG, "Unknown action " + what);
                }

                if (reply != null) {
                    reply.thenApply(
                            new PromisedReply.SuccessListener<ServerMessage>() {
                                @Override
                                public PromisedReply<ServerMessage> onSuccess(ServerMessage result) {
                                    return null;
                                }
                            },
                            new PromisedReply.FailureListener<ServerMessage>() {
                                @Override
                                public PromisedReply<ServerMessage> onFailure(final Exception err) {
                                    if (activity.isFinishing() || activity.isDestroyed()) {
                                        return null;
                                    }

                                    activity.runOnUiThread(new Runnable() {
                                        @Override
                                        public void run() {
                                            Log.w(TAG, "Failed", err);
                                            Toast.makeText(activity, R.string.action_failed, Toast.LENGTH_SHORT).show();
                                        }
                                    });
                                    return null;
                                }
                            });
                }
            }
        });
        builder.setNegativeButton(android.R.string.cancel, null);
        builder.show();
    }

    static <T extends Topic<VxCard, PrivateType, ?, ?>> void updateAvatar(final Activity activity,
                                                                          final T topic, final Intent data) {
        Bitmap bmp = UiUtils.extractBitmap(activity, data);
        if (bmp == null) {
            Toast.makeText(activity, activity.getString(R.string.image_is_unavailable), Toast.LENGTH_SHORT).show();
            Log.i(TAG, "Failed to extract bitmap from intent");
            return;
        }
        VxCard pub = topic.getPub();
        if (pub != null) {
            pub = pub.copy();
        } else {
            pub = new VxCard();
        }

        pub.setBitmap(scaleSquareBitmap(bmp));
        topic.setDescription(pub, null).thenCatch(new ToastFailureListener(activity));
    }

    // Provides callback for when title/subtitle get successfully updated.
    interface TitleUpdateCallbackInterface {
        void onTitleUpdated();
    }

    static <T extends Topic<VxCard, PrivateType, ?, ?>> void updateTitle(final Activity activity,
                                                                         T topic, String title, String comment,
                                                                         final TitleUpdateCallbackInterface done) {
        VxCard pub = null;
        if (!TextUtils.isEmpty(title)) {
            VxCard oldPub = topic.getPub();
            if (title.length() > MAX_TITLE_LENGTH) {
                title = title.substring(0, MAX_TITLE_LENGTH);
            }
            if (oldPub != null && !title.equals(oldPub.fn)) {
                pub = new VxCard();
                pub.fn = title;
            }
        }

        if (!TextUtils.isEmpty(comment)) {
            if (comment.length() > MAX_TITLE_LENGTH) {
                comment = comment.substring(0, MAX_TITLE_LENGTH);
            }
            PrivateType priv = topic.getPriv();
            String oldComment = priv != null ? priv.getComment() : null;
            if (comment.equals(oldComment)) {
                comment = null;
            }
        }

        if (pub != null || comment != null) {
            PrivateType priv = null;
            if (comment != null) {
                priv = new PrivateType();
                priv.setComment(comment);
            }
            topic.setDescription(pub, priv).thenApply(
                    new PromisedReply.SuccessListener<ServerMessage>() {
                        @Override
                        public PromisedReply<ServerMessage> onSuccess(ServerMessage result) {
                            done.onTitleUpdated();
                            return null;
                        }
                    },
                    new ToastFailureListener(activity));
        }
    }

    static boolean attachMeTopic(final Activity activity, final MeEventListener l) {
        Tinode tinode = Cache.getTinode();
        if (!tinode.isAuthenticated()) {
            // If connection is not ready, wait for completion. This method will be called again
            // from the onLogin callback;
            Cache.getTinode().reconnectNow(true, false, false);
            return false;
        }

        // If connection exists attachMeTopic returns resolved promise.
        Cache.attachMeTopic(l).thenCatch(new PromisedReply.FailureListener<ServerMessage>() {
                    @Override
                    public PromisedReply<ServerMessage> onFailure(Exception err) {
                        Log.w(TAG, "Error subscribing to 'me' topic", err);
                        l.onSubscriptionError(err);
                        if (err instanceof ServerResponseException) {
                            ServerResponseException sre = (ServerResponseException) err;
                            int errCode = sre.getCode();
                            if (errCode == 401 || errCode == 403 || errCode == 404) {
                                doLogout(activity);
                                activity.finish();
                            } else if (errCode == 502 && "cluster unreachable".equals(sre.getMessage())) {
                                // Must reset connection.
                                Cache.getTinode().reconnectNow(false,true, false);
                            }
                        }
                        return null;
                    }
                });

        return true;
    }

    // Parse comma separated list of possible quoted string into an array.
    static String[] parseTags(final String tagList) {
        if (TextUtils.isEmpty(tagList)) {
            return null;
        }

        ArrayList<String> tags = new ArrayList<>();
        int start = 0;
        final long maxTagCount = Cache.getTinode().getServerLimit(Tinode.MAX_TAG_COUNT, 16);
        final int length = tagList.length();
        boolean quoted = false;
        for (int idx = 0; idx < length && tags.size() < maxTagCount; idx++) {
            if (tagList.charAt(idx) == '\"') {
                // Toggle 'inside of quotes' state.
                quoted = !quoted;
            }

            String tag;
            if (tagList.charAt(idx) == ',' && !quoted) {
                tag = tagList.substring(start, idx);
                start = idx + 1;
            } else if (idx == length - 1) {
                // Last char
                tag = tagList.substring(start);
            } else {
                continue;
            }

            tag = tag.trim();
            // Remove possible quotes.
            if (tag.length() > 1 && tag.charAt(0) == '\"' && tag.charAt(tag.length()-1) == '\"') {
                tag = tag.substring(1, tag.length() - 1).trim();
            }
            if (tag.length() >= MIN_TAG_LENGTH) {
                tags.add(tag);
            }
        }

        if (tags.size() == 0) {
            return null;
        }

        return tags.toArray(new String[]{});
    }

    // Find path to content: DocumentProvider, DownloadsProvider, MediaProvider, MediaStore, File.
    static String getContentPath(Context context, Uri uri) {
        // DocumentProvider
        if (DocumentsContract.isDocumentUri(context, uri)) {
            final String docId = DocumentsContract.getDocumentId(uri);
            String authority = uri.getAuthority();
            if (authority != null) {
                switch (authority) {
                    case "com.android.externalstorage.documents": {
                        // ExternalStorageProvider
                        final String[] split = docId.split(":");
                        final String type = split[0];

                        if ("primary".equalsIgnoreCase(type)) {
                            return Environment.getExternalStorageDirectory() + "/" + split[1];
                        }
                        // TODO: handle non-primary volumes
                        break;
                    }
                    case "com.android.providers.downloads.documents": {
                        // DownloadsProvider
                        if (docId.startsWith("raw:")) {
                            // "raw:/storage/emulated/0/Download/app-debug.apk". Just return the path without 'raw:'.
                            return docId.substring(4);
                        }

                        long id;
                        try {
                            id = Long.parseLong(docId);
                        } catch (NumberFormatException e) {
                            Log.w(TAG, "Failed to parse document ID: " + docId);
                            return null;
                        }

                        // Possible locations of downloads directory.
                        String[] contentUriPrefixes = new String[]{
                            "content://downloads/public_downloads",
                            "content://downloads/my_downloads",
                            "content://downloads/all_downloads"
                        };

                        for (String uriPrefix: contentUriPrefixes) {
                            Uri contentUri = ContentUris.withAppendedId(Uri.parse(uriPrefix), id);
                            String path = getResolverData(context, contentUri, null, null);
                            if (path != null) {
                                return path;
                            }
                        }
                        return null;

                    }
                    case "com.android.providers.media.documents": {
                        // MediaProvider
                        final String[] split = docId.split(":");
                        final String type = split[0];
                        Uri contentUri = null;
                        if ("image".equals(type)) {
                            contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
                        } else if ("video".equals(type)) {
                            contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
                        } else if ("audio".equals(type)) {
                            contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
                        }
                        final String selection = "_id=?";
                        final String[] selectionArgs = new String[]{split[1]};
                        return getResolverData(context, contentUri, selection, selectionArgs);
                    }
                    default:
                        Log.d(TAG, "Unknown content authority " + uri.getAuthority());
                }
            } else {
                Log.d(TAG, "URI has no content authority " + uri);
            }
        } else if ("content".equalsIgnoreCase(uri.getScheme())) {
            // MediaStore (and general)
            // Return the remote address
            if ("com.google.android.apps.photos.content".equals(uri.getAuthority())) {
                return uri.getLastPathSegment();
            }
            return getResolverData(context, uri, null, null);
        } else if ("file".equalsIgnoreCase(uri.getScheme())) {
            // File
            return uri.getPath();
        }
        return null;
    }

    private static String getResolverData(Context context, Uri uri, String selection, String[] selectionArgs) {
        final String column = MediaStore.Files.FileColumns.DATA;
        final String[] projection = {column};
        try {
            try (Cursor cursor = context.getContentResolver().query(uri, projection,
                    selection, selectionArgs, null)) {
                if (cursor != null && cursor.moveToFirst()) {
                    final int index = cursor.getColumnIndex(column);
                    return index >= 0 ? cursor.getString(index) : null;
                }
            }
        } catch (SecurityException | IllegalArgumentException ex) {
            Log.w(TAG, "Failed to read resolver data", ex);
        }
        return null;
    }

    static String bytesToHumanSize(long bytes) {
        if (bytes <= 0) {
            return "0 Bytes";
        }

        String[] sizes = new String[]{"Bytes", "KB", "MB", "GB", "TB"};
        int bucket = (63 - Long.numberOfLeadingZeros(bytes)) / 10;
        double count = bytes / Math.pow(1024, bucket);
        int roundTo = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
        NumberFormat fmt = DecimalFormat.getInstance();
        fmt.setMaximumFractionDigits(roundTo);
        return fmt.format(count) + " " + sizes[bucket];
    }

    @Nullable
    static Fragment getVisibleFragment(@NonNull FragmentManager fm) {
        List<Fragment> fragments = fm.getFragments();
        for (Fragment f : fragments) {
            if (f.isVisible()) {
                return f;
            }
        }
        return null;
    }

    static String getMimeType(Uri uri) {
        if (uri == null) {
            return null;
        }

        MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
        String ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
        if (mimeTypeMap.hasExtension(ext)) {
            return mimeTypeMap.getMimeTypeFromExtension(ext);
        }
        return null;
    }

    static Credential parseCredential(String cred) {
        final PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
        final String country = Locale.getDefault().getCountry();
        if (Patterns.PHONE.matcher(cred).matches()) {
            // Looks like a phone number.
            try {
                // Normalize phone number format
                cred = phoneUtil.format(phoneUtil.parse(cred, country), PhoneNumberUtil.PhoneNumberFormat.E164);
                // Exception not thrown, we have a phone number.
                return new Credential(Credential.METH_PHONE, cred);
            } catch (NumberParseException ignored) {
                return null;
            }
        }

        // Not a phone number. Try parsing as email.
        if (Patterns.EMAIL_ADDRESS.matcher(cred).matches()) {
            return new Credential(Credential.METH_EMAIL, cred);
        }

        return null;
    }

    static class MeEventListener extends MeTopic.MeListener<VxCard> {
        // Called on failed subscription request.
        public void onSubscriptionError(Exception ex) {
        }
    }

    static class EventListener extends Tinode.EventListener {
        private Activity mActivity;
        private Boolean mConnected;

        EventListener(Activity owner, Boolean connected) {
            super();
            mActivity = owner;
            mConnected = connected;
            setConnectedStatus(mActivity, connected);
        }

        @Override
        public void onConnect(int code, String reason, Map<String, Object> params) {
            // Show that we are connected
            setConnected(true);
        }

        @Override
        public void onDisconnect(boolean byServer, int code, String reason) {
            // Show that we are disconnected
            if (code <= 0) {
                Log.d(TAG, "Network error");
            } else {
                Log.d(TAG, "onDisconnect error: " + code);
            }
            setConnected(false);
        }

        private void setConnected(final boolean connected) {
            if (mActivity != null &&
                    (mConnected == null || connected != mConnected)) {
                mConnected = connected;
                setConnectedStatus(mActivity, connected);
            } else {
                mConnected = null;
            }
        }
    }

    static class AccessModeLabel {
        public int color;
        int nameId;

        AccessModeLabel(int nameId, int color) {
            this.nameId = nameId;
            this.color = color;
        }
    }

    static class ToastFailureListener extends PromisedReply.FailureListener<ServerMessage> {
        private Activity mActivity;

        ToastFailureListener(Activity activity) {
            mActivity = activity;
        }

        @Override
        public PromisedReply<ServerMessage> onFailure(final Exception err) {
            if (mActivity == null || mActivity.isFinishing() || mActivity.isDestroyed()) {
                return null;
            }
            Log.d(TAG, mActivity.getLocalClassName() + ": promise rejected", err);
            mActivity.runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    if (err instanceof NotConnectedException) {
                        Toast.makeText(mActivity, R.string.no_connection, Toast.LENGTH_SHORT).show();
                    } else {
                        Toast.makeText(mActivity, R.string.action_failed, Toast.LENGTH_SHORT).show();
                    }
                }
            });
            return null;
        }
    }

    interface ProgressIndicator {
        void toggleProgressIndicator(boolean on);
    }
}