Redesigned search

This commit is contained in:
M66B 2019-05-12 14:28:18 +02:00
parent 215a28965e
commit 3e5e3dd1bc
8 changed files with 193 additions and 85 deletions

12
FAQ.md
View File

@ -423,12 +423,14 @@ about [these vulnerabilities](https://amp.thehackernews.com/thn/2019/04/email-si
You can start searching for messages on sender, recipient, subject, keyword or message text by using the magnify glass in the action bar of a folder.
You can also search from any app by select *Search email* in the copy/paste popup menu.
First on device messages will be searched and after that the search will be executed on the server.
Searching on the server will be in the current folder
or the archive folder of the primary account if there is no current folder, for example when searching from the unified inbox.
Messages will be searched on the device first (all accounts, all folders).
There will be an action button with a cloud download icon at the bottom to search on the server.
When the search was started in a specific folder,
the same folder will be searched in on the server,
else you can select which folder to search in on the server.
Searching on the server will be triggered/continued when reaching the end of the list.
Note that it is possible that searching on the server will find messages newer than already found locally.
The IMAP protocol doesn't support searching in more than one folder at the same time.
Searching on the server is an expensive operation, therefore it is not possible to select multiple folders.
Searching local messages is case insensitive and on partial text.
The message text of local messages will not be searched if the message text was not downloaded yet.

View File

@ -501,9 +501,8 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB
setIntent(intent);
FragmentMessages.search(
ActivityView.this, ActivityView.this,
getSupportFragmentManager(),
-1, search);
ActivityView.this, ActivityView.this, getSupportFragmentManager(),
-1, false, search);
}
}
};

View File

