diff --git a/app/src/main/java/eu/faircode/email/AdapterKeyword.java b/app/src/main/java/eu/faircode/email/AdapterKeyword.java new file mode 100644 index 0000000000..31a3e44f9f --- /dev/null +++ b/app/src/main/java/eu/faircode/email/AdapterKeyword.java @@ -0,0 +1,308 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2020 by Marcel Bokhorst (M66B) +*/ + +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.CompoundButton; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; + +import com.flask.colorpicker.ColorPickerView; +import com.flask.colorpicker.builder.ColorPickerClickListener; +import com.flask.colorpicker.builder.ColorPickerDialogBuilder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AdapterKeyword extends RecyclerView.Adapter { + private Context context; + private LifecycleOwner owner; + private LayoutInflater inflater; + private boolean pro; + + private long id; + private List all = new ArrayList<>(); + + public class ViewHolder extends RecyclerView.ViewHolder implements CompoundButton.OnCheckedChangeListener, View.OnClickListener { + private View view; + private CheckBox cbKeyword; + private ViewButtonColor btnColor; + + ViewHolder(View itemView) { + super(itemView); + + view = itemView.findViewById(R.id.clItem); + cbKeyword = itemView.findViewById(R.id.cbKeyword); + btnColor = itemView.findViewById(R.id.btnColor); + } + + private void wire() { + cbKeyword.setOnCheckedChangeListener(this); + btnColor.setOnClickListener(this); + } + + private void unwire() { + cbKeyword.setOnCheckedChangeListener(null); + btnColor.setOnClickListener(null); + } + + private void bindTo(TupleKeyword keyword) { + cbKeyword.setText(keyword.name); + cbKeyword.setChecked(keyword.selected); + cbKeyword.setEnabled(pro); + btnColor.setColor(keyword.color); + btnColor.setEnabled(pro); + } + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + int pos = getAdapterPosition(); + if (pos == RecyclerView.NO_POSITION) + return; + + TupleKeyword keyword = all.get(pos); + keyword.selected = isChecked; + + Bundle args = new Bundle(); + args.putLong("id", id); + args.putString("keyword", keyword.name); + args.putBoolean("selected", keyword.selected); + + new SimpleTask() { + @Override + protected Void onExecute(Context context, Bundle args) { + long id = args.getLong("id"); + String keyword = args.getString("keyword"); + boolean selected = args.getBoolean("selected"); + + DB db = DB.getInstance(context); + + EntityMessage message = db.message().getMessage(id); + if (message == null) + return null; + + List keywords = new ArrayList<>(Arrays.asList(message.keywords)); + if (selected) + keywords.add(keyword); + else + keywords.remove(keyword); + + db.message().setMessageKeywords(message.id, TextUtils.join(" ", keywords)); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.e(ex); + } + }.execute(context, owner, args, "keyword:set"); + } + + @Override + public void onClick(View view) { + int pos = getAdapterPosition(); + if (pos == RecyclerView.NO_POSITION) + return; + + final TupleKeyword keyword = all.get(pos); + + ColorPickerDialogBuilder builder = ColorPickerDialogBuilder + .with(context) + .setTitle(context.getString(R.string.title_color)) + .showColorEdit(true) + .wheelType(ColorPickerView.WHEEL_TYPE.FLOWER) + .density(6) + .lightnessSliderOnly() + .setNegativeButton(R.string.title_reset, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + update(keyword, null); + } + }) + .setPositiveButton(android.R.string.ok, new ColorPickerClickListener() { + @Override + public void onClick(DialogInterface dialog, int selectedColor, Integer[] allColors) { + update(keyword, selectedColor); + } + }); + + if (keyword.color != null) + builder.initialColor(keyword.color); + + builder.build().show(); + } + + private void update(TupleKeyword keyword, Integer color) { + btnColor.setColor(color); + keyword.color = color; + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (color == null) + prefs.edit().remove("keyword." + keyword.name).apply(); + else + prefs.edit().putInt("keyword." + keyword.name, keyword.color).apply(); + + Bundle args = new Bundle(); + args.putLong("id", id); + + new SimpleTask() { + @Override + protected Void onExecute(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + + EntityMessage message = db.message().getMessage(id); + if (message == null) + return null; + + // Update keyword colors + try { + db.beginTransaction(); + + db.message().setMessageKeywords(message.id, ""); + db.message().setMessageKeywords(message.id, TextUtils.join(" ", message.keywords)); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.e(ex); + } + }.execute(context, owner, args, "keyword:set"); + } + } + + AdapterKeyword(Context context, LifecycleOwner owner) { + this.context = context; + this.owner = owner; + this.inflater = LayoutInflater.from(context); + this.pro = ActivityBilling.isPro(context); + + setHasStableIds(false); + } + + public void set(long id, @NonNull List keywords) { + Log.i("Set id=" + id + " keywords=" + keywords.size()); + + DiffUtil.DiffResult diff = DiffUtil.calculateDiff(new DiffCallback(all, keywords), false); + + this.id = id; + this.all = keywords; + + diff.dispatchUpdatesTo(new ListUpdateCallback() { + @Override + public void onInserted(int position, int count) { + Log.d("Inserted @" + position + " #" + count); + } + + @Override + public void onRemoved(int position, int count) { + Log.d("Removed @" + position + " #" + count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + Log.d("Moved " + fromPosition + ">" + toPosition); + } + + @Override + public void onChanged(int position, int count, Object payload) { + Log.d("Changed @" + position + " #" + count); + } + }); + diff.dispatchUpdatesTo(this); + } + + + private class DiffCallback extends DiffUtil.Callback { + private List prev = new ArrayList<>(); + private List next = new ArrayList<>(); + + DiffCallback(List prev, List next) { + this.prev.addAll(prev); + this.next.addAll(next); + } + + @Override + public int getOldListSize() { + return prev.size(); + } + + @Override + public int getNewListSize() { + return next.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + TupleKeyword k1 = prev.get(oldItemPosition); + TupleKeyword k2 = next.get(newItemPosition); + return k1.name.equals(k2.name); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + TupleKeyword k1 = prev.get(oldItemPosition); + TupleKeyword k2 = next.get(newItemPosition); + return k1.equals(k2); + } + } + + @Override + public int getItemCount() { + return all.size(); + } + + @Override + @NonNull + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(inflater.inflate(R.layout.item_keyword, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.unwire(); + TupleKeyword contact = all.get(position); + holder.bindTo(contact); + holder.wire(); + } +} diff --git a/app/src/main/java/eu/faircode/email/AdapterMessage.java b/app/src/main/java/eu/faircode/email/AdapterMessage.java index 27863ecaab..df5da34418 100644 --- a/app/src/main/java/eu/faircode/email/AdapterMessage.java +++ b/app/src/main/java/eu/faircode/email/AdapterMessage.java @@ -125,6 +125,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager; import com.github.chrisbanes.photoview.PhotoView; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomnavigation.LabelVisibilityMode; +import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; import org.jsoup.nodes.Document; @@ -876,29 +877,31 @@ public class AdapterMessage extends RecyclerView.Adapter 0) - keywords.append(", "); - keywords.append(keyword); - - String key = "keyword." + keyword; - if (prefs.contains(key)) { - int len = keywords.length(); - int color = prefs.getInt(key, textColorSecondary); - keywords.setSpan( - new ForegroundColorSpan(color), - len - keyword.length(), len, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (keywords_header) { + SpannableStringBuilder keywords = new SpannableStringBuilder(); + for (int i = 0; i < message.keywords.length; i++) { + String k = message.keywords[i].toLowerCase(); + if (IMAP_KEYWORDS_WHITELIST.contains(k) || + !(k.startsWith("$") || IMAP_KEYWORDS_BLACKLIST.contains(k))) { + if (keywords.length() > 0) + keywords.append(", "); + + keywords.append(message.keywords[i]); + + if (message.keyword_colors[i] != null) { + int len = keywords.length(); + keywords.setSpan( + new ForegroundColorSpan(message.keyword_colors[i]), + len - message.keywords[i].length(), len, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } } } - } - tvKeywords.setVisibility(keywords_header && keywords.length() > 0 ? View.VISIBLE : View.GONE); - tvKeywords.setText(keywords); + tvKeywords.setVisibility(keywords.length() > 0 ? View.VISIBLE : View.GONE); + tvKeywords.setText(keywords); + } else + tvKeywords.setVisibility(View.GONE); // Line 3 int icon = (message.drafts > 0 @@ -3584,38 +3587,10 @@ public class AdapterMessage extends RecyclerView.Adapter() { - @Override - protected EntityFolder onExecute(Context context, Bundle args) { - long id = args.getLong("id"); - - DB db = DB.getInstance(context); - EntityMessage message = db.message().getMessage(id); - if (message == null) - return null; - - return db.folder().getFolder(message.folder); - } - - @Override - protected void onExecuted(final Bundle args, EntityFolder folder) { - if (folder == null) - return; - args.putStringArray("fkeywords", folder.keywords); - - FragmentKeywordManage fragment = new FragmentKeywordManage(); - fragment.setArguments(args); - fragment.show(parentFragment.getParentFragmentManager(), "keyword:manage"); - } - - @Override - protected void onException(Bundle args, Throwable ex) { - Log.unexpectedError(parentFragment.getParentFragmentManager(), ex); - } - }.execute(context, owner, args, "message:keywords"); + FragmentDialogKeywordManage fragment = new FragmentDialogKeywordManage(); + fragment.setArguments(args); + fragment.show(parentFragment.getParentFragmentManager(), "keyword:manage"); } private void onMenuShare(TupleMessageEx message) { @@ -4163,6 +4138,11 @@ public class AdapterMessage extends RecyclerView.Adapter list) { keyPosition.clear(); + for (int i = 0; i < list.size(); i++) { + TupleMessageEx message = list.get(i); + if (message != null) + message.resolveKeywordColors(context); + } differ.submitList(list); } @@ -4516,6 +4496,10 @@ public class AdapterMessage extends RecyclerView.Adapter keywords = Arrays.asList(getArguments().getStringArray("keywords")); - List fkeywords = Arrays.asList(getArguments().getStringArray("fkeywords")); - final List items = new ArrayList<>(keywords); - for (String keyword : fkeywords) - if (!items.contains(keyword)) - items.add(keyword); + final View dview = LayoutInflater.from(getContext()).inflate(R.layout.dialog_keyword_manage, null); + final RecyclerView rvKeyword = dview.findViewById(R.id.rvKeyword); + final TextView tvPro = dview.findViewById(R.id.tvPro); + final FloatingActionButton fabAdd = dview.findViewById(R.id.fabAdd); + final ContentLoadingProgressBar pbWait = dview.findViewById(R.id.pbWait); - Collections.sort(items); + rvKeyword.setHasFixedSize(false); + final LinearLayoutManager llm = new LinearLayoutManager(getContext()); + rvKeyword.setLayoutManager(llm); - final boolean[] selected = new boolean[items.size()]; - final boolean[] dirty = new boolean[items.size()]; - for (int i = 0; i < selected.length; i++) { - selected[i] = keywords.contains(items.get(i)); - dirty[i] = false; - } + final AdapterKeyword adapter = new AdapterKeyword(getContext(), getViewLifecycleOwner()); + rvKeyword.setAdapter(adapter); - return new AlertDialog.Builder(getContext()) - .setTitle(R.string.title_manage_keywords) - .setMultiChoiceItems(items.toArray(new String[0]), selected, new DialogInterface.OnMultiChoiceClickListener() { - @Override - public void onClick(DialogInterface dialog, int which, boolean isChecked) { - dirty[which] = true; - } - }) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - if (!ActivityBilling.isPro(getContext())) { - startActivity(new Intent(getContext(), ActivityBilling.class)); - return; - } + Helper.linkPro(tvPro); - Bundle args = new Bundle(); - args.putLong("id", id); - args.putStringArray("keywords", items.toArray(new String[0])); - args.putBooleanArray("selected", selected); - args.putBooleanArray("dirty", dirty); - - new SimpleTask() { - @Override - protected Void onExecute(Context context, Bundle args) { - long id = args.getLong("id"); - String[] keywords = args.getStringArray("keywords"); - boolean[] selected = args.getBooleanArray("selected"); - boolean[] dirty = args.getBooleanArray("dirty"); - - DB db = DB.getInstance(context); - try { - db.beginTransaction(); - - EntityMessage message = db.message().getMessage(id); - if (message == null) - return null; - - for (int i = 0; i < selected.length; i++) - if (dirty[i]) - EntityOperation.queue(context, message, EntityOperation.KEYWORD, keywords[i], selected[i]); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } + fabAdd.setEnabled(ActivityBilling.isPro(getContext())); + fabAdd.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Bundle args = new Bundle(); + args.putLong("id", id); - ServiceSynchronize.eval(context, "keywords"); + FragmentDialogKeywordAdd fragment = new FragmentDialogKeywordAdd(); + fragment.setArguments(args); + fragment.show(getParentFragmentManager(), "keyword:add"); + } + }); - return null; - } + pbWait.setVisibility(View.VISIBLE); - @Override - protected void onException(Bundle args, Throwable ex) { - Log.unexpectedError(getParentFragmentManager(), ex); - } - }.execute(getContext(), getActivity(), args, "message:keywords:manage"); - } - }) - .setNeutralButton(R.string.title_add, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - Bundle args = new Bundle(); - args.putLong("id", id); + DB db = DB.getInstance(getContext()); + db.message().liveMessageKeywords(id).observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(TupleKeyword.Persisted data) { + pbWait.setVisibility(View.GONE); + adapter.set(id, TupleKeyword.from(getContext(), data)); + } + }); - FragmentKeywordAdd fragment = new FragmentKeywordAdd(); - fragment.setArguments(args); - fragment.show(getParentFragmentManager(), "keyword:add"); - } - }) + return new AlertDialog.Builder(getContext()) + .setTitle(R.string.title_manage_keywords) + .setView(dview) .setNegativeButton(android.R.string.cancel, null) .create(); } } - public static class FragmentKeywordAdd extends FragmentDialogBase { + public static class FragmentDialogKeywordAdd extends FragmentDialogBase { @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { final long id = getArguments().getLong("id"); - View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_keyword, null); + View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_keyword_add, null); final EditText etKeyword = view.findViewById(R.id.etKeyword); etKeyword.setText(null); @@ -5045,11 +4985,6 @@ public class AdapterMessage extends RecyclerView.Adapter liveMessage(long id); + @Query("SELECT message.keywords AS selected, folder.keywords AS available" + + " FROM message" + + " JOIN folder ON folder.id = message.folder" + + " WHERE message.id = :id") + LiveData liveMessageKeywords(long id); + @Transaction @Query("SELECT account.id AS account, COUNT(message.id) AS unseen, SUM(ABS(notifying)) AS notifying" + " FROM message" + diff --git a/app/src/main/java/eu/faircode/email/TupleKeyword.java b/app/src/main/java/eu/faircode/email/TupleKeyword.java new file mode 100644 index 0000000000..d077547731 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/TupleKeyword.java @@ -0,0 +1,86 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2020 by Marcel Bokhorst (M66B) +*/ + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class TupleKeyword { + public String name; + public boolean selected; + public Integer color; + + @Override + public boolean equals(@Nullable Object obj) { + if (obj instanceof TupleKeyword) { + TupleKeyword other = (TupleKeyword) obj; + return (this.name.equals(other.name) && + this.selected == other.selected && + Objects.equals(this.color, other.color)); + } else + return false; + } + + public static class Persisted { + public String[] selected; + public String[] available; + } + + static List from(Context context, Persisted data) { + List result = new ArrayList<>(); + + List keywords = new ArrayList<>(); + + for (String keyword : data.selected) + if (!keywords.contains(keyword)) + keywords.add(keyword); + + for (String keyword : data.available) + if (!keywords.contains(keyword)) + keywords.add(keyword); + + Collections.sort(keywords); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + for (String keyword : keywords) { + TupleKeyword k = new TupleKeyword(); + k.name = keyword; + k.selected = Arrays.asList(data.selected).contains(keyword); + + String c = "keyword." + keyword; + if (prefs.contains(c)) + k.color = prefs.getInt(c, -1); + + result.add(k); + } + + return result; + } +} diff --git a/app/src/main/java/eu/faircode/email/TupleMessageEx.java b/app/src/main/java/eu/faircode/email/TupleMessageEx.java index 3d057e0b78..30a8a82035 100644 --- a/app/src/main/java/eu/faircode/email/TupleMessageEx.java +++ b/app/src/main/java/eu/faircode/email/TupleMessageEx.java @@ -20,9 +20,14 @@ package eu.faircode.email; */ import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import androidx.preference.PreferenceManager; import androidx.room.Ignore; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import javax.mail.Address; @@ -53,12 +58,31 @@ public class TupleMessageEx extends EntityMessage { @Ignore boolean duplicate; + @Ignore + public Integer[] keyword_colors; + String getFolderName(Context context) { return (folderDisplay == null ? Helper.localizeFolderName(context, folderName) : folderDisplay); } + void resolveKeywordColors(Context context) { + List color = new ArrayList<>(); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + for (int i = 0; i < this.keywords.length; i++) { + String key = "keyword." + this.keywords[i]; + if (prefs.contains(key)) + color.add(prefs.getInt(key, Color.GRAY)); + else + color.add(null); + } + + this.keyword_colors = color.toArray(new Integer[0]); + } + + @Override public boolean equals(Object obj) { if (obj instanceof TupleMessageEx) { diff --git a/app/src/main/res/layout/dialog_keyword.xml b/app/src/main/res/layout/dialog_keyword_add.xml similarity index 92% rename from app/src/main/res/layout/dialog_keyword.xml rename to app/src/main/res/layout/dialog_keyword_add.xml index 6bef3efe52..b8619808fa 100644 --- a/app/src/main/res/layout/dialog_keyword.xml +++ b/app/src/main/res/layout/dialog_keyword_add.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_marginTop="24dp" android:imeOptions="actionDone" - android:inputType="text" + android:inputType="textCapSentences" android:text="Keyword" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/dialog_keyword_manage.xml b/app/src/main/res/layout/dialog_keyword_manage.xml new file mode 100644 index 0000000000..be75572cbf --- /dev/null +++ b/app/src/main/res/layout/dialog_keyword_manage.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyword.xml b/app/src/main/res/layout/item_keyword.xml new file mode 100644 index 0000000000..21c61b0c02 --- /dev/null +++ b/app/src/main/res/layout/item_keyword.xml @@ -0,0 +1,38 @@ + + + + + + + + + +