diff --git a/app/src/main/java/awais/instagrabber/activities/MainActivity.java b/app/src/main/java/awais/instagrabber/activities/MainActivity.java index 08a0322a..705d415b 100644 --- a/app/src/main/java/awais/instagrabber/activities/MainActivity.java +++ b/app/src/main/java/awais/instagrabber/activities/MainActivity.java @@ -73,6 +73,7 @@ import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.FlavorTown; import awais.instagrabber.utils.IntentUtils; import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.emoji.EmojiParser; import static awais.instagrabber.utils.NavigationExtensions.setupWithNavController; @@ -477,6 +478,10 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage @SuppressLint("RestrictedApi") final Deque backStack = navController.getBackStack(); setupMenu(backStack.size(), destinationId); binding.bottomNavView.setVisibility(SHOW_BOTTOM_VIEW_DESTINATIONS.contains(destinationId) ? View.VISIBLE : View.GONE); + + // explicitly hide keyboard when we navigate + final View view = getCurrentFocus(); + Utils.hideKeyboard(view); }); } diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectMessageMembersAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectMessageMembersAdapter.java deleted file mode 100755 index 60e22a15..00000000 --- a/app/src/main/java/awais/instagrabber/adapters/DirectMessageMembersAdapter.java +++ /dev/null @@ -1,47 +0,0 @@ -package awais.instagrabber.adapters; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.List; - -import awais.instagrabber.adapters.viewholder.FollowsViewHolder; -import awais.instagrabber.databinding.ItemFollowBinding; -import awais.instagrabber.models.ProfileModel; - -public final class DirectMessageMembersAdapter extends RecyclerView.Adapter { - private final List profileModels; - private final List admins; - private final View.OnClickListener onClickListener; - - public DirectMessageMembersAdapter(final List profileModels, - final List admins, - final View.OnClickListener onClickListener) { - this.profileModels = profileModels; - this.admins = admins; - this.onClickListener = onClickListener; - } - - @NonNull - @Override - public FollowsViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { - final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); - final ItemFollowBinding binding = ItemFollowBinding.inflate(layoutInflater, parent, false); - return new FollowsViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull final FollowsViewHolder holder, final int position) { - final ProfileModel model = profileModels.get(position); - holder.bind(model, admins, onClickListener); - } - - @Override - public int getItemCount() { - return profileModels.size(); - } -} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java b/app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java new file mode 100644 index 00000000..c59b35a6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java @@ -0,0 +1,183 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.DirectUserViewHolder; +import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; + +public final class DirectUsersAdapter extends ListAdapter { + + private static final int VIEW_TYPE_HEADER = 0; + private static final int VIEW_TYPE_USER = 1; + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final DirectUserOrHeader oldItem, @NonNull final DirectUserOrHeader newItem) { + final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); + final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); + boolean areSameType = bothHeaders || bothItems; + if (!areSameType) return false; + if (bothHeaders) { + return oldItem.headerTitle == newItem.headerTitle; + } + if (oldItem.user != null && newItem.user != null) { + return oldItem.user.getPk() == newItem.user.getPk(); + } + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull final DirectUserOrHeader oldItem, @NonNull final DirectUserOrHeader newItem) { + final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); + final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); + boolean areSameType = bothHeaders || bothItems; + if (!areSameType) return false; + if (bothHeaders) { + return oldItem.headerTitle == newItem.headerTitle; + } + if (oldItem.user != null && newItem.user != null) { + return oldItem.user.getUsername().equals(newItem.user.getUsername()) && + oldItem.user.getFullName().equals(newItem.user.getFullName()); + } + return false; + } + }; + + private final long inviterId; + private final OnDirectUserClickListener onClickListener; + private final OnDirectUserLongClickListener onLongClickListener; + private List adminUserIds; + + public DirectUsersAdapter(final long inviterId, + final OnDirectUserClickListener onClickListener, + final OnDirectUserLongClickListener onLongClickListener) { + super(DIFF_CALLBACK); + this.inviterId = inviterId; + this.onClickListener = onClickListener; + this.onLongClickListener = onLongClickListener; + setHasStableIds(true); + } + + public void submitUsers(final List users, final List leftUsers) { + if (users == null && leftUsers == null) return; + final List userOrHeaders = combineLists(users, leftUsers); + submitList(userOrHeaders); + } + + private List combineLists(final List users, final List leftUsers) { + final ImmutableList.Builder listBuilder = ImmutableList.builder(); + if (users != null && !users.isEmpty()) { + listBuilder.add(new DirectUserOrHeader(R.string.members)); + users.stream() + .map(DirectUserOrHeader::new) + .forEach(listBuilder::add); + } + if (leftUsers != null && !leftUsers.isEmpty()) { + listBuilder.add(new DirectUserOrHeader(R.string.dms_left_users)); + leftUsers.stream() + .map(DirectUserOrHeader::new) + .forEach(listBuilder::add); + } + return listBuilder.build(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case VIEW_TYPE_USER: + final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); + return new DirectUserViewHolder(binding, onClickListener, onLongClickListener); + case VIEW_TYPE_HEADER: + default: + final ItemFavSectionHeaderBinding headerBinding = ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false); + return new HeaderViewHolder(headerBinding); + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + if (holder instanceof HeaderViewHolder) { + ((HeaderViewHolder) holder).bind(getItem(position).headerTitle); + return; + } + if (holder instanceof DirectUserViewHolder) { + final DirectUser user = getItem(position).user; + ((DirectUserViewHolder) holder).bind(position, + user, + user != null && adminUserIds != null && adminUserIds.contains(user.getPk()), + user != null && user.getPk() == inviterId, + false, + false); + } + } + + @Override + public int getItemViewType(final int position) { + final DirectUserOrHeader item = getItem(position); + return item.isHeader() ? VIEW_TYPE_HEADER : VIEW_TYPE_USER; + } + + @Override + public long getItemId(final int position) { + final DirectUserOrHeader item = getItem(position); + return item.isHeader() ? item.headerTitle : item.user.getPk(); + } + + public void setAdminUserIds(final List adminUserIds) { + this.adminUserIds = adminUserIds; + notifyDataSetChanged(); + } + + public static class DirectUserOrHeader { + int headerTitle; + DirectUser user; + + public DirectUserOrHeader(final int headerTitle) { + this.headerTitle = headerTitle; + } + + public DirectUserOrHeader(final DirectUser user) { + this.user = user; + } + + boolean isHeader() { + return headerTitle > 0; + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + private final ItemFavSectionHeaderBinding binding; + + public HeaderViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(@StringRes final int headerTitle) { + binding.getRoot().setText(headerTitle); + } + } + + public interface OnDirectUserClickListener { + void onClick(int position, DirectUser user, boolean selected); + } + + public interface OnDirectUserLongClickListener { + boolean onLongClick(int position, DirectUser user); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java new file mode 100644 index 00000000..d5480083 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java @@ -0,0 +1,85 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; +import awais.instagrabber.adapters.viewholder.DirectUserViewHolder; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; + +public final class UserSearchResultsAdapter extends ListAdapter { + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final DirectUser oldItem, @NonNull final DirectUser newItem) { + return oldItem.getPk() == newItem.getPk(); + } + + @Override + public boolean areContentsTheSame(@NonNull final DirectUser oldItem, @NonNull final DirectUser newItem) { + return oldItem.getUsername().equals(newItem.getUsername()) && + oldItem.getFullName().equals(newItem.getFullName()); + } + }; + private final boolean showSelection; + private final Set selectedUserIds; + private final OnDirectUserClickListener onUserClickListener; + + public UserSearchResultsAdapter(final boolean showSelection, + final OnDirectUserClickListener onUserClickListener) { + super(DIFF_CALLBACK); + this.showSelection = showSelection; + selectedUserIds = showSelection ? new HashSet<>() : null; + this.onUserClickListener = onUserClickListener; + setHasStableIds(true); + } + + @NonNull + @Override + public DirectUserViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); + return new DirectUserViewHolder(binding, onUserClickListener, null); + + } + + @Override + public void onBindViewHolder(@NonNull final DirectUserViewHolder holder, final int position) { + final DirectUser user = getItem(position); + boolean isSelected = selectedUserIds != null && selectedUserIds.contains(user.getPk()); + holder.bind(position, user, false, false, showSelection, isSelected); + } + + @Override + public long getItemId(final int position) { + return getItem(position).getPk(); + } + + public void setSelectedUser(final long userId, final boolean selected) { + if (selectedUserIds == null) return; + int position = -1; + final List currentList = getCurrentList(); + for (int i = 0; i < currentList.size(); i++) { + if (currentList.get(i).getPk() == userId) { + position = i; + break; + } + } + if (position < 0) return; + if (selected) { + selectedUserIds.add(userId); + } else { + selectedUserIds.remove(userId); + } + notifyItemChanged(position); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/DirectUserViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/DirectUserViewHolder.java new file mode 100644 index 00000000..8dcdbeab --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/DirectUserViewHolder.java @@ -0,0 +1,102 @@ +package awais.instagrabber.adapters.viewholder; + +import android.graphics.drawable.Drawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; +import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserLongClickListener; +import awais.instagrabber.customviews.VerticalImageSpan; +import awais.instagrabber.databinding.LayoutDmUserItemBinding; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; +import awais.instagrabber.utils.Utils; + +public class DirectUserViewHolder extends RecyclerView.ViewHolder { + private static final String TAG = DirectUserViewHolder.class.getSimpleName(); + + private final LayoutDmUserItemBinding binding; + private final OnDirectUserClickListener onClickListener; + private final OnDirectUserLongClickListener onLongClickListener; + private final int drawableSize; + + private VerticalImageSpan verifiedSpan; + + public DirectUserViewHolder(@NonNull final LayoutDmUserItemBinding binding, + final OnDirectUserClickListener onClickListener, + final OnDirectUserLongClickListener onLongClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onClickListener = onClickListener; + this.onLongClickListener = onLongClickListener; + drawableSize = Utils.convertDpToPx(24); + } + + public void bind(final int position, + final DirectUser user, + final boolean isAdmin, + final boolean isInviter, + final boolean showSelection, + final boolean isSelected) { + if (user == null) return; + binding.getRoot().setOnClickListener(v -> { + if (onClickListener == null) return; + onClickListener.onClick(position, user, isSelected); + }); + binding.getRoot().setOnLongClickListener(v -> { + if (onLongClickListener == null) return false; + return onLongClickListener.onLongClick(position, user); + }); + setFullName(user); + binding.username.setText(user.getUsername()); + binding.profilePic.setImageURI(user.getProfilePicUrl()); + setInfo(isAdmin, isInviter); + setSelection(showSelection, isSelected); + } + + private void setFullName(final DirectUser user) { + final SpannableStringBuilder sb = new SpannableStringBuilder(user.getFullName()); + if (user.isVerified()) { + if (verifiedSpan == null) { + final Drawable verifiedDrawable = AppCompatResources.getDrawable(itemView.getContext(), R.drawable.verified); + if (verifiedDrawable != null) { + final Drawable drawable = verifiedDrawable.mutate(); + drawable.setBounds(0, 0, drawableSize, drawableSize); + verifiedSpan = new VerticalImageSpan(drawable); + } + } + try { + if (verifiedSpan != null) { + sb.append(" "); + sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + Log.e(TAG, "bind: ", e); + } + } + binding.fullName.setText(sb); + } + + private void setInfo(final boolean isAdmin, final boolean isInviter) { + if (!isAdmin && !isInviter) { + binding.info.setVisibility(View.GONE); + return; + } + if (isAdmin) { + binding.info.setText(R.string.admin); + return; + } + binding.info.setText(R.string.inviter); + } + + private void setSelection(final boolean showSelection, final boolean isSelected) { + binding.select.setVisibility(showSelection ? View.VISIBLE : View.GONE); + binding.getRoot().setSelected(isSelected); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java b/app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java new file mode 100644 index 00000000..06c633f7 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java @@ -0,0 +1,76 @@ +package awais.instagrabber.customviews; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.style.ImageSpan; + +import androidx.annotation.NonNull; + +public class VerticalImageSpan extends ImageSpan { + + public VerticalImageSpan(final Drawable drawable) { + super(drawable); + } + + /** + * update the text line height + */ + @Override + public int getSize(@NonNull Paint paint, + CharSequence text, + int start, + int end, + Paint.FontMetricsInt fontMetricsInt) { + Drawable drawable = getDrawable(); + Rect rect = drawable.getBounds(); + if (fontMetricsInt != null) { + Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt(); + int fontHeight = fmPaint.descent - fmPaint.ascent; + int drHeight = rect.bottom - rect.top; + int centerY = fmPaint.ascent + fontHeight / 2; + + fontMetricsInt.ascent = centerY - drHeight / 2; + fontMetricsInt.top = fontMetricsInt.ascent; + fontMetricsInt.bottom = centerY + drHeight / 2; + fontMetricsInt.descent = fontMetricsInt.bottom; + } + return rect.right; + } + + /** + * see detail message in android.text.TextLine + * + * @param canvas the canvas, can be null if not rendering + * @param text the text to be draw + * @param start the text start position + * @param end the text end position + * @param x the edge of the replacement closest to the leading margin + * @param top the top of the line + * @param y the baseline + * @param bottom the bottom of the line + * @param paint the work paint + */ + @Override + public void draw(Canvas canvas, + CharSequence text, + int start, + int end, + float x, + int top, + int y, + int bottom, + Paint paint) { + Drawable drawable = getDrawable(); + canvas.save(); + Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt(); + int fontHeight = fmPaint.descent - fmPaint.ascent; + int centerY = y + fmPaint.descent - fontHeight / 2; + int transY = centerY - (drawable.getBounds().bottom - drawable.getBounds().top) / 2; + canvas.translate(x, transY); + drawable.draw(canvas); + canvas.restore(); + } + +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java new file mode 100644 index 00000000..4c7abb4d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java @@ -0,0 +1,236 @@ +package awais.instagrabber.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.util.SparseBooleanArray; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import com.google.common.primitives.Booleans; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class MultiOptionDialogFragment extends DialogFragment { + private static final String TAG = MultiOptionDialogFragment.class.getSimpleName(); + + public enum Type { + MULTIPLE, + SINGLE_CHECKED, + SINGLE + } + + private Context context; + private Type type; + private MultiOptionDialogCallback callback; + private MultiOptionDialogSingleCallback singleCallback; + private List> options; + + @NonNull + public static MultiOptionDialogFragment newInstance(@StringRes final int title, + @NonNull final ArrayList> options) { + return newInstance(title, 0, 0, options, Type.SINGLE); + } + + @NonNull + public static MultiOptionDialogFragment newInstance(@StringRes final int title, + @StringRes final int positiveButtonText, + @StringRes final int negativeButtonText, + @NonNull final ArrayList> options, + @NonNull final Type type) { + Bundle args = new Bundle(); + args.putInt("title", title); + args.putInt("positiveButtonText", positiveButtonText); + args.putInt("negativeButtonText", negativeButtonText); + args.putSerializable("options", options); + args.putSerializable("type", type); + MultiOptionDialogFragment fragment = new MultiOptionDialogFragment<>(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + this.context = context; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Bundle arguments = getArguments(); + int title = 0; + if (arguments != null) { + title = arguments.getInt("title"); + type = (Type) arguments.getSerializable("type"); + } + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + if (title > 0) { + builder.setTitle(title); + } + try { + //noinspection unchecked + options = arguments != null ? (List>) arguments.getSerializable("options") + : Collections.emptyList(); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + options = Collections.emptyList(); + } + final int negativeButtonText = arguments != null ? arguments.getInt("negativeButtonText", -1) : -1; + if (negativeButtonText > 0) { + builder.setNegativeButton(negativeButtonText, (dialog, which) -> { + if (callback != null) { + callback.onCancel(); + return; + } + if (singleCallback != null) { + singleCallback.onCancel(); + } + }); + } + if (type == Type.MULTIPLE || type == Type.SINGLE_CHECKED) { + final int positiveButtonText = arguments != null ? arguments.getInt("positiveButtonText", -1) : -1; + if (positiveButtonText > 0) { + builder.setPositiveButton(positiveButtonText, (dialog, which) -> { + if (callback == null || options == null || options.isEmpty()) return; + try { + final List selected = new ArrayList<>(); + final SparseBooleanArray checkedItemPositions = ((AlertDialog) dialog).getListView().getCheckedItemPositions(); + for (int i = 0; i < checkedItemPositions.size(); i++) { + final int position = checkedItemPositions.keyAt(i); + final boolean checked = checkedItemPositions.get(position); + if (!checked) continue; + //noinspection unchecked + final Option option = (Option) options.get(position); + selected.add(option.value); + } + callback.onMultipleSelect(selected); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } + } + if (type == Type.MULTIPLE) { + if (options != null && !options.isEmpty()) { + final String[] items = options.stream() + .map(option -> option.label) + .toArray(String[]::new); + final boolean[] checkedItems = Booleans.toArray(options.stream() + .map(option -> option.checked) + .collect(Collectors.toList())); + builder.setMultiChoiceItems(items, checkedItems, (dialog, which, isChecked) -> { + if (callback == null) return; + try { + final Option option = options.get(which); + //noinspection unchecked + callback.onCheckChange((T) option.value, isChecked); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } + } else { + if (options != null && !options.isEmpty()) { + final String[] items = options.stream() + .map(option -> option.label) + .toArray(String[]::new); + if (type == Type.SINGLE_CHECKED) { + int index = -1; + for (int i = 0; i < options.size(); i++) { + if (options.get(i).checked) { + index = i; + break; + } + } + builder.setSingleChoiceItems(items, index, (dialog, which) -> { + if (callback == null) return; + try { + final Option option = options.get(which); + //noinspection unchecked + callback.onCheckChange((T) option.value, true); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } else if (type == Type.SINGLE) { + builder.setItems(items, (dialog, which) -> { + if (singleCallback == null) return; + try { + final Option option = options.get(which); + //noinspection unchecked + singleCallback.onSelect((T) option.value); + } catch (Exception e) { + Log.e(TAG, "onCreateDialog: ", e); + } + }); + } + } + } + return builder.create(); + } + + public void setCallback(final MultiOptionDialogCallback callback) { + if (callback == null) return; + this.callback = callback; + } + + public void setSingleCallback(final MultiOptionDialogSingleCallback callback) { + if (callback == null) return; + this.singleCallback = callback; + } + + public interface MultiOptionDialogCallback { + void onSelect(T result); + + void onMultipleSelect(List result); + + void onCheckChange(T item, boolean isChecked); + + void onCancel(); + } + + public interface MultiOptionDialogSingleCallback { + void onSelect(T result); + + void onCancel(); + } + + public static class Option { + private final String label; + private final T value; + private final boolean checked; + + public Option(final String label, final T value) { + this.label = label; + this.value = value; + this.checked = false; + } + + public Option(final String label, final T value, final boolean checked) { + this.label = label; + this.value = value; + this.checked = checked; + } + + public String getLabel() { + return label; + } + + public T getValue() { + return value; + } + + public boolean isChecked() { + return checked; + } + } +} diff --git a/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java index 91779710..53f6f1dc 100644 --- a/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java +++ b/app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java @@ -9,7 +9,6 @@ import android.graphics.drawable.ColorDrawable; import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -20,7 +19,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentActivity; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.BaseControllerListener; @@ -32,17 +30,14 @@ import java.io.File; import awais.instagrabber.R; import awais.instagrabber.asyncs.ProfilePictureFetcher; import awais.instagrabber.databinding.DialogProfilepicBinding; -import awais.instagrabber.db.entities.Account; -import awais.instagrabber.db.repositories.RepositoryCallback; import awais.instagrabber.interfaces.FetchListener; -import awais.instagrabber.models.StoryModel; import awais.instagrabber.repositories.responses.UserInfo; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; -import awais.instagrabber.webservices.ProfileService; import awais.instagrabber.webservices.ServiceCallback; +import awais.instagrabber.webservices.UserService; import static awais.instagrabber.utils.Utils.settingsHelper; @@ -128,8 +123,8 @@ public class ProfilePicDialogFragment extends DialogFragment { private void fetchAvatar() { if (isLoggedIn) { - final ProfileService profileService = ProfileService.getInstance(); - profileService.getUserInfo(id, new ServiceCallback() { + final UserService userService = UserService.getInstance(); + userService.getUserInfo(id, new ServiceCallback() { @Override public void onSuccess(final UserInfo result) { if (result != null) { @@ -144,8 +139,9 @@ public class ProfilePicDialogFragment extends DialogFragment { getDialog().dismiss(); } }); + return; } - else new ProfilePictureFetcher(name, id, fetchListener, fallbackUrl, false).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + new ProfilePictureFetcher(name, id, fetchListener, fallbackUrl, false).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } private void setupPhoto() { diff --git a/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java b/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java new file mode 100644 index 00000000..70e0a96d --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.java @@ -0,0 +1,270 @@ +package awais.instagrabber.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.core.util.Pair; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.SavedStateHandle; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavBackStackEntry; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.transition.TransitionManager; + +import com.google.android.material.chip.Chip; +import com.google.android.material.snackbar.Snackbar; + +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.UserSearchResultsAdapter; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.databinding.FragmentUserSearchBinding; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.utils.Utils; +import awais.instagrabber.utils.ViewUtils; +import awais.instagrabber.viewmodels.UserSearchViewModel; + +public class UserSearchFragment extends Fragment { + private static final String TAG = UserSearchFragment.class.getSimpleName(); + + private FragmentUserSearchBinding binding; + private UserSearchViewModel viewModel; + private UserSearchResultsAdapter resultsAdapter; + private int paddingOffset; + + private final int windowWidth = Utils.displayMetrics.widthPixels; + private final int minInputWidth = Utils.convertDpToPx(50); + private String actionLabel; + private String title; + private boolean multiple; + private long[] hideUserIds; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + binding = FragmentUserSearchBinding.inflate(inflater, container, false); + viewModel = new ViewModelProvider(this).get(UserSearchViewModel.class); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + paddingOffset = binding.search.getPaddingStart() + binding.search.getPaddingEnd() + binding.group + .getPaddingStart() + binding.group.getPaddingEnd() + binding.group.getChipSpacingHorizontal(); + init(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + viewModel.cleanup(); + } + + private void init() { + final Bundle arguments = getArguments(); + if (arguments != null) { + final UserSearchFragmentArgs fragmentArgs = UserSearchFragmentArgs.fromBundle(arguments); + actionLabel = fragmentArgs.getActionLabel(); + title = fragmentArgs.getTitle(); + multiple = fragmentArgs.getMultiple(); + viewModel.setHideUserIds(fragmentArgs.getHideUserIds()); + } + setupTitles(); + setupInput(); + setupResults(); + setupObservers(); + } + + private void setupTitles() { + if (!TextUtils.isEmpty(actionLabel)) { + binding.done.setText(actionLabel); + } + if (!TextUtils.isEmpty(title)) { + final MainActivity activity = (MainActivity) getActivity(); + if (activity != null) { + final ActionBar actionBar = activity.getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(title); + } + } + } + } + + private void setupResults() { + final Context context = getContext(); + if (context == null) return; + binding.results.setLayoutManager(new LinearLayoutManager(context)); + resultsAdapter = new UserSearchResultsAdapter(multiple, (position, user, selected) -> { + if (!multiple) { + final NavController navController = NavHostFragment.findNavController(this); + final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); + if (navBackStackEntry == null) return; + final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); + savedStateHandle.set("result", user); + navController.navigateUp(); + return; + } + viewModel.setSelectedUser(user, !selected); + resultsAdapter.setSelectedUser(user.getPk(), !selected); + if (!selected) { + createUserChip(user); + return; + } + final View chip = findChip(user.getPk()); + if (chip == null) return; + removeChip(chip); + }); + binding.results.setAdapter(resultsAdapter); + binding.done.setOnClickListener(v -> { + final NavController navController = NavHostFragment.findNavController(this); + final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); + if (navBackStackEntry == null) return; + final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); + savedStateHandle.set("result", viewModel.getSelectedUsers()); + navController.navigateUp(); + }); + } + + private void setupInput() { + binding.search.addTextChangedListener(new TextWatcherAdapter() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + if (TextUtils.isEmpty(s)) { + viewModel.cancelSearch(); + viewModel.clearResults(); + return; + } + viewModel.search(s.toString().trim()); + } + }); + binding.search.setOnKeyListener((v, keyCode, event) -> { + if (event != null && event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL) { + final View chip = getLastChip(); + if (chip == null) return false; + removeSelectedUser(chip); + } + return false; + }); + binding.group.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() { + @Override + public void onChildViewAdded(final View parent, final View child) {} + + @Override + public void onChildViewRemoved(final View parent, final View child) { + binding.group.post(() -> { + TransitionManager.beginDelayedTransition(binding.getRoot()); + calculateInputWidth(0); + }); + } + }); + + } + + private void setupObservers() { + viewModel.getUsers().observe(getViewLifecycleOwner(), results -> { + if (results == null) return; + switch (results.status) { + case SUCCESS: + resultsAdapter.submitList(results.data); + break; + case ERROR: + if (results.message != null) { + Snackbar.make(binding.getRoot(), results.message, Snackbar.LENGTH_LONG).show(); + } + break; + case LOADING: + break; + } + }); + viewModel.showAction().observe(getViewLifecycleOwner(), showAction -> binding.done.setVisibility(showAction ? View.VISIBLE : View.GONE)); + } + + private void createUserChip(final DirectUser user) { + final Context context = getContext(); + if (context == null) return; + final Chip chip = new Chip(context); + chip.setTag(user); + chip.setText(user.getFullName()); + chip.setCloseIconVisible(true); + chip.setOnCloseIconClickListener(v -> removeSelectedUser(chip)); + binding.group.post(() -> { + final Pair measure = ViewUtils.measure(chip, binding.group); + TransitionManager.beginDelayedTransition(binding.getRoot()); + calculateInputWidth(measure.second != null ? measure.second : 0); + binding.group.addView(chip, binding.group.getChildCount() - 1); + }); + } + + private void removeSelectedUser(final View chip) { + final DirectUser user = (DirectUser) chip.getTag(); + if (user == null) return; + viewModel.setSelectedUser(user, false); + resultsAdapter.setSelectedUser(user.getPk(), false); + removeChip(chip); + } + + private View findChip(final long userId) { + final int childCount = binding.group.getChildCount(); + if (childCount == 0) { + return null; + } + for (int i = childCount - 1; i >= 0; i--) { + final View child = binding.group.getChildAt(i); + if (child == null) continue; + final DirectUser user = (DirectUser) child.getTag(); + if (user != null && user.getPk() == userId) { + return child; + } + } + return null; + } + + private void removeChip(final View chip) { + binding.group.post(() -> { + TransitionManager.beginDelayedTransition(binding.getRoot()); + binding.group.removeView(chip); + }); + } + + private void calculateInputWidth(final int newChipWidth) { + final View lastChip = getLastChip(); + int lastRight = lastChip != null ? lastChip.getRight() : 0; + final int remainingSpaceInRow = windowWidth - lastRight; + if (remainingSpaceInRow < newChipWidth) { + // next chip will go to the next row, so assume no chips present + lastRight = 0; + } + final int newRight = lastRight + newChipWidth; + final int newInputWidth = windowWidth - newRight - paddingOffset; + binding.search.getLayoutParams().width = newInputWidth < minInputWidth ? windowWidth : newInputWidth; + binding.search.requestLayout(); + } + + private View getLastChip() { + final int childCount = binding.group.getChildCount(); + if (childCount == 0) { + return null; + } + for (int i = childCount - 1; i >= 0; i--) { + final View child = binding.group.getChildAt(i); + if (child instanceof Chip) { + return child; + } + } + return null; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.java index 39478e56..545b68f4 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.java @@ -15,6 +15,8 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.fragment.app.Fragment; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.ViewModelStoreOwner; +import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; @@ -54,7 +56,9 @@ public class DirectMessageInboxFragment extends Fragment implements SwipeRefresh super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) getActivity(); if (fragmentActivity != null) { - viewModel = new ViewModelProvider(fragmentActivity).get(DirectInboxViewModel.class); + final NavController navController = NavHostFragment.findNavController(this); + final ViewModelStoreOwner viewModelStoreOwner = navController.getViewModelStoreOwner(R.id.direct_messages_nav_graph); + viewModel = new ViewModelProvider(viewModelStoreOwner).get(DirectInboxViewModel.class); } } diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java index 55ea42eb..84574194 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.java @@ -1,126 +1,117 @@ package awais.instagrabber.fragments.directmessages; import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.os.AsyncTask; import android.os.Bundle; import android.text.Editable; -import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.AppCompatButton; -import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.util.Pair; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.ViewModelStoreOwner; +import androidx.navigation.NavBackStackEntry; +import androidx.navigation.NavController; +import androidx.navigation.NavDestination; +import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import java.io.DataOutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; +import com.google.android.material.snackbar.Snackbar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; import awais.instagrabber.R; -import awais.instagrabber.broadcasts.DMRefreshBroadcastReceiver; +import awais.instagrabber.adapters.DirectUsersAdapter; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.FragmentDirectMessagesSettingsBinding; -import awais.instagrabber.models.ProfileModel; -import awais.instagrabber.utils.Constants; -import awais.instagrabber.utils.Utils; +import awais.instagrabber.dialogs.MultiOptionDialogFragment; +import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; +import awais.instagrabber.viewmodels.DirectInboxViewModel; +import awais.instagrabber.viewmodels.DirectSettingsViewModel; -public class DirectMessageSettingsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { - private static final String TAG = "DirectMsgsSettingsFrag"; +public class DirectMessageSettingsFragment extends Fragment { + private static final String TAG = DirectMessageSettingsFragment.class.getSimpleName(); - private AppCompatActivity fragmentActivity; - private RecyclerView userList; - private RecyclerView leftUserList; - private EditText titleText; - private View leftTitle; - private AppCompatImageView titleSend; - private String threadId; - private String threadTitle; - private final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); - // private AsyncTask currentlyRunning; - private View.OnClickListener clickListener; - private View.OnClickListener basicClickListener; - - // private final FetchListener fetchListener = new FetchListener() { - // @Override - // public void doBefore() {} - // - // @Override - // public void onResult(final InboxThreadModel threadModel) { - // if (threadModel == null) return; - // final List adminList = threadModel.getAdmins(); - // final String userIdFromCookie = CookieUtils.getUserIdFromCookie(cookie); - // if (userIdFromCookie == null) return; - // final boolean amAdmin = adminList.contains(Long.parseLong(userIdFromCookie)); - // final DirectMessageMembersAdapter memberAdapter = new DirectMessageMembersAdapter(threadModel.getUsers(), - // adminList, - // amAdmin ? clickListener : basicClickListener); - // userList.setAdapter(memberAdapter); - // if (threadModel.getLeftUsers() != null && threadModel.getLeftUsers().size() > 0) { - // leftTitle.setVisibility(View.VISIBLE); - // final DirectMessageMembersAdapter leftAdapter = new DirectMessageMembersAdapter(threadModel.getLeftUsers(), - // null, - // basicClickListener); - // leftUserList.setAdapter(leftAdapter); - // } - // } - // }; + private FragmentDirectMessagesSettingsBinding binding; + private DirectSettingsViewModel viewModel; + private DirectUsersAdapter usersAdapter; + private List> options; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - fragmentActivity = (AppCompatActivity) requireActivity(); - basicClickListener = v -> { - final Object tag = v.getTag(); - if (tag instanceof ProfileModel) { - ProfileModel model = (ProfileModel) tag; - final Bundle bundle = new Bundle(); - bundle.putString("username", "@" + model.getUsername()); - NavHostFragment.findNavController(this).navigate(R.id.action_global_profileFragment, bundle); - } - }; - - clickListener = v -> { - final Object tag = v.getTag(); - if (tag instanceof ProfileModel) { - ProfileModel model = (ProfileModel) tag; - final Context context = getContext(); - if (context == null) return; - final ArrayAdapter adapter = new ArrayAdapter<>(context, android.R.layout.simple_list_item_1, new String[]{ - getString(R.string.open_profile), - getString(R.string.dms_action_kick), - }); - final DialogInterface.OnClickListener clickListener = (d, w) -> { - if (w == 0) { - final Bundle bundle = new Bundle(); - bundle.putString("username", "@" + model.getUsername()); - NavHostFragment.findNavController(this).navigate(R.id.action_global_profileFragment, bundle); - } else if (w == 1) { - new ChangeSettings(titleText.getText().toString()).execute("remove_users", model.getId()); - onRefresh(); - } - }; - new AlertDialog.Builder(context) - .setAdapter(adapter, clickListener) - .show(); - } - }; + final NavController navController = NavHostFragment.findNavController(this); + final ViewModelStoreOwner viewModelStoreOwner = navController.getViewModelStoreOwner(R.id.direct_messages_nav_graph); + final DirectInboxViewModel inboxViewModel = new ViewModelProvider(viewModelStoreOwner).get(DirectInboxViewModel.class); + final List threads = inboxViewModel.getThreads().getValue(); + final Bundle arguments = getArguments(); + if (arguments == null) { + navController.navigateUp(); + return; + } + final DirectMessageSettingsFragmentArgs fragmentArgs = DirectMessageSettingsFragmentArgs.fromBundle(arguments); + final String threadId = fragmentArgs.getThreadId(); + final Optional first = threads != null ? threads.stream() + .filter(thread -> thread.getThreadId().equals(threadId)) + .findFirst() + : Optional.empty(); + if (!first.isPresent()) { + navController.navigateUp(); + return; + } + viewModel = new ViewModelProvider(this).get(DirectSettingsViewModel.class); + viewModel.setViewer(inboxViewModel.getViewer()); + viewModel.setThread(first.get()); + // basicClickListener = v -> { + // final Object tag = v.getTag(); + // if (tag instanceof ProfileModel) { + // ProfileModel model = (ProfileModel) tag; + // final Bundle bundle = new Bundle(); + // bundle.putString("username", "@" + model.getUsername()); + // NavHostFragment.findNavController(this).navigate(R.id.action_global_profileFragment, bundle); + // } + // }; + // + // clickListener = v -> { + // final Object tag = v.getTag(); + // if (tag instanceof ProfileModel) { + // ProfileModel model = (ProfileModel) tag; + // final Context context = getContext(); + // if (context == null) return; + // final ArrayAdapter adapter = new ArrayAdapter<>(context, android.R.layout.simple_list_item_1, new String[]{ + // getString(R.string.open_profile), + // getString(R.string.dms_action_kick), + // }); + // final DialogInterface.OnClickListener clickListener = (d, w) -> { + // if (w == 0) { + // final Bundle bundle = new Bundle(); + // bundle.putString("username", "@" + model.getUsername()); + // NavHostFragment.findNavController(this).navigate(R.id.action_global_profileFragment, bundle); + // } else if (w == 1) { + // new ChangeSettings(titleText.getText().toString()).execute("remove_users", model.getId()); + // onRefresh(); + // } + // }; + // new AlertDialog.Builder(context) + // .setAdapter(adapter, clickListener) + // .show(); + // } + // }; } @NonNull @@ -128,155 +119,239 @@ public class DirectMessageSettingsFragment extends Fragment implements SwipeRefr public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - final FragmentDirectMessagesSettingsBinding binding = FragmentDirectMessagesSettingsBinding.inflate(inflater, container, false); - final LinearLayout root = binding.getRoot(); - final Context context = getContext(); - if (context == null) return root; - final LinearLayoutManager layoutManager = new LinearLayoutManager(context) { - @Override - public boolean canScrollVertically() { - return false; - } - }; - final LinearLayoutManager layoutManagerDos = new LinearLayoutManager(context) { - @Override - public boolean canScrollVertically() { - return false; - } - }; - if (getArguments() == null) { - return root; - } - threadId = DirectMessageSettingsFragmentArgs.fromBundle(getArguments()).getThreadId(); - threadTitle = DirectMessageSettingsFragmentArgs.fromBundle(getArguments()).getTitle(); - binding.swipeRefreshLayout.setEnabled(false); + binding = FragmentDirectMessagesSettingsBinding.inflate(inflater, container, false); + // final String threadId = DirectMessageSettingsFragmentArgs.fromBundle(getArguments()).getThreadId(); + // threadTitle = DirectMessageSettingsFragmentArgs.fromBundle(getArguments()).getTitle(); + // binding.swipeRefreshLayout.setEnabled(false); - final ActionBar actionBar = fragmentActivity.getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(threadTitle); - } + // final ActionBar actionBar = fragmentActivity.getSupportActionBar(); + // if (actionBar != null) { + // actionBar.setTitle(threadTitle); + // } - userList = binding.userList; - userList.setHasFixedSize(true); - userList.setLayoutManager(layoutManager); + // titleSend.setOnClickListener(v -> new ChangeSettings(titleText.getText().toString()).execute("update_title")); - leftUserList = binding.leftUserList; - leftUserList.setHasFixedSize(true); - leftUserList.setLayoutManager(layoutManagerDos); + // binding.titleText.addTextChangedListener(new TextWatcherAdapter() { + // @Override + // public void onTextChanged(CharSequence s, int start, int before, int count) { + // binding.titleSend.setVisibility(s.toString().equals(threadTitle) ? View.GONE : View.VISIBLE); + // } + // }); - leftTitle = binding.leftTitle; - - titleText = binding.titleText; - titleText.setText(threadTitle); - - titleSend = binding.titleSend; - titleSend.setOnClickListener(v -> new ChangeSettings(titleText.getText().toString()).execute("update_title")); - - titleText.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(Editable s) {} - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - titleSend.setVisibility(s.toString().equals(threadTitle) ? View.GONE : View.VISIBLE); - } - }); - - final AppCompatButton btnLeave = binding.btnLeave; - btnLeave.setOnClickListener(v -> new AlertDialog.Builder(context) - .setTitle(R.string.dms_action_leave_question) - .setPositiveButton(R.string.yes, - (x, y) -> new ChangeSettings(titleText.getText().toString()).execute("leave")) - .setNegativeButton(R.string.no, null) - .show()); + // final AppCompatButton btnLeave = binding.btnLeave; + // btnLeave.setOnClickListener(v -> new AlertDialog.Builder(context) + // .setTitle(R.string.dms_action_leave_question) + // .setPositiveButton(R.string.yes, (x, y) -> new ChangeSettings(titleText.getText().toString()).execute("leave")) + // .setNegativeButton(R.string.no, null) + // .show()); // currentlyRunning = new DirectMessageInboxThreadFetcher(threadId, null, null, fetchListener).execute(); - return root; + return binding.getRoot(); } @Override - public void onRefresh() { - stopCurrentExecutor(); - // currentlyRunning = new DirectMessageInboxThreadFetcher(threadId, null, null, fetchListener).execute(); + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + init(); + setupObservers(); } - private void stopCurrentExecutor() { - // if (currentlyRunning != null) { - // try { - // currentlyRunning.cancel(true); - // } catch (final Exception e) { - // if (BuildConfig.DEBUG) { - // Log.e(TAG, "", e); - // } - // } - // } - } - - class ChangeSettings extends AsyncTask { - String action, argument; - boolean ok = false; - private final String text; - - public ChangeSettings(final String text) { - this.text = text; - } - - protected Void doInBackground(String... rawAction) { - action = rawAction[0]; - if (rawAction.length == 2) argument = rawAction[1]; - final String url = "https://i.instagram.com/api/v1/direct_v2/threads/" + threadId + "/" + action + "/"; - try { - String urlParameters = "_csrftoken=" + cookie.split("csrftoken=")[1].split(";")[0] - + "&_uuid=" + Utils.settingsHelper.getString(Constants.DEVICE_UUID); - if (action.equals("update_title")) { - urlParameters += "&title=" + URLEncoder.encode(text, "UTF-8") - .replaceAll("\\+", "%20").replaceAll("%21", "!").replaceAll("%27", "'") - .replaceAll("%28", "(").replaceAll("%29", ")").replaceAll("%7E", "~"); - } else if (action.startsWith("remove_users")) - urlParameters += ("&user_ids=[" + argument + "]"); - final HttpURLConnection urlConnection = (HttpURLConnection) new URL(url).openConnection(); - urlConnection.setRequestMethod("POST"); - urlConnection.setUseCaches(false); - urlConnection.setRequestProperty("User-Agent", Constants.I_USER_AGENT); - urlConnection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); - urlConnection.setRequestProperty("Content-Length", Integer.toString(urlParameters.getBytes().length)); - urlConnection.setDoOutput(true); - DataOutputStream wr = new DataOutputStream(urlConnection.getOutputStream()); - wr.writeBytes(urlParameters); - wr.flush(); - wr.close(); - urlConnection.connect(); - if (urlConnection.getResponseCode() == HttpURLConnection.HTTP_OK) { - ok = true; + private void setupObservers() { + viewModel.getUsers().observe(getViewLifecycleOwner(), users -> { + if (usersAdapter == null) return; + usersAdapter.submitUsers(users.first, users.second); + }); + viewModel.getTitle().observe(getViewLifecycleOwner(), title -> binding.titleEdit.setText(title)); + viewModel.getAdminUserIds().observe(getViewLifecycleOwner(), adminUserIds -> { + if (usersAdapter == null) return; + usersAdapter.setAdminUserIds(adminUserIds); + }); + final NavController navController = NavHostFragment.findNavController(this); + final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); + if (backStackEntry != null) { + final MutableLiveData resultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); + resultLiveData.observe(getViewLifecycleOwner(), result -> { + LiveData> detailsChangeResourceLiveData = null; + if ((result instanceof DirectUser)) { + // Log.d(TAG, "result: " + result); + detailsChangeResourceLiveData = viewModel.addMembers(Collections.singleton((DirectUser) result)); + } else if ((result instanceof Set)) { + try { + // Log.d(TAG, "result: " + result); + //noinspection unchecked + detailsChangeResourceLiveData = viewModel.addMembers((Set) result); + } catch (Exception e) { + Log.e(TAG, "search users result: ", e); + } } - urlConnection.disconnect(); - } catch (Throwable ex) { - Log.e("austin_debug", "unsend: " + ex); + if (detailsChangeResourceLiveData != null) { + observeDetailsChange(detailsChangeResourceLiveData); + } + }); + } + } + + private void init() { + setupSettings(); + setupMembers(); + } + + private void setupSettings() { + binding.groupSettings.setVisibility(viewModel.isGroup() ? View.VISIBLE : View.GONE); + if (!viewModel.isGroup()) return; + binding.titleEdit.addTextChangedListener(new TextWatcherAdapter() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + if (s.toString().trim().equals(viewModel.getTitle().getValue())) { + binding.titleEditInputLayout.setSuffixText(null); + return; + } + binding.titleEditInputLayout.setSuffixText(getString(R.string.save)); } - return null; - } - - @Override - protected void onPostExecute(Void result) { - final Context context = getContext(); - if (context == null) return; - if (ok) { - Toast.makeText(context, R.string.dms_action_success, Toast.LENGTH_SHORT).show(); - if (action.equals("update_title")) { - threadTitle = titleText.getText().toString(); - titleSend.setVisibility(View.GONE); - titleText.clearFocus(); - DirectMessageThreadFragment.hasSentSomething = true; - } else if (action.equals("leave")) { - context.sendBroadcast(new Intent(DMRefreshBroadcastReceiver.ACTION_REFRESH_DM)); - NavHostFragment.findNavController(DirectMessageSettingsFragment.this).popBackStack(R.id.directMessagesInboxFragment, false); - } else { - DirectMessageThreadFragment.hasSentSomething = true; - } - } else Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); - } + }); + binding.titleEditInputLayout.getSuffixTextView().setOnClickListener(v -> { + final Editable text = binding.titleEdit.getText(); + if (text == null) return; + final String newTitle = text.toString().trim(); + if (newTitle.equals(viewModel.getTitle().getValue())) return; + observeDetailsChange(viewModel.updateTitle(newTitle)); + }); + binding.addMembers.setOnClickListener(v -> { + if (!isAdded()) return; + final NavController navController = NavHostFragment.findNavController(this); + final NavDestination currentDestination = navController.getCurrentDestination(); + if (currentDestination == null) return; + if (currentDestination.getId() != R.id.directMessagesSettingsFragment) return; + final Pair, List> users = viewModel.getUsers().getValue(); + final long[] currentUserIds; + if (users != null && users.first != null) { + final List currentMembers = users.first; + currentUserIds = currentMembers.stream() + .mapToLong(DirectUser::getPk) + .sorted() + .toArray(); + } else { + currentUserIds = new long[0]; + } + final NavDirections directions = DirectMessageSettingsFragmentDirections.actionGlobalUserSearch( + true, + "Add users", + "Add", + currentUserIds + ); + navController.navigate(directions); + }); } + + private void setupMembers() { + final Context context = getContext(); + if (context == null) return; + binding.users.setLayoutManager(new LinearLayoutManager(context)); + final DirectUser inviter = viewModel.getThread().getInviter(); + usersAdapter = new DirectUsersAdapter( + inviter != null ? inviter.getPk() : -1, + (position, user, selected) -> { + // navigate to profile + }, + (position, user) -> { + final ArrayList> options = viewModel.createUserOptions(user); + if (options == null || options.isEmpty()) return true; + final MultiOptionDialogFragment fragment = MultiOptionDialogFragment.newInstance(-1, options); + fragment.setSingleCallback(new MultiOptionDialogFragment.MultiOptionDialogSingleCallback() { + @Override + public void onSelect(final String action) { + if (action == null) return; + observeDetailsChange(viewModel.doAction(user, action)); + } + + @Override + public void onCancel() {} + }); + final FragmentManager fragmentManager = getChildFragmentManager(); + fragment.show(fragmentManager, "actions"); + return true; + } + ); + binding.users.setAdapter(usersAdapter); + } + + private void observeDetailsChange(@NonNull final LiveData> detailsChangeResourceLiveData) { + detailsChangeResourceLiveData.observe(getViewLifecycleOwner(), resource -> { + if (resource == null) return; + switch (resource.status) { + case SUCCESS: + case LOADING: + break; + case ERROR: + if (resource.message != null) { + Snackbar.make(binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); + } + break; + } + }); + } + + // class ChangeSettings extends AsyncTask { + // String action, argument; + // boolean ok = false; + // private final String text; + // + // public ChangeSettings(final String text) { + // this.text = text; + // } + // + // protected Void doInBackground(String... rawAction) { + // action = rawAction[0]; + // if (rawAction.length == 2) argument = rawAction[1]; + // final String url = "https://i.instagram.com/api/v1/direct_v2/threads/" + threadId + "/" + action + "/"; + // try { + // String urlParameters = "_csrftoken=" + cookie.split("csrftoken=")[1].split(";")[0] + // + "&_uuid=" + Utils.settingsHelper.getString(Constants.DEVICE_UUID); + // if (action.equals("update_title")) { + // urlParameters += "&title=" + URLEncoder.encode(text, "UTF-8") + // .replaceAll("\\+", "%20").replaceAll("%21", "!").replaceAll("%27", "'") + // .replaceAll("%28", "(").replaceAll("%29", ")").replaceAll("%7E", "~"); + // } else if (action.startsWith("remove_users")) + // urlParameters += ("&user_ids=[" + argument + "]"); + // final HttpURLConnection urlConnection = (HttpURLConnection) new URL(url).openConnection(); + // urlConnection.setRequestMethod("POST"); + // urlConnection.setUseCaches(false); + // urlConnection.setRequestProperty("User-Agent", Constants.I_USER_AGENT); + // urlConnection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + // urlConnection.setRequestProperty("Content-Length", Integer.toString(urlParameters.getBytes().length)); + // urlConnection.setDoOutput(true); + // DataOutputStream wr = new DataOutputStream(urlConnection.getOutputStream()); + // wr.writeBytes(urlParameters); + // wr.flush(); + // wr.close(); + // urlConnection.connect(); + // if (urlConnection.getResponseCode() == HttpURLConnection.HTTP_OK) { + // ok = true; + // } + // urlConnection.disconnect(); + // } catch (Throwable ex) { + // Log.e("austin_debug", "unsend: " + ex); + // } + // return null; + // } + // + // @Override + // protected void onPostExecute(Void result) { + // final Context context = getContext(); + // if (context == null) return; + // if (ok) { + // Toast.makeText(context, R.string.dms_action_success, Toast.LENGTH_SHORT).show(); + // if (action.equals("update_title")) { + // threadTitle = titleText.getText().toString(); + // titleSend.setVisibility(View.GONE); + // titleText.clearFocus(); + // DirectMessageThreadFragment.hasSentSomething = true; + // } else if (action.equals("leave")) { + // context.sendBroadcast(new Intent(DMRefreshBroadcastReceiver.ACTION_REFRESH_DM)); + // NavHostFragment.findNavController(DirectMessageSettingsFragment.this).popBackStack(R.id.directMessagesInboxFragment, false); + // } else { + // DirectMessageThreadFragment.hasSentSomething = true; + // } + // } else Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); + // } + // } } diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java index b8a7e83f..fc4f76a0 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java @@ -32,6 +32,7 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.ViewModelStoreOwner; import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.NavDirections; @@ -249,8 +250,8 @@ public class DirectMessageThreadFragment extends Fragment { public boolean onOptionsItemSelected(@NonNull final MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.info) { - // final NavDirections action = DirectMessageThreadFragmentDirections.actionDMThreadFragmentToDMSettingsFragment(threadId, threadTitle); - // NavHostFragment.findNavController(this).navigate(action); + final NavDirections action = DirectMessageThreadFragmentDirections.actionDMThreadFragmentToDMSettingsFragment(viewModel.getThreadId()); + NavHostFragment.findNavController(this).navigate(action); return true; } if (itemId == R.id.mark_as_seen) { @@ -372,7 +373,9 @@ public class DirectMessageThreadFragment extends Fragment { } private void getInitialData() { - final DirectInboxViewModel threadListViewModel = new ViewModelProvider(fragmentActivity).get(DirectInboxViewModel.class); + final NavController navController = NavHostFragment.findNavController(this); + final ViewModelStoreOwner viewModelStoreOwner = navController.getViewModelStoreOwner(R.id.direct_messages_nav_graph); + final DirectInboxViewModel threadListViewModel = new ViewModelProvider(viewModelStoreOwner).get(DirectInboxViewModel.class); final List threads = threadListViewModel.getThreads().getValue(); final Optional first = threads != null ? threads.stream() diff --git a/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java b/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java index 6e2b88b8..d111e54c 100644 --- a/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java @@ -37,8 +37,8 @@ import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.FlavorTown; import awais.instagrabber.utils.TextUtils; -import awais.instagrabber.webservices.ProfileService; import awais.instagrabber.webservices.ServiceCallback; +import awais.instagrabber.webservices.UserService; import static awais.instagrabber.utils.Utils.settingsHelper; @@ -199,8 +199,8 @@ public class MorePreferencesFragment extends BasePreferencesFragment { // adds cookies to database for quick access final String uid = CookieUtils.getUserIdFromCookie(cookie); - final ProfileService profileService = ProfileService.getInstance(); - profileService.getUserInfo(uid, new ServiceCallback() { + final UserService userService = UserService.getInstance(); + userService.getUserInfo(uid, new ServiceCallback() { @Override public void onSuccess(final UserInfo result) { // Log.d(TAG, "adding userInfo: " + result); diff --git a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java index 62d7b637..37b0df1e 100644 --- a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java +++ b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.java @@ -5,6 +5,7 @@ import java.util.Map; import awais.instagrabber.repositories.responses.directmessages.DirectBadgeCount; import awais.instagrabber.repositories.responses.directmessages.DirectInboxResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroadcastResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectThreadDetailsChangeResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadFeedResponse; import retrofit2.Call; import retrofit2.http.FieldMap; @@ -30,4 +31,29 @@ public interface DirectMessagesRepository { @POST("/api/v1/direct_v2/threads/broadcast/{item}/") Call broadcast(@Path("item") String item, @FieldMap final Map signedForm); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/add_user/") + Call addUsers(@Path("threadId") String threadId, + @FieldMap final Map form); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/remove_users/") + Call removeUsers(@Path("threadId") String threadId, + @FieldMap final Map form); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/update_title/") + Call updateTitle(@Path("threadId") String threadId, + @FieldMap final Map form); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/add_admins/") + Call addAdmins(@Path("threadId") String threadId, + @FieldMap final Map form); + + @FormUrlEncoded + @POST("/api/v1/direct_v2/threads/{threadId}/remove_admins/") + Call removeAdmins(@Path("threadId") String threadId, + @FieldMap final Map form); } diff --git a/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java b/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java index 916dd094..a35d759f 100644 --- a/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java +++ b/app/src/main/java/awais/instagrabber/repositories/ProfileRepository.java @@ -9,9 +9,6 @@ import retrofit2.http.QueryMap; public interface ProfileRepository { - @GET("/api/v1/users/{uid}/info/") - Call getUserInfo(@Path("uid") final String uid); - @GET("/api/v1/feed/user/{uid}/") Call fetch(@Path("uid") final String uid, @QueryMap Map queryParams); diff --git a/app/src/main/java/awais/instagrabber/repositories/UserRepository.java b/app/src/main/java/awais/instagrabber/repositories/UserRepository.java new file mode 100644 index 00000000..206f57b9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/UserRepository.java @@ -0,0 +1,17 @@ +package awais.instagrabber.repositories; + +import awais.instagrabber.repositories.responses.UserSearchResponse; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Path; +import retrofit2.http.Query; + +public interface UserRepository { + + @GET("/api/v1/users/{uid}/info/") + Call getUserInfo(@Path("uid") final String uid); + + @GET("/api/v1/users/search/") + Call search(@Query("timezone_offset") float timezoneOffset, + @Query("q") String query); +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.java new file mode 100644 index 00000000..fccf0ae0 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.java @@ -0,0 +1,35 @@ +package awais.instagrabber.repositories.responses; + +import java.util.List; + +import awais.instagrabber.repositories.responses.directmessages.DirectUser; + +public class UserSearchResponse { + private final int numResults; + private final List users; + private final boolean hasMore; + private final String status; + + public UserSearchResponse(final int numResults, final List users, final boolean hasMore, final String status) { + this.numResults = numResults; + this.users = users; + this.hasMore = hasMore; + this.status = status; + } + + public int getNumResults() { + return numResults; + } + + public List getUsers() { + return users; + } + + public boolean hasMore() { + return hasMore; + } + + public String getStatus() { + return status; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.java index 5fa2b03b..0155f425 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.java @@ -1,6 +1,7 @@ package awais.instagrabber.repositories.responses.directmessages; public class DirectInboxResponse { + private final DirectUser viewer; private final DirectInbox inbox; private final long seqId; private final long snapshotAtMs; @@ -8,12 +9,14 @@ public class DirectInboxResponse { private final DirectUser mostRecentInviter; private final String status; - public DirectInboxResponse(final DirectInbox inbox, + public DirectInboxResponse(final DirectUser viewer, + final DirectInbox inbox, final long seqId, final long snapshotAtMs, final int pendingRequestsTotal, final DirectUser mostRecentInviter, final String status) { + this.viewer = viewer; this.inbox = inbox; this.seqId = seqId; this.snapshotAtMs = snapshotAtMs; @@ -22,6 +25,10 @@ public class DirectInboxResponse { this.status = status; } + public DirectUser getViewer() { + return viewer; + } + public DirectInbox getInbox() { return inbox; } diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java index 05cf9fd6..8012ad11 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.java @@ -10,8 +10,8 @@ public class DirectThread { private final String threadId; private final String threadV2Id; private final List users; - private final List leftUsers; - private final List adminUserIds; + private final List leftUsers; + private final List adminUserIds; private final List items; private final long lastActivityAt; private final boolean muted; @@ -42,8 +42,8 @@ public class DirectThread { public DirectThread(final String threadId, final String threadV2Id, final List users, - final List leftUsers, - final List adminUserIds, + final List leftUsers, + final List adminUserIds, final List items, final long lastActivityAt, final boolean muted, @@ -115,11 +115,11 @@ public class DirectThread { return users; } - public List getLeftUsers() { + public List getLeftUsers() { return leftUsers; } - public List getAdminUserIds() { + public List getAdminUserIds() { return adminUserIds; } diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.java new file mode 100644 index 00000000..416fc284 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.java @@ -0,0 +1,19 @@ +package awais.instagrabber.repositories.responses.directmessages; + +public class DirectThreadDetailsChangeResponse { + private final DirectThread thread; + private final String status; + + public DirectThreadDetailsChangeResponse(final DirectThread thread, final String status) { + this.thread = thread; + this.status = status; + } + + public DirectThread getThread() { + return thread; + } + + public String getStatus() { + return status; + } +} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUser.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUser.java index d9da1cf3..2d3a8880 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUser.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUser.java @@ -1,8 +1,11 @@ package awais.instagrabber.repositories.responses.directmessages; +import java.io.Serializable; +import java.util.Objects; + import awais.instagrabber.models.ProfileModel; -public class DirectUser { +public class DirectUser implements Serializable { private final long pk; private final String username; private final String fullName; @@ -104,8 +107,8 @@ public class DirectUser { profileModel.isPrivate(), false, profileModel.isRequested(), - false - ), + false, + profileModel.isRestricted()), profileModel.isVerified(), false, false, @@ -113,4 +116,18 @@ public class DirectUser { null ); } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final DirectUser that = (DirectUser) o; + return pk == that.pk && + Objects.equals(username, that.username); + } + + @Override + public int hashCode() { + return Objects.hash(pk, username); + } } diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUserFriendshipStatus.java b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUserFriendshipStatus.java index 4555a2e7..10d5089d 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUserFriendshipStatus.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectUserFriendshipStatus.java @@ -1,25 +1,31 @@ package awais.instagrabber.repositories.responses.directmessages; -public class DirectUserFriendshipStatus { +import androidx.annotation.NonNull; + +import java.io.Serializable; + +public class DirectUserFriendshipStatus implements Serializable { private final boolean following; private final boolean blocking; private final boolean isPrivate; private final boolean incomingRequest; private final boolean outgoingRequest; private final boolean isBestie; + private final boolean isRestricted; public DirectUserFriendshipStatus(final boolean following, final boolean blocking, final boolean isPrivate, final boolean incomingRequest, final boolean outgoingRequest, - final boolean isBestie) { + final boolean isBestie, final boolean isRestricted) { this.following = following; this.blocking = blocking; this.isPrivate = isPrivate; this.incomingRequest = incomingRequest; this.outgoingRequest = outgoingRequest; this.isBestie = isBestie; + this.isRestricted = isRestricted; } public boolean isFollowing() { @@ -46,15 +52,21 @@ public class DirectUserFriendshipStatus { return isBestie; } + public boolean isRestricted() { + return isRestricted; + } + + @NonNull @Override public String toString() { return "DirectInboxFeedResponseFriendshipStatus{" + "following=" + following + ", blocking=" + blocking + - ", is_private=" + isPrivate + + ", isPrivate=" + isPrivate + ", incomingRequest=" + incomingRequest + ", outgoingRequest=" + outgoingRequest + ", isBestie=" + isBestie + + ", isRestricted" + isRestricted + '}'; } } diff --git a/app/src/main/java/awais/instagrabber/utils/Debouncer.java b/app/src/main/java/awais/instagrabber/utils/Debouncer.java new file mode 100644 index 00000000..063e9f46 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/Debouncer.java @@ -0,0 +1,90 @@ +package awais.instagrabber.utils; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class Debouncer { + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final ConcurrentHashMap delayedMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> futureMap = new ConcurrentHashMap<>(); + private final Callback callback; + private final int interval; + + public Debouncer(Callback c, int interval) { + this.callback = c; + this.interval = interval; + } + + public void call(T key) { + TimerTask task = new TimerTask(key); + + TimerTask prev; + do { + prev = delayedMap.putIfAbsent(key, task); + if (prev == null) { + final ScheduledFuture future = scheduler.schedule(task, interval, TimeUnit.MILLISECONDS); + futureMap.put(key, future); + } + } while (prev != null && !prev.extend()); // Exit only if new task was added to map, or existing task was extended successfully + } + + public void terminate() { + scheduler.shutdownNow(); + } + + public void cancel(final T key) { + delayedMap.remove(key); + final ScheduledFuture future = futureMap.get(key); + if (future != null) { + future.cancel(true); + } + } + + // The task that wakes up when the wait time elapses + private class TimerTask implements Runnable { + private final T key; + private long dueTime; + private final Object lock = new Object(); + + public TimerTask(T key) { + this.key = key; + extend(); + } + + public boolean extend() { + synchronized (lock) { + if (dueTime < 0) // Task has been shutdown + return false; + dueTime = System.currentTimeMillis() + interval; + return true; + } + } + + public void run() { + synchronized (lock) { + long remaining = dueTime - System.currentTimeMillis(); + if (remaining > 0) { // Re-schedule task + scheduler.schedule(this, remaining, TimeUnit.MILLISECONDS); + } else { // Mark as terminated and invoke callback + dueTime = -1; + try { + callback.call(key); + } catch (Exception e) { + callback.onError(e); + } finally { + delayedMap.remove(key); + } + } + } + } + } + + public interface Callback { + void call(T key); + + void onError(Throwable t); + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/Utils.java b/app/src/main/java/awais/instagrabber/utils/Utils.java index ddcd5d6f..528bac59 100644 --- a/app/src/main/java/awais/instagrabber/utils/Utils.java +++ b/app/src/main/java/awais/instagrabber/utils/Utils.java @@ -24,6 +24,7 @@ import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; import android.webkit.MimeTypeMap; import android.widget.Toast; @@ -340,4 +341,17 @@ public final class Utils { callback ); } + + public static void hideKeyboard(final View view) { + if (view == null) return; + final Context context = view.getContext(); + if (context == null) return; + try { + final InputMethodManager manager = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE); + if (manager == null) return; + manager.hideSoftInputFromWindow(view.getWindowToken(), 0); + } catch (Exception e) { + Log.e(TAG, "hideKeyboard: ", e); + } + } } diff --git a/app/src/main/java/awais/instagrabber/utils/ViewUtils.java b/app/src/main/java/awais/instagrabber/utils/ViewUtils.java index 43e32191..bf39d7a4 100644 --- a/app/src/main/java/awais/instagrabber/utils/ViewUtils.java +++ b/app/src/main/java/awais/instagrabber/utils/ViewUtils.java @@ -5,10 +5,14 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.RoundRectShape; +import android.view.View; import android.widget.FrameLayout; +import android.widget.TextView; import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; import androidx.core.content.res.ResourcesCompat; +import androidx.core.util.Pair; public final class ViewUtils { @@ -53,4 +57,16 @@ public final class ViewUtils { private static int getSize(float size) { return (int) (size < 0 ? size : Utils.convertDpToPx(size)); } + + public static Pair measure(@NonNull final View view, @NonNull final View parent) { + view.measure( + View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED) + ); + return new Pair<>(view.getMeasuredHeight(), view.getMeasuredWidth()); + } + + public static float getTextViewValueWidth(final TextView textView, final String text) { + return textView.getPaint().measureText(text); + } } diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.java index 0a01a5c3..d13e1257 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.java @@ -14,6 +14,7 @@ import awais.instagrabber.repositories.responses.directmessages.DirectBadgeCount import awais.instagrabber.repositories.responses.directmessages.DirectInbox; import awais.instagrabber.repositories.responses.directmessages.DirectInboxResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.TextUtils; @@ -38,6 +39,7 @@ public class DirectInboxViewModel extends ViewModel { private long seqId; private String cursor; private boolean hasOlder = true; + private DirectUser viewer; public DirectInboxViewModel() { final String cookie = settingsHelper.getString(Constants.COOKIE); @@ -76,6 +78,10 @@ public class DirectInboxViewModel extends ViewModel { return fetchingInbox; } + public DirectUser getViewer() { + return viewer; + } + public void fetchInbox() { if ((fetchingInbox.getValue() != null && fetchingInbox.getValue()) || !hasOlder) return; stopCurrentInboxRequest(); @@ -108,6 +114,9 @@ public class DirectInboxViewModel extends ViewModel { return; } seqId = response.getSeqId(); + if (viewer == null) { + viewer = response.getViewer(); + } final DirectInbox inbox = response.getInbox(); final List threads = inbox.getThreads(); if (!TextUtils.isEmpty(cursor)) { diff --git a/app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.java new file mode 100644 index 00000000..d871207c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.java @@ -0,0 +1,457 @@ +package awais.instagrabber.viewmodels; + +import android.app.Application; +import android.content.res.Resources; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.util.Pair; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.google.common.collect.ImmutableList; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option; +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.FriendshipRepoChangeRootResponse; +import awais.instagrabber.repositories.responses.FriendshipRepoRestrictRootResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectThread; +import awais.instagrabber.repositories.responses.directmessages.DirectThreadDetailsChangeResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.DirectMessagesService; +import awais.instagrabber.webservices.FriendshipService; +import awais.instagrabber.webservices.ServiceCallback; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class DirectSettingsViewModel extends AndroidViewModel { + private static final String TAG = DirectSettingsViewModel.class.getSimpleName(); + private static final String ACTION_KICK = "kick"; + private static final String ACTION_MAKE_ADMIN = "make_admin"; + private static final String ACTION_REMOVE_ADMIN = "remove_admin"; + private static final String ACTION_BLOCK = "block"; + private static final String ACTION_UNBLOCK = "unblock"; + // private static final String ACTION_REPORT = "report"; + private static final String ACTION_RESTRICT = "restrict"; + private static final String ACTION_UNRESTRICT = "unrestrict"; + + private final MutableLiveData, List>> users = new MutableLiveData<>( + new Pair<>(Collections.emptyList(), Collections.emptyList())); + private final MutableLiveData title = new MutableLiveData<>(""); + private final MutableLiveData> adminUserIds = new MutableLiveData<>(Collections.emptyList()); + private final DirectMessagesService directMessagesService; + + private DirectThread thread; + private final String userId; + private boolean viewerIsAdmin; + private final Resources resources; + private final FriendshipService friendshipService; + private final String csrfToken; + private DirectUser viewer; + + public DirectSettingsViewModel(final Application application) { + super(application); + final String cookie = settingsHelper.getString(Constants.COOKIE); + userId = CookieUtils.getUserIdFromCookie(cookie); + final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); + csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); + if (TextUtils.isEmpty(csrfToken) || TextUtils.isEmpty(userId) || TextUtils.isEmpty(deviceUuid)) { + throw new IllegalArgumentException("User is not logged in!"); + } + directMessagesService = DirectMessagesService.getInstance(csrfToken, userId, deviceUuid); + friendshipService = FriendshipService.getInstance(); + resources = getApplication().getResources(); + } + + @NonNull + public DirectThread getThread() { + return thread; + } + + public void setThread(@NonNull final DirectThread thread) { + this.thread = thread; + List users = thread.getUsers(); + if (viewer != null) { + final ImmutableList.Builder builder = ImmutableList.builder().add(viewer); + if (users != null) { + builder.addAll(users); + } + users = builder.build(); + } + this.users.postValue(new Pair<>(users, thread.getLeftUsers())); + setTitle(thread.getThreadTitle()); + final List adminUserIds = thread.getAdminUserIds(); + this.adminUserIds.postValue(adminUserIds); + viewerIsAdmin = adminUserIds.contains(Long.parseLong(userId)); + } + + public boolean isGroup() { + if (thread != null) { + return thread.isGroup(); + } + return false; + } + + public LiveData, List>> getUsers() { + return users; + } + + public LiveData getTitle() { + return title; + } + + public void setTitle(final String title) { + if (title == null) { + this.title.postValue(""); + return; + } + this.title.postValue(title.trim()); + } + + public LiveData> getAdminUserIds() { + return adminUserIds; + } + + public LiveData> updateTitle(final String newTitle) { + final MutableLiveData> data = new MutableLiveData<>(); + final Call addUsersRequest = directMessagesService + .updateTitle(thread.getThreadId(), newTitle.trim()); + handleDetailsChangeRequest(data, addUsersRequest); + return data; + } + + public LiveData> addMembers(final Set users) { + final MutableLiveData> data = new MutableLiveData<>(); + final Call addUsersRequest = directMessagesService + .addUsers(thread.getThreadId(), users.stream().map(DirectUser::getPk).collect(Collectors.toList())); + handleDetailsChangeRequest(data, addUsersRequest); + return data; + } + + public LiveData> removeMember(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + final Call request = directMessagesService + .removeUsers(thread.getThreadId(), Collections.singleton(user.getPk())); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + handleAdminChangeResponseError(response, data); + return; + } + Pair, List> usersValue = users.getValue(); + if (usersValue == null) { + usersValue = new Pair<>(Collections.emptyList(), Collections.emptyList()); + } + List activeUsers = usersValue.first; + if (activeUsers == null) { + activeUsers = Collections.emptyList(); + } + final List updatedActiveUsers = activeUsers.stream() + .filter(user1 -> user1.getPk() != user.getPk()) + .collect(Collectors.toList()); + List leftUsers = usersValue.second; + if (leftUsers == null) { + leftUsers = Collections.emptyList(); + } + final ImmutableList updateLeftUsers = ImmutableList.builder() + .addAll(leftUsers) + .add(user) + .build(); + users.postValue(new Pair<>(updatedActiveUsers, updateLeftUsers)); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private LiveData> makeAdmin(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + if (isAdmin(user)) return data; + final Call request = directMessagesService.addAdmins(thread.getThreadId(), Collections.singleton(user.getPk())); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + handleAdminChangeResponseError(response, data); + return; + } + final List currentAdmins = adminUserIds.getValue(); + adminUserIds.postValue(ImmutableList.builder() + .addAll(currentAdmins != null ? currentAdmins : Collections.emptyList()) + .add(user.getPk()) + .build()); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private LiveData> removeAdmin(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + if (!isAdmin(user)) return data; + final Call request = directMessagesService.removeAdmins(thread.getThreadId(), Collections.singleton(user.getPk())); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + handleAdminChangeResponseError(response, data); + return; + } + final List currentAdmins = adminUserIds.getValue(); + if (currentAdmins == null) return; + adminUserIds.postValue(currentAdmins.stream() + .filter(userId1 -> userId1 != user.getPk()) + .collect(Collectors.toList())); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private void handleAdminChangeResponseError(@NonNull final Response response, + final MutableLiveData> data) { + final ResponseBody errorBody = response.errorBody(); + if (errorBody == null) { + handleErrorResponse(response, data); + return; + } + try { + final JSONObject json = new JSONObject(errorBody.string()); + if (json.has("message")) { + data.postValue(Resource.error(json.getString("message"), null)); + } + } catch (IOException | JSONException e) { + Log.e(TAG, "onResponse: ", e); + data.postValue(Resource.error(e.getMessage(), null)); + } + } + + private LiveData> blockUser(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + friendshipService.block(userId, String.valueOf(user.getPk()), csrfToken, new ServiceCallback() { + @Override + public void onSuccess(final FriendshipRepoChangeRootResponse result) { + // refresh thread + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private LiveData> unblockUser(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + friendshipService.unblock(userId, String.valueOf(user.getPk()), csrfToken, new ServiceCallback() { + @Override + public void onSuccess(final FriendshipRepoChangeRootResponse result) { + // refresh thread + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private LiveData> restrictUser(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + friendshipService.toggleRestrict(String.valueOf(user.getPk()), true, csrfToken, new ServiceCallback() { + @Override + public void onSuccess(final FriendshipRepoRestrictRootResponse result) { + // refresh thread + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private LiveData> unRestrictUser(final DirectUser user) { + final MutableLiveData> data = new MutableLiveData<>(); + friendshipService.toggleRestrict(String.valueOf(user.getPk()), false, csrfToken, new ServiceCallback() { + @Override + public void onSuccess(final FriendshipRepoRestrictRootResponse result) { + // refresh thread + } + + @Override + public void onFailure(final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + return data; + } + + private void handleDetailsChangeRequest(final MutableLiveData> data, + final Call addUsersRequest) { + addUsersRequest.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, + @NonNull final Response response) { + if (!response.isSuccessful()) { + handleErrorResponse(response, data); + return; + } + final DirectThreadDetailsChangeResponse addUserResponse = response.body(); + if (addUserResponse == null) { + data.postValue(Resource.error("Response is null", null)); + return; + } + data.postValue(Resource.success(new Object())); + final DirectThread thread = addUserResponse.getThread(); + if (thread != null) { + setThread(thread); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + data.postValue(Resource.error(t.getMessage(), null)); + } + }); + } + + private void handleErrorResponse(@NonNull final Response response, + final MutableLiveData> data) { + final ResponseBody errorBody = response.errorBody(); + if (errorBody == null) { + data.postValue(Resource.error("Request failed!", null)); + return; + } + try { + data.postValue(Resource.error(errorBody.string(), null)); + } catch (IOException e) { + Log.e(TAG, "onResponse: ", e); + data.postValue(Resource.error(e.getMessage(), null)); + } + } + + public ArrayList> createUserOptions(final DirectUser user) { + final ArrayList> options = new ArrayList<>(); + if (user == null || isSelf(user) || hasLeft(user)) { + return options; + } + if (viewerIsAdmin) { + options.add(new Option<>(getString(R.string.dms_action_kick), ACTION_KICK)); + + final boolean isAdmin = isAdmin(user); + options.add(new Option<>( + isAdmin ? getString(R.string.dms_action_remove_admin) : getString(R.string.dms_action_make_admin), + isAdmin ? ACTION_REMOVE_ADMIN : ACTION_MAKE_ADMIN + )); + } + + final boolean blocking = user.getFriendshipStatus().isBlocking(); + options.add(new Option<>( + blocking ? getString(R.string.unblock) : getString(R.string.block), + blocking ? ACTION_UNBLOCK : ACTION_BLOCK + )); + + // options.add(new Option<>(getString(R.string.report), ACTION_REPORT)); + + if (!isGroup()) { + final boolean restricted = user.getFriendshipStatus().isRestricted(); + options.add(new Option<>( + restricted ? getString(R.string.unrestrict) : getString(R.string.restrict), + restricted ? ACTION_UNRESTRICT : ACTION_RESTRICT + )); + } + return options; + } + + private boolean hasLeft(final DirectUser user) { + final Pair, List> users = this.users.getValue(); + if (users == null || users.second == null) return false; + return users.second.contains(user); + } + + private boolean isAdmin(final DirectUser user) { + final List adminUserIdsValue = adminUserIds.getValue(); + return adminUserIdsValue != null && adminUserIdsValue.contains(user.getPk()); + } + + private boolean isSelf(final DirectUser user) { + return user.getPk() == Long.parseLong(userId); + } + + private String getString(@StringRes final int resId) { + return resources.getString(resId); + } + + public LiveData> doAction(final DirectUser user, final String action) { + if (user == null || action == null) return null; + switch (action) { + case ACTION_KICK: + return removeMember(user); + case ACTION_MAKE_ADMIN: + return makeAdmin(user); + case ACTION_REMOVE_ADMIN: + return removeAdmin(user); + case ACTION_BLOCK: + return blockUser(user); + case ACTION_UNBLOCK: + return unblockUser(user); + // case ACTION_REPORT: + // break; + case ACTION_RESTRICT: + return restrictUser(user); + case ACTION_UNRESTRICT: + return unRestrictUser(user); + default: + return null; + } + } + + public void setViewer(final DirectUser viewer) { + this.viewer = viewer; + } +} diff --git a/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java new file mode 100644 index 00000000..ecc4b0a4 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java @@ -0,0 +1,155 @@ +package awais.instagrabber.viewmodels; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import awais.instagrabber.models.Resource; +import awais.instagrabber.repositories.responses.UserSearchResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectUser; +import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.webservices.UserService; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class UserSearchViewModel extends ViewModel { + private static final String TAG = UserSearchViewModel.class.getSimpleName(); + public static final String DEBOUNCE_KEY = "search"; + + private String prevQuery; + private String currentQuery; + private Call searchRequest; + + private final MutableLiveData>> users = new MutableLiveData<>(); + private final MutableLiveData showAction = new MutableLiveData<>(false); + private final Debouncer searchDebouncer; + private final Set selectedUsers = new HashSet<>(); + private final UserService userService; + private long[] hideUserIds; + + public UserSearchViewModel() { + userService = UserService.getInstance(); + final Debouncer.Callback searchCallback = new Debouncer.Callback() { + @Override + public void call(final String key) { + if (userService == null || (currentQuery != null && currentQuery.equalsIgnoreCase(prevQuery))) return; + searchRequest = userService.search(currentQuery); + handleRequest(searchRequest); + prevQuery = currentQuery; + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "onError: ", t); + } + }; + searchDebouncer = new Debouncer<>(searchCallback, 1000); + } + + public LiveData>> getUsers() { + return users; + } + + public void search(final String query) { + currentQuery = query; + users.postValue(Resource.loading(null)); + searchDebouncer.call(DEBOUNCE_KEY); + } + + public void cleanup() { + searchDebouncer.terminate(); + } + + private void handleRequest(final Call request) { + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + if (!response.isSuccessful()) { + handleErrorResponse(response); + return; + } + final UserSearchResponse userSearchResponse = response.body(); + if (userSearchResponse == null) return; + handleResponse(userSearchResponse); + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + + } + }); + } + + private void handleResponse(final UserSearchResponse userSearchResponse) { + users.postValue(Resource.success(userSearchResponse + .getUsers() + .stream() + .filter(directUser -> Arrays.binarySearch(hideUserIds, directUser.getPk()) < 0) + .collect(Collectors.toList()) + )); + } + + private void handleErrorResponse(final Response response) { + final ResponseBody errorBody = response.errorBody(); + if (errorBody == null) { + users.postValue(Resource.error("Request failed!", Collections.emptyList())); + return; + } + String errorString; + try { + errorString = errorBody.string(); + Log.e(TAG, "handleErrorResponse: " + errorString); + } catch (IOException e) { + Log.e(TAG, "handleErrorResponse: ", e); + errorString = e.getMessage(); + } + users.postValue(Resource.error(errorString, Collections.emptyList())); + } + + public void setSelectedUser(final DirectUser user, final boolean selected) { + if (selected) { + selectedUsers.add(user); + } else { + selectedUsers.remove(user); + } + showAction.postValue(!selectedUsers.isEmpty()); + } + + public Set getSelectedUsers() { + return selectedUsers; + } + + public void clearResults() { + users.postValue(Resource.success(Collections.emptyList())); + prevQuery = ""; + } + + public void cancelSearch() { + searchDebouncer.cancel(DEBOUNCE_KEY); + if (searchRequest != null) { + searchRequest.cancel(); + searchRequest = null; + } + } + + public LiveData showAction() { + return showAction; + } + + public void setHideUserIds(final long[] hideUserIds) { + this.hideUserIds = hideUserIds; + } +} diff --git a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java index 9a67f8ed..802f328d 100644 --- a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java +++ b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.java @@ -7,6 +7,7 @@ import com.google.common.collect.ImmutableMap; import org.json.JSONArray; import java.io.UnsupportedEncodingException; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -25,6 +26,7 @@ import awais.instagrabber.repositories.requests.directmessages.VoiceBroadcastOpt import awais.instagrabber.repositories.responses.directmessages.DirectBadgeCount; import awais.instagrabber.repositories.responses.directmessages.DirectInboxResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadBroadcastResponse; +import awais.instagrabber.repositories.responses.directmessages.DirectThreadDetailsChangeResponse; import awais.instagrabber.repositories.responses.directmessages.DirectThreadFeedResponse; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; @@ -175,4 +177,54 @@ public class DirectMessagesService extends BaseService { final Map signedForm = Utils.sign(form); return repository.broadcast(broadcastOptions.getItemType().getValue(), signedForm); } + + public Call addUsers(final String threadId, + final Collection userIds) { + final ImmutableMap form = ImmutableMap.of( + "_csrftoken", csrfToken, + "_uuid", deviceUuid, + "user_ids", new JSONArray(userIds).toString() + ); + return repository.addUsers(threadId, form); + } + + public Call removeUsers(final String threadId, + final Collection userIds) { + final ImmutableMap form = ImmutableMap.of( + "_csrftoken", csrfToken, + "_uuid", deviceUuid, + "user_ids", new JSONArray(userIds).toString() + ); + return repository.removeUsers(threadId, form); + } + + public Call updateTitle(final String threadId, + final String title) { + final ImmutableMap form = ImmutableMap.of( + "_csrftoken", csrfToken, + "_uuid", deviceUuid, + "title", title + ); + return repository.updateTitle(threadId, form); + } + + public Call addAdmins(final String threadId, + final Collection userIds) { + final ImmutableMap form = ImmutableMap.of( + "_csrftoken", csrfToken, + "_uuid", deviceUuid, + "user_ids", new JSONArray(userIds).toString() + ); + return repository.addAdmins(threadId, form); + } + + public Call removeAdmins(final String threadId, + final Collection userIds) { + final ImmutableMap form = ImmutableMap.of( + "_csrftoken", csrfToken, + "_uuid", deviceUuid, + "user_ids", new JSONArray(userIds).toString() + ); + return repository.removeAdmins(threadId, form); + } } diff --git a/app/src/main/java/awais/instagrabber/webservices/ProfileService.java b/app/src/main/java/awais/instagrabber/webservices/ProfileService.java index 495cd06c..62eafe74 100644 --- a/app/src/main/java/awais/instagrabber/webservices/ProfileService.java +++ b/app/src/main/java/awais/instagrabber/webservices/ProfileService.java @@ -17,8 +17,6 @@ import java.util.List; import awais.instagrabber.models.FeedModel; import awais.instagrabber.repositories.ProfileRepository; import awais.instagrabber.repositories.responses.PostsFetchResponse; -import awais.instagrabber.repositories.responses.UserInfo; -import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; import retrofit2.Call; @@ -47,39 +45,6 @@ public class ProfileService extends BaseService { return instance; } - public void getUserInfo(final String uid, final ServiceCallback callback) { - final Call request = repository.getUserInfo(uid); - request.enqueue(new Callback() { - @Override - public void onResponse(@NonNull final Call call, @NonNull final Response response) { - final String body = response.body(); - if (body == null) return; - try { - final JSONObject jsonObject = new JSONObject(body); - final JSONObject user = jsonObject.optJSONObject(Constants.EXTRAS_USER); - if (user == null) return; - // Log.d(TAG, "user: " + user.toString()); - final UserInfo userInfo = new UserInfo( - uid, - user.getString(Constants.EXTRAS_USERNAME), - user.optString("full_name"), - user.optString("profile_pic_url"), - user.has("hd_profile_pic_url_info") - ? user.getJSONObject("hd_profile_pic_url_info").optString("url") : null - ); - callback.onSuccess(userInfo); - } catch (JSONException e) { - Log.e(TAG, "Error parsing json", e); - } - } - - @Override - public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { - callback.onFailure(t); - } - }); - } - public void fetchPosts(final String userId, final String maxId, final ServiceCallback callback) { diff --git a/app/src/main/java/awais/instagrabber/webservices/StoriesService.java b/app/src/main/java/awais/instagrabber/webservices/StoriesService.java index 7ea3b5c9..948e7107 100644 --- a/app/src/main/java/awais/instagrabber/webservices/StoriesService.java +++ b/app/src/main/java/awais/instagrabber/webservices/StoriesService.java @@ -160,8 +160,8 @@ public class StoriesService extends BaseService { highlightNode.getString("title"), highlightNode.getString(Constants.EXTRAS_ID), highlightNode.getJSONObject("cover_media") - .getJSONObject("cropped_image_version") - .getString("url"), + .getJSONObject("cropped_image_version") + .getString("url"), highlightNode.getLong("latest_reel_media"), highlightNode.getInt("media_count") )); @@ -188,7 +188,7 @@ public class StoriesService extends BaseService { form.put("include_suggested_highlights", "false"); form.put("is_in_archive_home", "true"); form.put("include_cover", "1"); - form.put("timezone_offset", String.valueOf(TimeZone.getDefault().getRawOffset() / 1000)); + form.put("timezone_offset", String.valueOf((float) TimeZone.getDefault().getRawOffset() / 1000)); if (!TextUtils.isEmpty(maxId)) { form.put("max_id", maxId); // NOT TESTED } @@ -338,40 +338,40 @@ public class StoriesService extends BaseService { // RespondAction.java public void respondToQuestion(final String storyId, - final String stickerId, - final String answer, - final String userId, - final String csrfToken, - final ServiceCallback callback) { + final String stickerId, + final String answer, + final String userId, + final String csrfToken, + final ServiceCallback callback) { respondToSticker(storyId, stickerId, "story_question_response", "response", answer, userId, csrfToken, callback); } // QuizAction.java public void respondToQuiz(final String storyId, - final String stickerId, - final int answer, - final String userId, - final String csrfToken, - final ServiceCallback callback) { + final String stickerId, + final int answer, + final String userId, + final String csrfToken, + final ServiceCallback callback) { respondToSticker(storyId, stickerId, "story_quiz_answer", "answer", String.valueOf(answer), userId, csrfToken, callback); } // VoteAction.java public void respondToPoll(final String storyId, - final String stickerId, - final int answer, - final String userId, - final String csrfToken, - final ServiceCallback callback) { + final String stickerId, + final int answer, + final String userId, + final String csrfToken, + final ServiceCallback callback) { respondToSticker(storyId, stickerId, "story_poll_vote", "vote", String.valueOf(answer), userId, csrfToken, callback); } public void respondToSlider(final String storyId, - final String stickerId, - final double answer, - final String userId, - final String csrfToken, - final ServiceCallback callback) { + final String stickerId, + final double answer, + final String userId, + final String csrfToken, + final ServiceCallback callback) { respondToSticker(storyId, stickerId, "story_slider_vote", "vote", String.valueOf(answer), userId, csrfToken, callback); } diff --git a/app/src/main/java/awais/instagrabber/webservices/UserService.java b/app/src/main/java/awais/instagrabber/webservices/UserService.java new file mode 100644 index 00000000..b61c6960 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/UserService.java @@ -0,0 +1,79 @@ +package awais.instagrabber.webservices; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.TimeZone; + +import awais.instagrabber.repositories.UserRepository; +import awais.instagrabber.repositories.responses.UserInfo; +import awais.instagrabber.repositories.responses.UserSearchResponse; +import awais.instagrabber.utils.Constants; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; + +public class UserService extends BaseService { + private static final String TAG = UserService.class.getSimpleName(); + + private final UserRepository repository; + + private static UserService instance; + + private UserService() { + final Retrofit retrofit = getRetrofitBuilder() + .baseUrl("https://i.instagram.com") + .build(); + repository = retrofit.create(UserRepository.class); + } + + public static UserService getInstance() { + if (instance == null) { + instance = new UserService(); + } + return instance; + } + + public void getUserInfo(final String uid, final ServiceCallback callback) { + final Call request = repository.getUserInfo(uid); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, @NonNull final Response response) { + final String body = response.body(); + if (body == null) return; + try { + final JSONObject jsonObject = new JSONObject(body); + final JSONObject user = jsonObject.optJSONObject(Constants.EXTRAS_USER); + if (user == null) return; + // Log.d(TAG, "user: " + user.toString()); + final UserInfo userInfo = new UserInfo( + uid, + user.getString(Constants.EXTRAS_USERNAME), + user.optString("full_name"), + user.optString("profile_pic_url"), + user.has("hd_profile_pic_url_info") + ? user.getJSONObject("hd_profile_pic_url_info").optString("url") : null + ); + callback.onSuccess(userInfo); + } catch (JSONException e) { + Log.e(TAG, "Error parsing json", e); + } + } + + @Override + public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { + callback.onFailure(t); + } + }); + } + + public Call search(final String query) { + final float timezoneOffset = (float) TimeZone.getDefault().getRawOffset() / 1000; + return repository.search(timezoneOffset, query); + } +} diff --git a/app/src/main/res/color/ic_circle_check_tint.xml b/app/src/main/res/color/ic_circle_check_tint.xml new file mode 100644 index 00000000..66f6c7e7 --- /dev/null +++ b/app/src/main/res/color/ic_circle_check_tint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_user_search_input.xml b/app/src/main/res/drawable/bg_user_search_input.xml new file mode 100644 index 00000000..bc48eb1e --- /dev/null +++ b/app/src/main/res/drawable/bg_user_search_input.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_check.xml b/app/src/main/res/drawable/ic_circle_check.xml new file mode 100644 index 00000000..f0708c71 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_radio_button_unchecked_24.xml b/app/src/main/res/drawable/ic_radio_button_unchecked_24.xml new file mode 100644 index 00000000..bcb6fc9c --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_button_unchecked_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_check_circle_24.xml b/app/src/main/res/drawable/ic_round_check_circle_24.xml new file mode 100644 index 00000000..1f3ee6e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_check_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_direct_messages_settings.xml b/app/src/main/res/layout/fragment_direct_messages_settings.xml index 7cd9699a..9026c445 100644 --- a/app/src/main/res/layout/fragment_direct_messages_settings.xml +++ b/app/src/main/res/layout/fragment_direct_messages_settings.xml @@ -1,94 +1,154 @@ - + android:focusableInTouchMode="true"> - - - - - - - - + android:background="@null" + android:elevation="0dp" + app:elevation="0dp"> - - - - + + - - + + + + + + + + - + + + + + + + + - - - - \ No newline at end of file + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:paddingStart="16dp" + android:paddingTop="8dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp" + android:text="@string/dms_action_leave" + android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" + android:textColor="@color/red_600" + app:layout_constraintBottom_toTopOf="@id/add_members" + app:layout_constraintTop_toBottomOf="@id/mute_mentions" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_user_search.xml b/app/src/main/res/layout/fragment_user_search.xml new file mode 100644 index 00000000..2fecdb54 --- /dev/null +++ b/app/src/main/res/layout/fragment_user_search.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_follow.xml b/app/src/main/res/layout/item_follow.xml index 9ab8ba18..ab2d0bd9 100755 --- a/app/src/main/res/layout/item_follow.xml +++ b/app/src/main/res/layout/item_follow.xml @@ -17,7 +17,6 @@ android:layout_width="match_parent" android:layout_height="60dp" android:layout_marginStart="66dp" - android:layout_marginLeft="66dp" android:layout_marginEnd="26dp" android:orientation="vertical"> diff --git a/app/src/main/res/layout/layout_dm_user_item.xml b/app/src/main/res/layout/layout_dm_user_item.xml new file mode 100644 index 00000000..43b35af8 --- /dev/null +++ b/app/src/main/res/layout/layout_dm_user_item.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/direct_messages_nav_graph.xml b/app/src/main/res/navigation/direct_messages_nav_graph.xml index e39eda0a..55fd2515 100644 --- a/app/src/main/res/navigation/direct_messages_nav_graph.xml +++ b/app/src/main/res/navigation/direct_messages_nav_graph.xml @@ -73,14 +73,38 @@ - - + + + + + + + + + + + + + + - + + + android:label="Edit Photo" + tools:layout="@layout/fragment_image_edit"> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2abf4f3..91dbdd46 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -176,7 +176,7 @@ Screenshotted Cannot deliver Great success! - Leave + Leave chat Leave this chat? Kick Left users @@ -378,4 +378,16 @@ %s stories Storage permission not granted! + Details + Title + Members + Admin + Inviter + Mute messages + Mute mentions + Add members + Search + Done + Make Admin + Remove as Admin