@ -69,23 +69,25 @@ import javax.mail.search.SubjectTerm;
public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMessageEx> {
private Context context;
private Long folder;
private String searching;
private boolean server;
private String query;
private int pageSize;
private IBoundaryCallbackMessages intf;
private Handler handler;
private ExecutorService executor = Executors.newSingleThreadExecutor(Helper.backgroundThreadFactory);
private boolean destroyed = false;
private boolean error = false;
private int local_index = 0;
private int remote_index = -1;
private int index = 0;
private boolean loading = false;
private List<Long> messages = null;
private IMAPStore istore = null;
private IMAPFolder ifolder = null;
private Message[] imessages = null;
private boolean loading = false;
interface IBoundaryCallbackMessages {
void onLoading();
@ -96,12 +98,13 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
BoundaryCallbackMessages(
Context context, LifecycleOwner owner,
long folder, String searching, int pageSize,
long folder, boolean server, String query, int pageSize,
IBoundaryCallbackMessages intf) {
this.context = context.getApplicationContext();
this.folder = (folder < 0 ? null : folder);
this.searching = searching;
this.server = server;
this.query = query;
this.pageSize = pageSize;
this.intf = intf;
@ -110,6 +113,8 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
owner.getLifecycle().addObserver(new LifecycleObserver() {
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
public void onDestroyed() {
destroyed = true;
executor.submit(new Runnable() {
@Override
public void run() {
@ -128,13 +133,13 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
@Override
public void onZeroItemsLoaded() {
Log.i("onZeroItemsLoaded");
Log.i("Boundary zero loaded");
queue_load();
}
@Override
public void onItemAtEndLoaded(final TupleMessageEx itemAtEnd) {
Log.i("onItemAtEndLoaded");
Log.i("Boundary at end");
queue_load();
}
@ -145,6 +150,9 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
@Override
public void run() {
try {
if (destroyed)
return;
loading = true;
fetched = 0;
handler.post(new Runnable() {
@ -153,7 +161,10 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
intf.onLoading();
}
});
fetched = load();
if (server)
fetched = load_server();
else
fetched = load_device();
} catch (final Throwable ex) {
Log.e("Boundary", ex);
handler.post(new Runnable() {
@ -179,32 +190,28 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
return loading;
}
private int load() throws MessagingException, IOException, AuthenticatorException, OperationCanceledException {
if (error)
return 0;
private int load_device() {
DB db = DB.getInstance(context);
// Search local
int local_count = 0;
int found = 0;
try {
db.beginTransaction();
if (messages == null) {
messages = db.message().getMessageIdsByFolder(folder);
Log.i("Boundary search folder=" + folder + " messages=" + messages.size());
Log.i("Boundary device folder=" + folder + " query=" + query + " messages=" + messages.size());
}
for (int i = local_index; i < messages.size() && local_count < pageSize; i++) {
local_index = i + 1;
for (int i = index; i < messages.size() && found < pageSize && !destroyed; i++) {
index = i + 1;
EntityMessage message = db.message().getMessage(messages.get(i));
boolean match = false;
if (searching == null)
if (query == null)
match = true;
else {
String find = searching.toLowerCase();
String find = query.toLowerCase();
String body = null;
if (message.content)
try {
@ -229,36 +236,36 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
}
if (match) {
local_count++;
found++;
db.message().setMessageFound(message.account, message.thread);
}
}
db.setTransactionSuccessful();
if (local_count == pageSize)
return local_count;
if (found == pageSize)
return found;
} finally {
db.endTransaction();
}
// Search remote
long bid;
if (folder == null) {
EntityFolder archive = db.folder().getPrimaryArchive();
if (archive == null)
return local_count;
else
bid = archive.id;
} else
bid = folder;
Log.i("Boundary device done");
return found;
}
final EntityFolder browsable = db.folder().getBrowsableFolder(bid, searching != null);
private int load_server() throws MessagingException, IOException, AuthenticatorException, OperationCanceledException {
DB db = DB.getInstance(context);
if (destroyed || error)
return 0;
final EntityFolder browsable = db.folder().getBrowsableFolder(folder, query != null);
if (browsable == null)
return local_count;
return 0;
EntityAccount account = db.account().getAccount(browsable.account);
if (account == null)
return local_count;
return 0;
if (imessages == null)
try {
@ -279,16 +286,16 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
Session isession = Session.getInstance(props, null);
isession.setDebug(debug);
Log.i("Boundary connecting account=" + account.name);
Log.i("Boundary server connecting account=" + account.name);
istore = (IMAPStore) isession.getStore(protocol);
Helper.connect(context, istore, account);
Log.i("Boundary opening folder=" + browsable.name);
Log.i("Boundary server opening folder=" + browsable.name);
ifolder = (IMAPFolder) istore.getFolder(browsable.name);
ifolder.open(Folder.READ_WRITE);
Log.i("Boundary searching=" + searching);
if (searching == null)
Log.i("Boundary server query=" + query);
if (query == null)
imessages = ifolder.getMessages();
else {
Object result = ifolder.doCommand(new IMAPFolder.ProtocolCommand() {
@ -305,11 +312,11 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
try {
// https://tools.ietf.org/html/rfc3501#section-6.4.4
Argument arg = new Argument();
if (searching.startsWith("raw:") && istore.hasCapability("X-GM-EXT-1")) {
if (query.startsWith("raw:") && istore.hasCapability("X-GM-EXT-1")) {
// https://support.google.com/mail/answer/7190
// https://developers.google.com/gmail/imap/imap-extensions#extension_of_the_search_command_x-gm-raw
arg.writeAtom("X-GM-RAW");
arg.writeString(searching.substring(4));
arg.writeString(query.substring(4));
} else {
if (!protocol.supportsUtf8()) {
arg.writeAtom("CHARSET");
@ -321,20 +328,20 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
arg.writeAtom("OR");
arg.writeAtom("OR");
arg.writeAtom("FROM");
arg.writeBytes(searching.getBytes());
arg.writeBytes(query.getBytes());
arg.writeAtom("TO");
arg.writeBytes(searching.getBytes());
arg.writeBytes(query.getBytes());
arg.writeAtom("SUBJECT");
arg.writeBytes(searching.getBytes());
arg.writeBytes(query.getBytes());
arg.writeAtom("BODY");
arg.writeBytes(searching.getBytes());
arg.writeBytes(query.getBytes());
if (keywords) {
arg.writeAtom("KEYWORD");
arg.writeBytes(searching.getBytes());
arg.writeBytes(query.getBytes());
}
}
Log.i("Boundary UTF8 search=" + searching);
Log.i("Boundary UTF8 search=" + query);
Response[] responses = protocol.command("SEARCH", arg);
if (responses.length > 0 && responses[responses.length - 1].isOK()) {
List<Integer> msgnums = new ArrayList<>();
@ -353,7 +360,7 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
return imessages;
} else {
// Assume no UTF-8 support
String search = searching.replace("ß", "ss"); // Eszett
String search = query.replace("ß", "ss"); // Eszett
search = Normalizer.normalize(search, Normalizer.Form.NFD)
.replaceAll("[^\\p{ASCII}]", "");
@ -388,9 +395,9 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
imessages = (Message[]) result;
}
Log.i("Boundary found messages=" + imessages.length);
Log.i("Boundary server found messages=" + imessages.length);
remote_index = imessages.length - 1;
index = imessages.length - 1;
} catch (Throwable ex) {
error = true;
if (ex instanceof FolderClosedException)
@ -401,12 +408,12 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
}
}
int remote_count = 0;
while (remote_index >= 0 && remote_count < pageSize) {
Log.i("Boundary index=" + remote_index);
int from = Math.max(0, remote_index - (pageSize - remote_count) + 1);
Message[] isub = Arrays.copyOfRange(imessages, from, remote_index + 1);
remote_index -= (pageSize - remote_count);
int found = 0;
while (index >= 0 && found < pageSize && !destroyed) {
Log.i("Boundary server index=" + index);
int from = Math.max(0, index - (pageSize - found) + 1);
Message[] isub = Arrays.copyOfRange(imessages, from, index + 1);
index -= (pageSize - found);
FetchProfile fp = new FetchProfile();
fp.add(FetchProfile.Item.ENVELOPE);
@ -421,10 +428,10 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
try {
db.beginTransaction();
for (int j = isub.length - 1; j >= 0; j--)
for (int j = isub.length - 1; j >= 0 && found < pageSize && !destroyed; j--)
try {
long uid = ifolder.getUID(isub[j]);
Log.i("Boundary sync uid=" + uid);
Log.i("Boundary server sync uid=" + uid);
EntityMessage message = db.message().getMessageByUid(browsable.id, uid);
if (message == null) {
message = Core.synchronizeMessage(context,
@ -432,21 +439,21 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
ifolder, (IMAPMessage) isub[j],
true,
new ArrayList<EntityRule>());
remote_count++;
found++;
}
db.message().setMessageFound(message.account, message.thread);
} catch (MessageRemovedException ex) {
Log.w(browsable.name + " boundary", ex);
Log.w(browsable.name + " boundary server", ex);
} catch (FolderClosedException ex) {
throw ex;
} catch (IOException ex) {
if (ex.getCause() instanceof MessagingException) {
Log.w(browsable.name + " boundary", ex);
Log.w(browsable.name + " boundary server", ex);
db.folder().setFolderError(browsable.id, Helper.formatThrowable(ex, true));
} else
throw ex;
} catch (Throwable ex) {
Log.e(browsable.name + " boundary", ex);
Log.e(browsable.name + " boundary server", ex);
db.folder().setFolderError(browsable.id, Helper.formatThrowable(ex, true));
} finally {
((IMAPMessage) isub[j]).invalidateHeaders();
@ -458,7 +465,7 @@ public class BoundaryCallbackMessages extends PagedList.BoundaryCallback<TupleMe
}
}
Log.i("Boundary done");
return local_count + remote_count;
Log.i("Boundary server done");
return found;
}
}

View File

@ -222,6 +222,7 @@ public class FragmentAccounts extends FragmentBase {
final MenuItem menuSearch = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) menuSearch.getActionView();
searchView.setQueryHint(getString(R.string.title_search));
if (!TextUtils.isEmpty(searching)) {
menuSearch.expandActionView();
@ -239,7 +240,9 @@ public class FragmentAccounts extends FragmentBase {
public boolean onQueryTextSubmit(String query) {
searching = null;
menuSearch.collapseActionView();
FragmentMessages.search(getContext(), getViewLifecycleOwner(), getFragmentManager(), -1, query);
FragmentMessages.search(
getContext(), getViewLifecycleOwner(), getFragmentManager(),
-1, false, query);
return true;
}
});

View File

@ -120,6 +120,7 @@ public class FragmentContacts extends FragmentBase {
MenuItem menuSearch = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) menuSearch.getActionView();
searchView.setQueryHint(getString(R.string.title_search));
if (!TextUtils.isEmpty(searching)) {
menuSearch.expandActionView();

View File

@ -390,6 +390,7 @@ public class FragmentFolders extends FragmentBase {
final MenuItem menuSearch = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) menuSearch.getActionView();
searchView.setQueryHint(getString(R.string.title_search));
if (!TextUtils.isEmpty(searching)) {
menuSearch.expandActionView();
@ -407,7 +408,9 @@ public class FragmentFolders extends FragmentBase {
public boolean onQueryTextSubmit(String query) {
searching = null;
menuSearch.collapseActionView();
FragmentMessages.search(getContext(), getViewLifecycleOwner(), getFragmentManager(), -1, query);
FragmentMessages.search(
getContext(), getViewLifecycleOwner(), getFragmentManager(),
-1, false, query);
return true;
}
});

View File

@ -100,6 +100,7 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@ -131,14 +132,16 @@ public class FragmentMessages extends FragmentBase {
private Group grpReady;
private FloatingActionButton fab;
private FloatingActionButton fabMore;
private FloatingActionButton fabSearch;
private FloatingActionButton fabError;
private long account;
private long folder;
private boolean server;
private String thread;
private long id;
private boolean found;
private String search;
private String query;
private boolean pane;
private boolean date;
@ -221,15 +224,16 @@ public class FragmentMessages extends FragmentBase {
Bundle args = getArguments();
account = args.getLong("account", -1);
folder = args.getLong("folder", -1);
server = args.getBoolean("server", false);
thread = args.getString("thread");
id = args.getLong("id", -1);
found = args.getBoolean("found", false);
search = args.getString("search");
query = args.getString("query");
pane = args.getBoolean("pane", false);
primary = args.getLong("primary", -1);
connected = args.getBoolean("connected", false);
if (TextUtils.isEmpty(search))
if (TextUtils.isEmpty(query))
if (thread == null)
if (folder < 0)
viewType = AdapterMessage.ViewType.UNIFIED;
@ -286,6 +290,7 @@ public class FragmentMessages extends FragmentBase {
grpHintSelect = view.findViewById(R.id.grpHintSelect);
grpReady = view.findViewById(R.id.grpReady);
fab = view.findViewById(R.id.fab);
fabSearch = view.findViewById(R.id.fabSearch);
fabMore = view.findViewById(R.id.fabMore);
fabError = view.findViewById(R.id.fabError);
@ -569,6 +574,72 @@ public class FragmentMessages extends FragmentBase {
}
});
fabSearch.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (folder < 0) {
Bundle args = new Bundle();
new SimpleTask<Map<EntityAccount, List<EntityFolder>>>() {
@Override
protected Map<EntityAccount, List<EntityFolder>> onExecute(Context context, Bundle args) {
Map<EntityAccount, List<EntityFolder>> result = new LinkedHashMap<>();
DB db = DB.getInstance(context);
List<EntityAccount> accounts = db.account().getSynchronizingAccounts();
for (EntityAccount account : accounts) {
List<EntityFolder> folders = db.folder().getFolders(account.id);
if (folders.size() > 0)
Collections.sort(folders, folders.get(0).getComparator(context));
result.put(account, folders);
}
return result;
}
@Override
protected void onExecuted(Bundle args, Map<EntityAccount, List<EntityFolder>> result) {
PopupMenuLifecycle popupMenu = new PopupMenuLifecycle(getContext(), getViewLifecycleOwner(), fabSearch);
int order = 1;
for (EntityAccount account : result.keySet()) {
SubMenu smenu = popupMenu.getMenu()
.addSubMenu(Menu.NONE, 0, order++, account.name);
int sorder = 1;
for (EntityFolder folder : result.get(account)) {
MenuItem item = smenu.add(Menu.NONE, 1, sorder++, folder.getDisplayName(getContext()));
item.setIntent(new Intent().putExtra("target", folder.id));
}
}
popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem target) {
Intent intent = target.getIntent();
if (intent == null)
return false;
long folder = intent.getLongExtra("target", -1);
search(getContext(), getViewLifecycleOwner(), getFragmentManager(), folder, true, query);
return true;
}
});
popupMenu.show();
}
@Override
protected void onException(Bundle args, Throwable ex) {
Helper.unexpectedError(getContext(), getViewLifecycleOwner(), ex);
}
}.execute(FragmentMessages.this, args, "messages:search");
} else
search(getContext(), getViewLifecycleOwner(), getFragmentManager(), folder, true, query);
}
});
fabMore.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
@ -597,6 +668,10 @@ public class FragmentMessages extends FragmentBase {
pbWait.setVisibility(View.VISIBLE);
fab.hide();
if (viewType == AdapterMessage.ViewType.SEARCH && !server)
fabSearch.show();
else
fabSearch.hide();
fabMore.hide();
fabError.hide();
@ -1949,7 +2024,7 @@ public class FragmentMessages extends FragmentBase {
break;
case SEARCH:
setSubtitle(getString(R.string.title_searching, search));
setSubtitle(getString(R.string.title_searching, query));
break;
}
@ -1960,7 +2035,7 @@ public class FragmentMessages extends FragmentBase {
else
fabMore.hide();
if (viewType != AdapterMessage.ViewType.THREAD) {
if (viewType != AdapterMessage.ViewType.THREAD && viewType != AdapterMessage.ViewType.SEARCH) {
db.identity().liveComposableIdentities(account < 0 ? null : account).observe(getViewLifecycleOwner(),
new Observer<List<TupleIdentityEx>>() {
@Override
@ -2104,6 +2179,7 @@ public class FragmentMessages extends FragmentBase {
final MenuItem menuSearch = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) menuSearch.getActionView();
searchView.setQueryHint(getString(R.string.title_search));
if (!TextUtils.isEmpty(searching)) {
menuSearch.expandActionView();
@ -2121,7 +2197,9 @@ public class FragmentMessages extends FragmentBase {
public boolean onQueryTextSubmit(String query) {
searching = null;
menuSearch.collapseActionView();
search(getContext(), getViewLifecycleOwner(), getFragmentManager(), folder, query);
search(
getContext(), getViewLifecycleOwner(), getFragmentManager(),
folder, false, query);
return true;
}
});
@ -2478,7 +2556,7 @@ public class FragmentMessages extends FragmentBase {
if (boundaryCallback == null)
boundaryCallback = new BoundaryCallbackMessages(
getContext(), getViewLifecycleOwner(),
folder, search, REMOTE_PAGE_SIZE,
folder, server, query, REMOTE_PAGE_SIZE,
new BoundaryCallbackMessages.IBoundaryCallbackMessages() {
@Override
public void onLoading() {
@ -3122,11 +3200,12 @@ public class FragmentMessages extends FragmentBase {
static void search(
final Context context, final LifecycleOwner owner, final FragmentManager manager,
long folder, String query) {
long folder, boolean server, String query) {
if (Helper.isPro(context)) {
Bundle args = new Bundle();
args.putLong("folder", folder);
args.putString("search", query);
args.putBoolean("server", server);
args.putString("query", query);
new SimpleTask<Void>() {
@Override
@ -3137,6 +3216,9 @@ public class FragmentMessages extends FragmentBase {
@Override
protected void onExecuted(Bundle args, Void data) {
if (owner.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED))
manager.popBackStack("search", FragmentManager.POP_BACK_STACK_INCLUSIVE);
FragmentMessages fragment = new FragmentMessages();
fragment.setArguments(args);

View File

@ -244,6 +244,17 @@
android:tint="@color/colorActionForeground"
app:backgroundTint="?attr/colorAccent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="@dimen/fab_padding"
android:src="@drawable/baseline_cloud_download_24"
android:tint="@color/colorActionForeground"
android:tooltipText="@string/title_search"
app:backgroundTint="?attr/colorAccent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"