diff --git a/app/src/main/java/eu/faircode/email/ActivityView.java b/app/src/main/java/eu/faircode/email/ActivityView.java index 3c98a160..bf6fee01 100644 --- a/app/src/main/java/eu/faircode/email/ActivityView.java +++ b/app/src/main/java/eu/faircode/email/ActivityView.java @@ -82,23 +82,24 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB private ListView drawerList; private ActionBarDrawerToggle drawerToggle; - private boolean newMessages = false; private long attachment = -1; private static final int ATTACHMENT_BUFFER_SIZE = 8192; // bytes - static final int REQUEST_SERVICE = 1; - static final int REQUEST_UNSEEN = 2; + static final int REQUEST_UNIFIED = 1; + static final int REQUEST_THREAD = 2; static final int REQUEST_ERROR = 3; static final int REQUEST_ATTACHMENT = 1; static final int REQUEST_INVITE = 2; static final String ACTION_VIEW_MESSAGES = BuildConfig.APPLICATION_ID + ".VIEW_MESSAGES"; - static final String ACTION_VIEW_MESSAGE = BuildConfig.APPLICATION_ID + ".VIEW_MESSAGE"; + static final String ACTION_VIEW_THREAD = BuildConfig.APPLICATION_ID + ".VIEW_THREAD"; + static final String ACTION_VIEW_FULL = BuildConfig.APPLICATION_ID + ".VIEW_FULL"; static final String ACTION_EDIT_FOLDER = BuildConfig.APPLICATION_ID + ".EDIT_FOLDER"; static final String ACTION_EDIT_ANSWER = BuildConfig.APPLICATION_ID + ".EDIT_ANSWER"; static final String ACTION_STORE_ATTACHMENT = BuildConfig.APPLICATION_ID + ".STORE_ATTACHMENT"; + static final String ACTION_SHOW_PRO = BuildConfig.APPLICATION_ID + ".SHOW_PRO"; static final String UPDATE_LATEST_API = "https://api.github.com/repos/M66B/open-source-email/releases/latest"; static final long UPDATE_INTERVAL = 12 * 3600 * 1000L; // milliseconds @@ -286,19 +287,13 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this); IntentFilter iff = new IntentFilter(); iff.addAction(ACTION_VIEW_MESSAGES); - iff.addAction(ACTION_VIEW_MESSAGE); + iff.addAction(ACTION_VIEW_THREAD); + iff.addAction(ACTION_VIEW_FULL); iff.addAction(ACTION_EDIT_FOLDER); iff.addAction(ACTION_EDIT_ANSWER); iff.addAction(ACTION_STORE_ATTACHMENT); + iff.addAction(ACTION_SHOW_PRO); lbm.registerReceiver(receiver, iff); - - if (newMessages) { - newMessages = false; - FragmentManager fm = getSupportFragmentManager(); - fm.popBackStackImmediate("unified", 0); - FragmentMessages fragment = (FragmentMessages) fm.findFragmentById(R.id.content_frame); - fragment.onNewMessages(); - } } @Override @@ -463,12 +458,13 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB } private void checkIntent(Intent intent) { - Log.i(Helper.TAG, "View intent=" + intent + " action=" + intent.getAction()); String action = intent.getAction(); - if ("notification".equals(action)) { + Log.i(Helper.TAG, "View intent=" + intent + " action=" + action); + if (action != null && action.startsWith("thread")) { intent.setAction(null); setIntent(intent); - newMessages = true; + intent.putExtra("id", Long.parseLong(action.split(":")[1])); + onViewThread(intent); } } @@ -739,14 +735,18 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB public void onReceive(Context context, Intent intent) { if (ACTION_VIEW_MESSAGES.equals(intent.getAction())) onViewMessages(intent); - else if (ACTION_VIEW_MESSAGE.equals(intent.getAction())) - onViewMessage(intent); + else if (ACTION_VIEW_THREAD.equals(intent.getAction())) + onViewThread(intent); + else if (ACTION_VIEW_FULL.equals(intent.getAction())) + onViewFull(intent); else if (ACTION_EDIT_FOLDER.equals(intent.getAction())) onEditFolder(intent); else if (ACTION_EDIT_ANSWER.equals(intent.getAction())) onEditAnswer(intent); else if (ACTION_STORE_ATTACHMENT.equals(intent.getAction())) onStoreAttachment(intent); + else if (ACTION_SHOW_PRO.equals(intent.getAction())) + onShowPro(intent); } }; @@ -758,65 +758,25 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB fragmentTransaction.commit(); } - private void onViewMessage(Intent intent) { - new SimpleTask() { - @Override - protected Void onLoad(Context context, Bundle args) { - TupleMessageEx message = (TupleMessageEx) args.getSerializable("message"); - - DB db = DB.getInstance(context); - try { - db.beginTransaction(); - - if (!EntityFolder.OUTBOX.equals(message.folderType)) { - if (!message.content) - EntityOperation.queue(db, message, EntityOperation.BODY); - - if (!message.threaded) { - db.message().setMessageUiSeen(message.id, true); - EntityOperation.queue(db, message, EntityOperation.SEEN, true); - } - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } + private void onViewThread(Intent intent) { + Bundle args = new Bundle(); + args.putLong("thread", intent.getLongExtra("id", -1)); - EntityOperation.process(context); + FragmentMessages fragment = new FragmentMessages(); + fragment.setArguments(args); - return null; - } + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.replace(R.id.content_frame, fragment).addToBackStack("thread"); + fragmentTransaction.commit(); + } - @Override - protected void onLoaded(Bundle args, Void result) { - TupleMessageEx message = (TupleMessageEx) args.getSerializable("message"); - - if (message.threaded) { - Bundle targs = new Bundle(); - targs.putLong("thread", message.id); - - FragmentMessages fragment = new FragmentMessages(); - fragment.setArguments(targs); - - FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); - fragmentTransaction.replace(R.id.content_frame, fragment).addToBackStack("thread"); - fragmentTransaction.commit(); - - } else { - FragmentMessage fragment = new FragmentMessage(); - fragment.setArguments(args); - FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); - fragmentTransaction.replace(R.id.content_frame, fragment).addToBackStack("message"); - fragmentTransaction.commit(); - } - } + private void onViewFull(Intent intent) { + FragmentWebView fragment = new FragmentWebView(); + fragment.setArguments(intent.getExtras()); - @Override - protected void onException(Bundle args, Throwable ex) { - Helper.unexpectedError(ActivityView.this, ex); - } - }.load(ActivityView.this, intent.getExtras()); + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.replace(R.id.content_frame, fragment).addToBackStack("webview"); + fragmentTransaction.commit(); } private void onEditFolder(Intent intent) { @@ -844,6 +804,12 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB startActivityForResult(create, REQUEST_ATTACHMENT); } + private void onShowPro(Intent intent) { + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.replace(R.id.content_frame, new FragmentPro()).addToBackStack("pro"); + fragmentTransaction.commit(); + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { Log.i(Helper.TAG, "View onActivityResult request=" + requestCode + " result=" + resultCode + " data=" + data); diff --git a/app/src/main/java/eu/faircode/email/AdapterMessage.java b/app/src/main/java/eu/faircode/email/AdapterMessage.java index e8d05510..cc0590b9 100644 --- a/app/src/main/java/eu/faircode/email/AdapterMessage.java +++ b/app/src/main/java/eu/faircode/email/AdapterMessage.java @@ -26,39 +26,77 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; import android.provider.ContactsContract; +import android.text.Editable; +import android.text.Html; +import android.text.Layout; +import android.text.Spannable; +import android.text.Spanned; +import android.text.SpannedString; +import android.text.TextUtils; import android.text.format.DateUtils; +import android.text.method.LinkMovementMethod; +import android.text.style.ImageSpan; +import android.text.style.URLSpan; +import android.util.Log; +import android.util.LongSparseArray; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; +import android.widget.Toast; +import com.google.android.material.bottomnavigation.BottomNavigationView; + +import org.xml.sax.XMLReader; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; import java.io.InputStream; +import java.net.URL; +import java.text.Collator; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.Locale; + +import javax.mail.Address; +import javax.mail.internet.InternetAddress; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.widget.PopupMenu; +import androidx.constraintlayout.widget.Group; import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.Observer; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.paging.PagedListAdapter; import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; public class AdapterMessage extends PagedListAdapter { @@ -69,42 +107,71 @@ public class AdapterMessage extends PagedListAdapter expanded = new LongSparseArray<>(); + private LongSparseArray headers = new LongSparseArray<>(); + private LongSparseArray images = new LongSparseArray<>(); + private DateFormat df = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.LONG); enum ViewType {UNIFIED, FOLDER, THREAD, SEARCH} - public class ViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener, View.OnLongClickListener { - View itemView; - View vwColor; - ImageView ivAvatar; - ImageView ivFlagged; - TextView tvFrom; - TextView tvSize; - TextView tvTime; - ImageView ivAttachments; - TextView tvSubject; - TextView tvFolder; - TextView tvCount; - ImageView ivThread; - TextView tvError; - ProgressBar pbLoading; - - private static final int action_flag = 1; - private static final int action_seen = 2; - private static final int action_delete = 3; + private static final long CACHE_IMAGE_DURATION = 3 * 24 * 3600 * 1000L; + + public class ViewHolder extends RecyclerView.ViewHolder implements + View.OnClickListener, BottomNavigationView.OnNavigationItemSelectedListener { + private View itemView; + private View vwColor; + private ImageView ivExpander; + private ImageView ivFlagged; + private ImageView ivAvatar; + private TextView tvFrom; + private ImageView ivAddContact; + private TextView tvSize; + private TextView tvTime; + private TextView tvTimeEx; + private ImageView ivAttachments; + private TextView tvSubject; + private TextView tvFolder; + private TextView tvCount; + private ImageView ivThread; + private TextView tvError; + private ProgressBar pbLoading; + + private TextView tvFromEx; + private TextView tvTo; + private TextView tvReplyTo; + private TextView tvCc; + private TextView tvBcc; + private TextView tvSubjectEx; + + private TextView tvHeaders; + private ProgressBar pbHeaders; + + private BottomNavigationView bnvActions; + private Button btnImages; + private TextView tvBody; + private ProgressBar pbBody; + + private RecyclerView rvAttachment; + private AdapterAttachment adapter; + + private Group grpHeaders; + private Group grpAttachments; + private Group grpExpanded; ViewHolder(View itemView) { super(itemView); this.itemView = itemView.findViewById(R.id.clItem); vwColor = itemView.findViewById(R.id.vwColor); + ivExpander = itemView.findViewById(R.id.ivExpander); ivFlagged = itemView.findViewById(R.id.ivFlagged); ivAvatar = itemView.findViewById(R.id.ivAvatar); tvFrom = itemView.findViewById(R.id.tvFrom); + ivAddContact = itemView.findViewById(R.id.ivAddContact); tvSize = itemView.findViewById(R.id.tvSize); tvTime = itemView.findViewById(R.id.tvTime); + tvTimeEx = itemView.findViewById(R.id.tvTimeEx); ivAttachments = itemView.findViewById(R.id.ivAttachments); tvSubject = itemView.findViewById(R.id.tvSubject); tvFolder = itemView.findViewById(R.id.tvFolder); @@ -112,23 +179,60 @@ public class AdapterMessage extends PagedListAdapter>() { @Override @@ -220,14 +331,13 @@ public class AdapterMessage extends PagedListAdapter 0 ? Typeface.BOLD : Typeface.NORMAL); tvFrom.setTypeface(null, typeface); tvTime.setTypeface(null, typeface); @@ -238,6 +348,105 @@ public class AdapterMessage extends PagedListAdapter>() { + @Override + public void onChanged(@Nullable List folders) { + boolean hasTrash = false; + boolean hasArchive = false; + boolean hasUser = false; + + if (folders != null) + for (EntityFolder folder : folders) { + if (EntityFolder.TRASH.equals(folder.type)) + hasTrash = true; + else if (EntityFolder.ARCHIVE.equals(folder.type)) + hasArchive = true; + else if (EntityFolder.USER.equals(folder.type)) + hasUser = true; + } + + boolean inInbox = EntityFolder.INBOX.equals(message.folderType); + boolean inOutbox = EntityFolder.OUTBOX.equals(message.folderType); + boolean inArchive = EntityFolder.ARCHIVE.equals(message.folderType); + boolean inTrash = EntityFolder.TRASH.equals(message.folderType); + + ActionData data = new ActionData(); + data.delete = (inTrash || !hasTrash || inOutbox); + data.message = message; + bnvActions.setTag(data); + + bnvActions.getMenu().findItem(R.id.action_delete).setVisible((message.uid != null && hasTrash) || (inOutbox && !TextUtils.isEmpty(message.error))); + bnvActions.getMenu().findItem(R.id.action_move).setVisible(message.uid != null && (!inInbox || hasUser)); + bnvActions.getMenu().findItem(R.id.action_archive).setVisible(message.uid != null && !inArchive && hasArchive); + bnvActions.getMenu().findItem(R.id.action_reply).setVisible(message.content && !inOutbox); + + bnvActions.setVisibility(View.VISIBLE); + } + }); + + // Observe attachments + db.attachment().liveAttachments(message.id).observe(owner, + new Observer>() { + @Override + public void onChanged(@Nullable List attachments) { + if (attachments == null) + attachments = new ArrayList<>(); + + adapter.set(attachments); + grpAttachments.setVisibility(attachments.size() > 0 ? View.VISIBLE : View.GONE); + + if (message.content) { + Bundle args = new Bundle(); + args.putSerializable("message", message); + bodyTask.load(context, owner, args); + } + } + }); + } } @Override @@ -245,130 +454,848 @@ public class AdapterMessage extends PagedListAdapter() { + @Override + protected Void onLoad(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + EntityMessage message = db.message().getMessage(id); + EntityFolder folder = db.folder().getFolder(message.folder); + + if (!EntityFolder.OUTBOX.equals(folder.type)) { + if (!message.content) + EntityOperation.queue(db, message, EntityOperation.BODY); + + db.message().setMessageUiSeen(message.id, true); + EntityOperation.queue(db, message, EntityOperation.SEEN, true); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EntityOperation.process(context); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); } } - @Override - public boolean onLongClick(View view) { - int pos = getAdapterPosition(); - if (pos == RecyclerView.NO_POSITION) - return false; + private SimpleTask bodyTask = new SimpleTask() { + @Override + protected Spanned onLoad(final Context context, final Bundle args) throws Throwable { + TupleMessageEx message = (TupleMessageEx) args.getSerializable("message"); + String body = message.read(context); + return decodeHtml(message, body); + } + + @Override + protected void onLoaded(Bundle args, Spanned body) { + TupleMessageEx message = (TupleMessageEx) args.getSerializable("message"); - final TupleMessageEx message = getItem(pos); + SpannedString ss = new SpannedString(body); + boolean has_images = (ss.getSpans(0, ss.length(), ImageSpan.class).length > 0); + boolean show_expanded = (expanded.get(message.id) != null && expanded.get(message.id)); + boolean show_images = (images.get(message.id) != null && images.get(message.id)); - PopupMenu popupMenu = new PopupMenu(context, itemView); - if (!message.threaded && !EntityFolder.OUTBOX.equals(message.folderType)) { - popupMenu.getMenu().add(Menu.NONE, action_flag, 1, message.ui_flagged ? R.string.title_unflag : R.string.title_flag); - popupMenu.getMenu().add(Menu.NONE, action_seen, 2, message.ui_seen ? R.string.title_unseen : R.string.title_seen); + btnImages.setVisibility(has_images && show_expanded && !show_images ? View.VISIBLE : View.GONE); + pbBody.setVisibility(View.GONE); + tvBody.setText(body); } - if (EntityFolder.TRASH.equals(message.folderType)) - popupMenu.getMenu().add(Menu.NONE, action_delete, 3, R.string.title_delete); - popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }; + + private Spanned decodeHtml(final EntityMessage message, String body) { + return Html.fromHtml(HtmlHelper.sanitize(body), new Html.ImageGetter() { @Override - public boolean onMenuItemClick(MenuItem target) { - Bundle args = new Bundle(); - args.putLong("id", message.id); - args.putInt("action", target.getItemId()); + public Drawable getDrawable(String source) { + float scale = context.getResources().getDisplayMetrics().density; + int px = (int) (24 * scale + 0.5f); + + if (source != null && source.startsWith("cid")) { + String cid = "<" + source.split(":")[1] + ">"; + EntityAttachment attachment = DB.getInstance(context).attachment().getAttachment(message.id, cid); + if (attachment == null || !attachment.available) { + Drawable d = context.getResources().getDrawable(R.drawable.baseline_warning_24, context.getTheme()); + d.setBounds(0, 0, px, px); + return d; + } else { + File file = EntityAttachment.getFile(context, attachment.id); + Drawable d = Drawable.createFromPath(file.getAbsolutePath()); + d.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight()); + return d; + } + } + + if (images.get(message.id) != null && images.get(message.id)) { + // Get cache folder + File dir = new File(context.getCacheDir(), "images"); + dir.mkdir(); + + // Cleanup cache + long now = new Date().getTime(); + File[] images = dir.listFiles(); + if (images != null) + for (File image : images) + if (image.isFile() && image.lastModified() + CACHE_IMAGE_DURATION < now) { + Log.i(Helper.TAG, "Deleting from image cache " + image.getName()); + image.delete(); + } - if (target.getItemId() == action_delete) { + InputStream is = null; + FileOutputStream os = null; + try { + if (source == null) + throw new IllegalArgumentException("Html.ImageGetter.getDrawable(source == null)"); + + // Create unique file name + File file = new File(dir, message.id + "_" + source.hashCode()); + + // Get input stream + if (file.exists()) { + Log.i(Helper.TAG, "Using cached " + file); + is = new FileInputStream(file); + } else { + Log.i(Helper.TAG, "Downloading " + source); + is = new URL(source).openStream(); + } + + // Decode image from stream + Bitmap bm = BitmapFactory.decodeStream(is); + if (bm == null) + throw new IllegalArgumentException(); + + // Cache bitmap + if (!file.exists()) { + os = new FileOutputStream(file); + bm.compress(Bitmap.CompressFormat.PNG, 100, os); + } + + // Create drawable from bitmap + Drawable d = new BitmapDrawable(context.getResources(), bm); + d.setBounds(0, 0, bm.getWidth(), bm.getHeight()); + return d; + } catch (Throwable ex) { + // Show warning icon + Log.e(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + Drawable d = context.getResources().getDrawable(R.drawable.baseline_warning_24, context.getTheme()); + d.setBounds(0, 0, px, px); + return d; + } finally { + // Close streams + if (is != null) { + try { + is.close(); + } catch (IOException e) { + Log.w(Helper.TAG, e + "\n" + Log.getStackTraceString(e)); + } + } + if (os != null) { + try { + os.close(); + } catch (IOException e) { + Log.w(Helper.TAG, e + "\n" + Log.getStackTraceString(e)); + } + } + } + } else { + // Show placeholder icon + Drawable d = context.getResources().getDrawable(R.drawable.baseline_image_24, context.getTheme()); + d.setBounds(0, 0, px, px); + return d; + } + } + }, new Html.TagHandler() { + @Override + public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) { + Log.i(Helper.TAG, "HTML tag=" + tag + " opening=" + opening); + } + }); + } + + private class UrlHandler extends LinkMovementMethod { + public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { + if (event.getAction() != MotionEvent.ACTION_UP) + return false; + + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + URLSpan[] link = buffer.getSpans(off, off, URLSpan.class); + if (link.length != 0) { + String url = link[0].getURL(); + Uri uri = Uri.parse(url); + + if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) { + Toast.makeText(context, context.getString(R.string.title_no_viewer, uri.toString()), Toast.LENGTH_LONG).show(); + return true; + } + + if (BuildConfig.APPLICATION_ID.equals(uri.getHost()) && "/activate/".equals(uri.getPath())) { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.sendBroadcast( + new Intent(ActivityView.ACTION_ACTIVATE_PRO) + .putExtra("uri", uri)); + + } else { + View view = LayoutInflater.from(context).inflate(R.layout.dialog_link, null); + final EditText etLink = view.findViewById(R.id.etLink); + etLink.setText(url); new DialogBuilderLifecycle(context, owner) - .setMessage(R.string.title_ask_delete) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + .setView(view) + .setPositiveButton(R.string.title_yes, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - Bundle args = new Bundle(); - args.putLong("id", message.id); + Uri uri = Uri.parse(etLink.getText().toString()); - new SimpleTask() { - @Override - protected Void onLoad(Context context, Bundle args) { - long id = args.getLong("id"); + if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) { + Toast.makeText(context, context.getString(R.string.title_no_viewer, uri.toString()), Toast.LENGTH_LONG).show(); + return; + } + + Helper.view(context, uri); + } + }) + .setNegativeButton(R.string.title_no, null) + .show(); + } + } - DB db = DB.getInstance(context); - try { - db.beginTransaction(); + return true; + } + } - EntityMessage message = db.message().getMessage(id); - db.message().setMessageUiHide(id, true); - EntityOperation.queue(db, message, EntityOperation.DELETE); + private class ActionData { + boolean delete; + TupleMessageEx message; + } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } + @Override + public boolean onNavigationItemSelected(@NonNull MenuItem item) { + ActionData data = (ActionData) bnvActions.getTag(); + switch (item.getItemId()) { + case R.id.action_more: + onMore(data); + return true; + case R.id.action_delete: + onDelete(data); + return true; + case R.id.action_move: + onMove(data); + return true; + case R.id.action_archive: + onArchive(data); + return true; + case R.id.action_reply: + onReply(data); + return true; + default: + return false; + } + } - EntityOperation.process(context); + private void onMore(final ActionData data) { + boolean inOutbox = EntityFolder.OUTBOX.equals(data.message.folderType); + boolean show_headers = (headers.get(data.message.id) != null && headers.get(data.message.id)); - return null; - } + View anchor = bnvActions.findViewById(R.id.action_more); + PopupMenu popupMenu = new PopupMenu(context, anchor); + popupMenu.inflate(R.menu.menu_message); + popupMenu.getMenu().findItem(R.id.menu_forward).setVisible(data.message.content && !inOutbox); + popupMenu.getMenu().findItem(R.id.menu_show_headers).setChecked(show_headers); + popupMenu.getMenu().findItem(R.id.menu_show_headers).setEnabled(data.message.uid != null); + popupMenu.getMenu().findItem(R.id.menu_show_html).setEnabled(data.message.content && Helper.classExists("android.webkit.WebView")); + popupMenu.getMenu().findItem(R.id.menu_flag).setChecked(data.message.uid != null && data.message.unflagged != 1); + popupMenu.getMenu().findItem(R.id.menu_reply_all).setVisible(data.message.content && !inOutbox); - @Override - protected void onException(Bundle args, Throwable ex) { - Helper.unexpectedError(context, ex); - } - }.load(context, owner, args); + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem target) { + switch (target.getItemId()) { + case R.id.menu_forward: + onForward(data); + return true; + case R.id.menu_reply_all: + onReplyAll(data); + return true; + case R.id.menu_show_headers: + onShowHeaders(data); + return true; + case R.id.menu_show_html: + onShowHtml(data); + return true; + case R.id.menu_flag: + onFlag(data); + return true; + case R.id.menu_unseen: + onUnseen(data); + return true; + case R.id.menu_answer: + onAnswer(data); + return true; + default: + return false; + } + } + }); + popupMenu.show(); + } + + private void onForward(final ActionData data) { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); + + new SimpleTask() { + @Override + protected Boolean onLoad(Context context, Bundle args) { + long id = args.getLong("id"); + List attachments = DB.getInstance(context).attachment().getAttachments(id); + for (EntityAttachment attachment : attachments) + if (!attachment.available) + return false; + return true; + } + + @Override + protected void onLoaded(Bundle args, Boolean available) { + final Intent forward = new Intent(context, ActivityCompose.class) + .putExtra("action", "forward") + .putExtra("reference", data.message.id); + if (available) + context.startActivity(forward); + else + new DialogBuilderLifecycle(context, owner) + .setMessage(R.string.title_attachment_unavailable) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + context.startActivity(forward); } }) .setNegativeButton(android.R.string.cancel, null) .show(); - } else - new SimpleTask() { - @Override - protected Void onLoad(final Context context, Bundle args) { - long id = args.getLong("id"); - int action = args.getInt("action"); + } - DB db = DB.getInstance(context); - try { - db.beginTransaction(); - - EntityMessage message = db.message().getMessage(id); - if (action == action_flag) { - db.message().setMessageUiFlagged(message.id, !message.ui_flagged); - EntityOperation.queue(db, message, EntityOperation.FLAG, !message.ui_flagged); - } else if (action == action_seen) { - db.message().setMessageUiSeen(message.id, !message.ui_seen); - EntityOperation.queue(db, message, EntityOperation.SEEN, !message.ui_seen); - } + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); + } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } + private void onReplyAll(ActionData data) { + context.startActivity(new Intent(context, ActivityCompose.class) + .putExtra("action", "reply_all") + .putExtra("reference", data.message.id)); + } + + private void onShowHeaders(ActionData data) { + if (headers.get(data.message.id) == null) + headers.put(data.message.id, true); + else + headers.put(data.message.id, !headers.get(data.message.id)); + + if (headers.get(data.message.id) && data.message.headers == null) { + grpHeaders.setVisibility(View.VISIBLE); + pbHeaders.setVisibility(View.VISIBLE); + + Bundle args = new Bundle(); + args.putLong("id", data.message.id); - EntityOperation.process(context); + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) { + Long id = args.getLong("id"); + DB db = DB.getInstance(context); + EntityMessage message = db.message().getMessage(id); + EntityOperation.queue(db, message, EntityOperation.HEADERS); + EntityOperation.process(context); + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); + } else + notifyDataSetChanged(); + } + + private void onShowHtml(ActionData data) { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.sendBroadcast( + new Intent(ActivityView.ACTION_VIEW_FULL) + .putExtra("id", data.message.id) + .putExtra("from", MessageHelper.getFormattedAddresses(data.message.from, true))); + } - return null; + private void onFlag(ActionData data) { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); + args.putBoolean("flagged", !data.message.ui_flagged); + Log.i(Helper.TAG, "Set message id=" + data.message.id + " flagged=" + !data.message.ui_flagged); + + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + boolean flagged = args.getBoolean("flagged"); + DB db = DB.getInstance(context); + EntityMessage message = db.message().getMessage(id); + db.message().setMessageUiFlagged(message.id, flagged); + EntityOperation.queue(db, message, EntityOperation.FLAG, flagged); + EntityOperation.process(context); + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); + } + + private void onUnseen(final ActionData data) { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); + + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + EntityMessage message = db.message().getMessage(id); + db.message().setMessageUiSeen(message.id, false); + EntityOperation.queue(db, message, EntityOperation.SEEN, true); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EntityOperation.process(context); + + return null; + } + + @Override + protected void onLoaded(Bundle args, Void data) { + expanded.clear(); + notifyDataSetChanged(); + } + }.load(context, owner, args); + } + + private void onAnswer(final ActionData data) { + final DB db = DB.getInstance(context); + db.answer().liveAnswers().observe(owner, new Observer>() { + @Override + public void onChanged(List answers) { + final Collator collator = Collator.getInstance(Locale.getDefault()); + collator.setStrength(Collator.SECONDARY); // Case insensitive, process accents etc + + Collections.sort(answers, new Comparator() { + @Override + public int compare(EntityAnswer a1, EntityAnswer a2) { + return collator.compare(a1.name, a2.name); + } + }); + + PopupMenu popupMenu = new PopupMenu(context, itemView); + + int order = 0; + for (EntityAnswer answer : answers) + popupMenu.getMenu().add(Menu.NONE, answer.id.intValue(), order++, answer.name); + + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem target) { + if (Helper.isPro(context)) + context.startActivity(new Intent(context, ActivityCompose.class) + .putExtra("action", "reply") + .putExtra("reference", data.message.id) + .putExtra("answer", (long) target.getItemId())); + else { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.sendBroadcast(new Intent(ActivityView.ACTION_SHOW_PRO)); } + return true; + } + }); + + popupMenu.show(); + db.answer().liveAnswers().removeObservers(owner); + } + }); + } + + private void onDelete(final ActionData data) { + if (data.delete) { + // No trash or is trash + new DialogBuilderLifecycle(context, owner) + .setMessage(R.string.title_ask_delete) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { @Override - public void onException(Bundle args, Throwable ex) { - Helper.unexpectedError(context, ex); + public void onClick(DialogInterface dialog, int which) { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); + + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + EntityMessage message = db.message().getMessage(id); + if (message.uid == null && !TextUtils.isEmpty(message.error)) // outbox + db.message().deleteMessage(id); + else { + db.message().setMessageUiHide(message.id, true); + EntityOperation.queue(db, message, EntityOperation.DELETE); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EntityOperation.process(context); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); } - }.load(context, owner, args); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); - return true; + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) { + long id = args.getLong("id"); + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + db.message().setMessageUiHide(id, true); + + EntityMessage message = db.message().getMessage(id); + EntityFolder trash = db.folder().getFolderByType(message.account, EntityFolder.TRASH); + EntityOperation.queue(db, message, EntityOperation.MOVE, trash.id); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EntityOperation.process(context); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); + } + } + + private void onMove(ActionData data) { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); + + new SimpleTask>() { + @Override + protected List onLoad(Context context, Bundle args) { + EntityMessage message; + List folders; + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + message = db.message().getMessage(args.getLong("id")); + folders = db.folder().getUserFolders(message.account); + + for (int i = 0; i < folders.size(); i++) + if (folders.get(i).id.equals(message.folder)) { + folders.remove(i); + break; + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + final Collator collator = Collator.getInstance(Locale.getDefault()); + collator.setStrength(Collator.SECONDARY); // Case insensitive, process accents etc + + Collections.sort(folders, new Comparator() { + @Override + public int compare(EntityFolder f1, EntityFolder f2) { + return collator.compare(f1.name, f2.name); + } + }); + + EntityFolder junk = db.folder().getFolderByType(message.account, EntityFolder.JUNK); + if (junk != null && !message.folder.equals(junk.id)) + folders.add(0, junk); + + EntityFolder sent = db.folder().getFolderByType(message.account, EntityFolder.SENT); + if (sent != null && !message.folder.equals(sent.id)) + folders.add(0, sent); + + EntityFolder inbox = db.folder().getFolderByType(message.account, EntityFolder.INBOX); + if (!message.folder.equals(inbox.id)) + folders.add(0, inbox); + + return folders; } - }); - if (popupMenu.getMenu().hasVisibleItems()) - popupMenu.show(); + @Override + protected void onLoaded(final Bundle args, List folders) { + View anchor = bnvActions.findViewById(R.id.action_move); + PopupMenu popupMenu = new PopupMenu(context, anchor); + + int order = 0; + for (EntityFolder folder : folders) { + String name = (folder.display == null + ? Helper.localizeFolderName(context, folder.name) + : folder.display); + popupMenu.getMenu().add(Menu.NONE, folder.id.intValue(), order++, name); + } + + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(final MenuItem target) { + args.putLong("target", target.getItemId()); + + new SimpleTask() { + @Override + protected Boolean onLoad(Context context, Bundle args) { + long id = args.getLong("id"); + long target = args.getLong("target"); + + boolean close; + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + EntityMessage message = db.message().getMessage(id); + EntityFolder folder = db.folder().getFolder(message.folder); + + close = EntityFolder.ARCHIVE.equals(folder.type); + if (!close) + db.message().setMessageUiHide(message.id, true); + + EntityOperation.queue(db, message, EntityOperation.MOVE, target); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EntityOperation.process(context); + + return close; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); + + return true; + } + }); + + popupMenu.show(); + } + }.load(context, owner, args); + } + + private void onArchive(ActionData data) { + Bundle args = new Bundle(); + args.putLong("id", data.message.id); + + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + db.message().setMessageUiHide(id, true); + + EntityMessage message = db.message().getMessage(id); + EntityFolder archive = db.folder().getFolderByType(message.account, EntityFolder.ARCHIVE); + EntityOperation.queue(db, message, EntityOperation.MOVE, archive.id); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + EntityOperation.process(context); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(context, ex); + } + }.load(context, owner, args); + } - return true; + private void onReply(ActionData data) { + context.startActivity(new Intent(context, ActivityCompose.class) + .putExtra("action", "reply") + .putExtra("reference", data.message.id)); } } diff --git a/app/src/main/java/eu/faircode/email/DaoAccount.java b/app/src/main/java/eu/faircode/email/DaoAccount.java index ebbff044..c64f464f 100644 --- a/app/src/main/java/eu/faircode/email/DaoAccount.java +++ b/app/src/main/java/eu/faircode/email/DaoAccount.java @@ -75,9 +75,6 @@ public interface DaoAccount { @Update void updateAccount(EntityAccount account); - @Query("UPDATE account SET seen_until = :time WHERE id = :id") - int setAccountSeenUntil(long id, long time); - @Query("UPDATE account SET state = :state WHERE id = :id") int setAccountState(long id, String state); diff --git a/app/src/main/java/eu/faircode/email/DaoMessage.java b/app/src/main/java/eu/faircode/email/DaoMessage.java index c94210d0..57013a71 100644 --- a/app/src/main/java/eu/faircode/email/DaoMessage.java +++ b/app/src/main/java/eu/faircode/email/DaoMessage.java @@ -38,7 +38,6 @@ public interface DaoMessage { @Query("SELECT message.*" + ", account.name AS accountName, account.color AS accountColor" + ", folder.name AS folderName, folder.display AS folderDisplay, folder.type AS folderType" + - ", SUM(CASE WHEN folder.type = '" + EntityFolder.ARCHIVE + "' THEN 0 ELSE 1 END) > 1 AS threaded" + ", COUNT(message.id) AS count" + ", SUM(CASE WHEN message.ui_seen" + " OR folder.type = '" + EntityFolder.ARCHIVE + "'" + @@ -67,7 +66,6 @@ public interface DaoMessage { @Query("SELECT message.*" + ", account.name AS accountName, account.color AS accountColor" + ", folder.name AS folderName, folder.display AS folderDisplay, folder.type AS folderType" + - ", SUM(CASE WHEN folder.type = '" + EntityFolder.ARCHIVE + "' THEN 0 ELSE 1 END) > 1 AS threaded" + ", COUNT(message.id) AS count" + ", SUM(CASE WHEN message.ui_seen" + " OR (folder.id <> :folder AND folder.type = '" + EntityFolder.ARCHIVE + "')" + @@ -98,7 +96,6 @@ public interface DaoMessage { @Query("SELECT message.*" + ", account.name AS accountName, account.color AS accountColor" + ", folder.name AS folderName, folder.display AS folderDisplay, folder.type AS folderType" + - ", 0 AS threaded" + ", (SELECT COUNT(m1.id) FROM message m1 WHERE m1.account = message.account AND m1.thread = message.thread AND NOT m1.ui_hide) AS count" + ", CASE WHEN message.ui_seen THEN 0 ELSE 1 END AS unseen" + ", CASE WHEN message.ui_flagged THEN 0 ELSE 1 END AS unflagged" + @@ -147,7 +144,6 @@ public interface DaoMessage { @Query("SELECT message.*" + ", account.name AS accountName, account.color AS accountColor" + ", folder.name AS folderName, folder.display AS folderDisplay, folder.type AS folderType" + - ", 0 AS threaded" + ", (SELECT COUNT(m1.id) FROM message m1 WHERE m1.account = message.account AND m1.thread = message.thread AND NOT m1.ui_hide) AS count" + ", CASE WHEN message.ui_seen THEN 0 ELSE 1 END AS unseen" + ", CASE WHEN message.ui_flagged THEN 0 ELSE 1 END AS unflagged" + @@ -164,7 +160,6 @@ public interface DaoMessage { " WHERE account.`synchronize`" + " AND folder.unified" + " AND NOT message.ui_seen AND NOT message.ui_hide" + - " AND (account.seen_until IS NULL OR message.stored > account.seen_until)" + " ORDER BY message.received") LiveData> liveUnseenUnified(); diff --git a/app/src/main/java/eu/faircode/email/EntityAccount.java b/app/src/main/java/eu/faircode/email/EntityAccount.java index e1e7f7b9..9f680d08 100644 --- a/app/src/main/java/eu/faircode/email/EntityAccount.java +++ b/app/src/main/java/eu/faircode/email/EntityAccount.java @@ -57,7 +57,7 @@ public class EntityAccount { public Boolean store_sent; // obsolete @NonNull public Integer poll_interval; // keep-alive interval - public Long seen_until; + public Long seen_until; // obsolete public String state; public String error; @@ -76,7 +76,6 @@ public class EntityAccount { this.primary.equals(other.primary) && (this.color == null ? other.color == null : this.color.equals(other.color)) && this.poll_interval.equals(other.poll_interval) && - (this.seen_until == null ? other.seen_until == null : this.seen_until.equals(other.seen_until)) && (this.state == null ? other.state == null : this.state.equals(other.state)) && (this.error == null ? other.error == null : this.error.equals(other.error))); } else diff --git a/app/src/main/java/eu/faircode/email/EntityMessage.java b/app/src/main/java/eu/faircode/email/EntityMessage.java index 09f9911d..ed114a5b 100644 --- a/app/src/main/java/eu/faircode/email/EntityMessage.java +++ b/app/src/main/java/eu/faircode/email/EntityMessage.java @@ -189,6 +189,7 @@ public class EntityMessage implements Serializable { (this.uid == null ? other.uid == null : this.uid.equals(other.uid)) && (this.msgid == null ? other.msgid == null : this.msgid.equals(other.msgid)) && (this.references == null ? other.references == null : this.references.equals(other.references)) && + (this.deliveredto == null ? other.deliveredto == null : this.deliveredto.equals(other.deliveredto)) && (this.inreplyto == null ? other.inreplyto == null : this.inreplyto.equals(other.inreplyto)) && (this.thread == null ? other.thread == null : this.thread.equals(other.thread)) && (this.avatar == null ? other.avatar == null : this.avatar.equals(other.avatar)) && @@ -199,12 +200,14 @@ public class EntityMessage implements Serializable { equal(this.reply, other.reply) && (this.headers == null ? other.headers == null : this.headers.equals(other.headers)) && (this.subject == null ? other.subject == null : this.subject.equals(other.subject)) && + (this.size == null ? other.size == null : this.size.equals(other.size)) && + this.content == other.content && (this.sent == null ? other.sent == null : this.sent.equals(other.sent)) && this.received.equals(other.received) && this.stored.equals(other.stored) && this.seen.equals(other.seen) && - this.ui_seen.equals(other.ui_seen) && this.flagged.equals(other.flagged) && + this.ui_seen.equals(other.ui_seen) && this.ui_flagged.equals(other.ui_flagged) && this.ui_hide.equals(other.ui_hide) && this.ui_found.equals(other.ui_found) && diff --git a/app/src/main/java/eu/faircode/email/FragmentAccount.java b/app/src/main/java/eu/faircode/email/FragmentAccount.java index facdf702..dd3525e8 100644 --- a/app/src/main/java/eu/faircode/email/FragmentAccount.java +++ b/app/src/main/java/eu/faircode/email/FragmentAccount.java @@ -71,7 +71,6 @@ import java.text.Collator; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Properties; @@ -683,10 +682,8 @@ public class FragmentAccount extends FragmentEx { account.primary = (account.synchronize && primary); account.poll_interval = Integer.parseInt(interval); - if (!update) - account.seen_until = new Date().getTime(); - - account.store_sent = false; + account.store_sent = false; // obsolete + account.seen_until = null; // obsolete if (!synchronize) account.error = null; diff --git a/app/src/main/java/eu/faircode/email/FragmentMessage.java b/app/src/main/java/eu/faircode/email/FragmentMessage.java index 13dde9ca..d659800e 100644 --- a/app/src/main/java/eu/faircode/email/FragmentMessage.java +++ b/app/src/main/java/eu/faircode/email/FragmentMessage.java @@ -167,7 +167,7 @@ public class FragmentMessage extends FragmentEx { ivFlagged = view.findViewById(R.id.ivFlagged); ivAvatar = view.findViewById(R.id.ivAvatar); tvFrom = view.findViewById(R.id.tvFrom); - ivContactAdd = view.findViewById(R.id.ivContactAdd); + ivContactAdd = view.findViewById(R.id.ivAddContact); tvTime = view.findViewById(R.id.tvTime); tvCount = view.findViewById(R.id.tvCount); tvTo = view.findViewById(R.id.tvTo); diff --git a/app/src/main/java/eu/faircode/email/FragmentMessages.java b/app/src/main/java/eu/faircode/email/FragmentMessages.java index 6d5ed859..9de60031 100644 --- a/app/src/main/java/eu/faircode/email/FragmentMessages.java +++ b/app/src/main/java/eu/faircode/email/FragmentMessages.java @@ -42,7 +42,6 @@ import android.widget.TextView; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; -import java.util.Date; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -185,7 +184,7 @@ public class FragmentMessages extends FragmentEx { return 0; TupleMessageEx message = ((AdapterMessage) rvMessage.getAdapter()).getCurrentList().get(pos); - if (message == null || message.threaded || EntityFolder.OUTBOX.equals(message.folderType)) + if (message == null || viewType != AdapterMessage.ViewType.THREAD || EntityFolder.OUTBOX.equals(message.folderType)) return 0; return makeMovementFlags(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); @@ -403,7 +402,7 @@ public class FragmentMessages extends FragmentEx { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); grpHintSupport.setVisibility(prefs.getBoolean("app_support", false) ? View.GONE : View.VISIBLE); - grpHintActions.setVisibility(prefs.getBoolean("message_actions", false) ? View.GONE : View.VISIBLE); + grpHintActions.setVisibility(prefs.getBoolean("message_actions", false) || viewType != AdapterMessage.ViewType.THREAD ? View.GONE : View.VISIBLE); final DB db = DB.getInstance(getContext()); @@ -522,37 +521,6 @@ public class FragmentMessages extends FragmentEx { public void onResume() { super.onResume(); grpSupport.setVisibility(Helper.isPro(getContext()) ? View.GONE : View.VISIBLE); - - if (viewType == AdapterMessage.ViewType.UNIFIED) { - Bundle args = new Bundle(); - args.putLong("time", new Date().getTime()); - - new SimpleTask() { - @Override - protected Void onLoad(Context context, Bundle args) { - long time = args.getLong("time"); - - DB db = DB.getInstance(context); - try { - db.beginTransaction(); - - for (EntityAccount account : db.account().getAccounts(true)) - db.account().setAccountSeenUntil(account.id, time); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - return null; - } - - @Override - protected void onException(Bundle args, Throwable ex) { - Helper.unexpectedError(getContext(), ex); - } - }.load(this, args); - } } @Override @@ -793,8 +761,4 @@ public class FragmentMessages extends FragmentEx { }); } - - void onNewMessages() { - rvMessage.scrollToPosition(0); - } } diff --git a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java index c92b79c5..e77239c1 100644 --- a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java +++ b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java @@ -122,7 +122,6 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; import static android.os.Process.THREAD_PRIORITY_BACKGROUND; public class ServiceSynchronize extends LifecycleService { - private TupleAccountStats stats = null; private final Object lock = new Object(); private ServiceManager serviceManager = new ServiceManager(); @@ -188,7 +187,6 @@ public class ServiceSynchronize extends LifecycleService { db.account().liveStats().observe(this, new Observer() { @Override public void onChanged(@Nullable TupleAccountStats stats) { - ServiceSynchronize.this.stats = stats; NotificationManager nm = getSystemService(NotificationManager.class); nm.notify(NOTIFICATION_SYNCHRONIZE, getNotificationService(stats.accounts, stats.operations, stats.unsent).build()); @@ -241,37 +239,7 @@ public class ServiceSynchronize extends LifecycleService { serviceManager.queue_stop(); else if ("reload".equals(action)) serviceManager.queue_reload(); - else if ("until".equals(action)) { - Bundle args = new Bundle(); - args.putLong("time", new Date().getTime()); - - new SimpleTask() { - @Override - protected Void onLoad(Context context, Bundle args) { - long time = args.getLong("time"); - - DB db = DB.getInstance(context); - try { - db.beginTransaction(); - - for (EntityAccount account : db.account().getAccounts(true)) - db.account().setAccountSeenUntil(account.id, time); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - return null; - } - - @Override - protected void onLoaded(Bundle args, Void data) { - Log.i(Helper.TAG, "Updated seen until"); - } - }.load(this, args); - - } else if (action.startsWith("seen:") || action.startsWith("trash:")) { + else if (action.startsWith("seen:") || action.startsWith("trash:")) { Bundle args = new Bundle(); args.putLong("id", Long.parseLong(action.split(":")[1])); args.putString("action", action.split(":")[0]); @@ -323,7 +291,7 @@ public class ServiceSynchronize extends LifecycleService { Intent intent = new Intent(this, ActivityView.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent pi = PendingIntent.getActivity( - this, ActivityView.REQUEST_SERVICE, intent, PendingIntent.FLAG_UPDATE_CURRENT); + this, ActivityView.REQUEST_UNIFIED, intent, PendingIntent.FLAG_UPDATE_CURRENT); // Build notification Notification.Builder builder; @@ -364,15 +332,9 @@ public class ServiceSynchronize extends LifecycleService { // Build pending intent Intent view = new Intent(this, ActivityView.class); - view.setAction("notification"); view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent piView = PendingIntent.getActivity( - this, ActivityView.REQUEST_UNSEEN, view, PendingIntent.FLAG_UPDATE_CURRENT); - - Intent until = new Intent(this, ServiceSynchronize.class); - until.setAction("until"); - PendingIntent piUntil = PendingIntent.getService( - this, PI_UNSEEN, until, PendingIntent.FLAG_UPDATE_CURRENT); + this, ActivityView.REQUEST_UNIFIED, view, PendingIntent.FLAG_UPDATE_CURRENT); // Build notification Notification.Builder builder; @@ -388,10 +350,10 @@ public class ServiceSynchronize extends LifecycleService { .setContentIntent(piView) .setNumber(messages.size()) .setShowWhen(false) + .setOngoing(true) .setPriority(Notification.PRIORITY_DEFAULT) .setCategory(Notification.CATEGORY_STATUS) .setVisibility(Notification.VISIBILITY_PRIVATE) - .setDeleteIntent(piUntil) .setGroup(BuildConfig.APPLICATION_ID) .setGroupSummary(true); @@ -428,6 +390,12 @@ public class ServiceSynchronize extends LifecycleService { Bundle args = new Bundle(); args.putLong("id", message.id); + Intent thread = new Intent(this, ActivityView.class); + thread.setAction("thread:" + message.id); + thread.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent piThread = PendingIntent.getActivity( + this, ActivityView.REQUEST_THREAD, thread, PendingIntent.FLAG_UPDATE_CURRENT); + Intent seen = new Intent(this, ServiceSynchronize.class); seen.setAction("seen:" + message.id); PendingIntent piSeen = PendingIntent.getService(this, PI_SEEN, seen, PendingIntent.FLAG_UPDATE_CURRENT); @@ -456,9 +424,10 @@ public class ServiceSynchronize extends LifecycleService { .addExtras(args) .setSmallIcon(R.drawable.baseline_mail_24) .setContentTitle(MessageHelper.getFormattedAddresses(message.from, true)) - .setContentIntent(piView) + .setContentIntent(piThread) .setSound(uri) .setWhen(message.sent == null ? message.received : message.sent) + .setOngoing(true) .setPriority(Notification.PRIORITY_DEFAULT) .setCategory(Notification.CATEGORY_STATUS) .setVisibility(Notification.VISIBILITY_PRIVATE) diff --git a/app/src/main/java/eu/faircode/email/TupleMessageEx.java b/app/src/main/java/eu/faircode/email/TupleMessageEx.java index a6356d5a..abb57ce2 100644 --- a/app/src/main/java/eu/faircode/email/TupleMessageEx.java +++ b/app/src/main/java/eu/faircode/email/TupleMessageEx.java @@ -25,7 +25,6 @@ public class TupleMessageEx extends EntityMessage { public String folderName; public String folderDisplay; public String folderType; - public boolean threaded; public int count; public int unseen; public int unflagged; @@ -41,7 +40,6 @@ public class TupleMessageEx extends EntityMessage { this.folderName.equals(other.folderName) && (this.folderDisplay == null ? other.folderDisplay == null : this.folderDisplay.equals(other.folderDisplay)) && this.folderType.equals(other.folderType) && - this.threaded == other.threaded && this.count == other.count && this.unseen == other.unseen && this.unflagged == other.unflagged && diff --git a/app/src/main/res/drawable/baseline_expand_less_24.xml b/app/src/main/res/drawable/baseline_expand_less_24.xml new file mode 100644 index 00000000..0a1e170e --- /dev/null +++ b/app/src/main/res/drawable/baseline_expand_less_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_expand_more_24.xml b/app/src/main/res/drawable/baseline_expand_more_24.xml new file mode 100644 index 00000000..d6d7c3c5 --- /dev/null +++ b/app/src/main/res/drawable/baseline_expand_more_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_more_horiz_24.xml b/app/src/main/res/drawable/baseline_more_horiz_24.xml new file mode 100644 index 00000000..eba29c35 --- /dev/null +++ b/app/src/main/res/drawable/baseline_more_horiz_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_more_vert_24.xml b/app/src/main/res/drawable/baseline_more_vert_24.xml new file mode 100644 index 00000000..1a3a684f --- /dev/null +++ b/app/src/main/res/drawable/baseline_more_vert_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_message.xml b/app/src/main/res/layout/fragment_message.xml index 77a32a98..be610976 100644 --- a/app/src/main/res/layout/fragment_message.xml +++ b/app/src/main/res/layout/fragment_message.xml @@ -41,14 +41,14 @@ android:id="@+id/tvFrom" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" android:layout_marginTop="3dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:text="From" android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:textIsSelectable="true" - app:layout_constraintEnd_toStartOf="@+id/ivContactAdd" + app:layout_constraintEnd_toStartOf="@+id/ivAddContact" app:layout_constraintStart_toEndOf="@id/ivAvatar" app:layout_constraintTop_toTopOf="parent" /> @@ -56,8 +56,8 @@ android:id="@+id/ivContactAdd" android:layout_width="24dp" android:layout_height="24dp" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" + android:layout_marginEnd="6dp" android:src="@drawable/baseline_import_contacts_24" app:layout_constraintBottom_toBottomOf="@id/tvFrom" app:layout_constraintEnd_toEndOf="parent" @@ -67,9 +67,9 @@ android:id="@+id/tvTime" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" android:layout_marginTop="3dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:maxLines="1" android:text="12:34:56" @@ -114,8 +114,8 @@ android:id="@+id/tvTo" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:maxHeight="60dp" android:scrollbars="vertical" @@ -130,8 +130,8 @@ android:id="@+id/tvSubject" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:text="Subject" android:textAppearance="@style/TextAppearance.AppCompat.Medium" @@ -163,8 +163,8 @@ android:id="@+id/tvReplyTo" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:maxHeight="60dp" android:scrollbars="vertical" @@ -189,8 +189,8 @@ android:id="@+id/tvCc" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:maxHeight="60dp" android:scrollbars="vertical" @@ -215,8 +215,8 @@ android:id="@+id/tvBcc" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:maxHeight="60dp" android:scrollbars="vertical" @@ -241,9 +241,9 @@ android:id="@+id/tvRawHeaders" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" android:layout_marginTop="3dp" + android:layout_marginEnd="6dp" android:fontFamily="monospace" android:freezesText="true" @@ -279,9 +279,9 @@ android:id="@+id/rvAttachment" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" android:layout_marginTop="3dp" + android:layout_marginEnd="6dp" android:scrollbarStyle="outsideOverlay" android:scrollbars="vertical" app:layout_constrainedHeight="true" @@ -303,9 +303,9 @@ android:id="@+id/tvError" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" android:layout_marginTop="3dp" + android:layout_marginEnd="6dp" android:freezesText="true" android:text="error" android:textAppearance="@style/TextAppearance.AppCompat.Small" @@ -330,8 +330,8 @@ android:layout_height="wrap_content" android:layout_marginStart="6dp" android:layout_marginTop="3dp" - android:minHeight="0dp" android:minWidth="0dp" + android:minHeight="0dp" android:text="@string/title_show_images" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/vSeparatorBody" /> @@ -340,9 +340,9 @@ android:id="@+id/scroll" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_marginEnd="6dp" android:layout_marginStart="6dp" android:layout_marginTop="3dp" + android:layout_marginEnd="6dp" android:fillViewport="true" android:orientation="vertical" app:layout_constraintBottom_toTopOf="@+id/bottom_navigation" diff --git a/app/src/main/res/layout/item_message.xml b/app/src/main/res/layout/item_message.xml index 259a08e8..2b7817fc 100644 --- a/app/src/main/res/layout/item_message.xml +++ b/app/src/main/res/layout/item_message.xml @@ -15,7 +15,19 @@ android:layout_width="6dp" android:layout_height="0dp" android:background="@color/colorAccent" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/vSeparator" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + + app:layout_constraintEnd_toStartOf="@id/tvTime" + app:layout_constraintTop_toTopOf="@id/tvFrom" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@id/tvFrom" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +