Java tutorial
/* * This file is part of nori. * Copyright (c) 2014-2016 Tomasz Jan Gralczyk <tomg@fastmail.uk> * License: GNU GPLv2 */ package io.github.tjg1.nori; import android.app.SearchManager; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.database.Cursor; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.design.widget.Snackbar; import android.support.v4.view.MenuItemCompat; import android.support.v4.widget.CursorAdapter; import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.SearchView; import android.support.v7.widget.Toolbar; import android.text.TextUtils; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.ProgressBar; import android.widget.Spinner; import android.widget.TextView; import java.io.IOException; import io.github.tjg1.library.norilib.Image; import io.github.tjg1.library.norilib.SearchResult; import io.github.tjg1.library.norilib.Tag; import io.github.tjg1.library.norilib.clients.SearchClient; import io.github.tjg1.nori.adapter.ServiceDropdownAdapter; import io.github.tjg1.nori.database.SearchSuggestionDatabase; import io.github.tjg1.nori.fragment.SearchResultGridFragment; /** Searches for images and displays the results in a scrollable grid of thumbnails. */ public class SearchActivity extends AppCompatActivity implements SearchResultGridFragment.OnSearchResultGridFragmentInteractionListener, ServiceDropdownAdapter.Listener { //region Bundle IDs /** Identifier used to send the active {@link io.github.tjg1.library.norilib.SearchResult} to {@link io.github.tjg1.nori.ImageViewerActivity}. */ public static final String BUNDLE_ID_SEARCH_RESULT = "io.github.tjg1.nori.SearchResult"; /** Identifier used to send the position of the selected {@link io.github.tjg1.library.norilib.Image} to {@link io.github.tjg1.nori.ImageViewerActivity}. */ public static final String BUNDLE_ID_IMAGE_INDEX = "io.github.tjg1.nori.ImageIndex"; /** Identifier used to send {@link io.github.tjg1.library.norilib.clients.SearchClient} settings to {@link io.github.tjg1.nori.ImageViewerActivity}. */ public static final String BUNDLE_ID_SEARCH_CLIENT_SETTINGS = "io.github.tjg1.nori.SearchClient.Settings"; /** Identifier used to preserve current search query in {@link #onSaveInstanceState(android.os.Bundle)}. */ private static final String BUNDLE_ID_SEARCH_QUERY = "io.github.tjg1.nori.SearchQuery"; /** Identifier used to preserve iconified/expanded state of the SearchView in {@link #onSaveInstanceState(android.os.Bundle)}. */ private static final String BUNDLE_ID_SEARCH_VIEW_IS_EXPANDED = "io.github.tjg1.nori.SearchView.isExpanded"; /** Identifier used to preserve search view focused state. */ private static final String BUNDLE_ID_SEARCH_VIEW_IS_FOCUSED = "io.github.tjg1.nori.SearchView.isFocused"; //endregion //region Intent extra IDs /** Identifier used for the query string to search when starting this activity with an {@link android.content.Intent} */ public static final String INTENT_EXTRA_SEARCH_QUERY = "io.github.tjg1.nori.SearchQuery"; /** Identifier used to include {@link SearchClient.Settings} objects in search intents. */ public static final String INTENT_EXTRA_SEARCH_CLIENT_SETTINGS = "io.github.tjg1.nori.SearchClient.Settings"; //endregion //region Instance fields (Views) /** Search API activity indicator. */ private ProgressBar searchProgressBar; /** Search view menu item. */ private MenuItem searchMenuItem; /** Action bar search view. */ private SearchView searchView; /** Search result grid fragment shown in this activity. */ private SearchResultGridFragment searchResultGridFragment; //endregion //region Instance fields /** Default {@link android.content.SharedPreferences} object. */ private SharedPreferences sharedPreferences; /* {@link SearchClient.Settings} object selected from the service dropdown menu. */ private SearchClient.Settings searchClientSettings; /** Search API client. */ private SearchClient searchClient; /** Search callback currently awaiting a response from the Search API. */ private SearchResultCallback searchCallback; /** Bundle used when restoring saved instance state (after screen rotation, app restored from background, etc.) */ private Bundle savedInstanceState; //endregion //region Activity lifecycle @Override protected void onCreate(Bundle savedInstanceState) { // Restore state from savedInstanceState. super.onCreate(savedInstanceState); // Get shared preferences. sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); // Inflate views. setContentView(R.layout.activity_search); searchProgressBar = (ProgressBar) findViewById(R.id.progressBar); // Get search result grid fragment from fragment manager. searchResultGridFragment = (SearchResultGridFragment) getSupportFragmentManager() .findFragmentById(R.id.fragment_searchResultGrid); SearchClient.Settings searchClientSettings; // Try restoring the SearchClient from savedInstanceState if (savedInstanceState != null) { if (this.searchClient == null && savedInstanceState.containsKey(BUNDLE_ID_SEARCH_CLIENT_SETTINGS)) { searchClientSettings = savedInstanceState.getParcelable(BUNDLE_ID_SEARCH_CLIENT_SETTINGS); if (searchClientSettings != null) { searchClient = searchClientSettings.createSearchClient(this); } } } else { Intent intent = getIntent(); // If the activity was started from a Search intent, create the SearchClient object and submit search. if (intent != null && intent.getAction().equals(Intent.ACTION_SEARCH) && searchResultGridFragment.getSearchResult() == null) { searchClientSettings = intent.getParcelableExtra(BUNDLE_ID_SEARCH_CLIENT_SETTINGS); searchClient = searchClientSettings.createSearchClient(this); doSearch(intent.getStringExtra(BUNDLE_ID_SEARCH_QUERY)); } showDonationDialog(); } // Set up the dropdown API server picker. setUpActionBar(); } @Override protected void onDestroy() { super.onDestroy(); // Cancel pending API callbacks. if (searchCallback != null) { searchCallback.cancel(); } } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); // Make saved instance state available to other methods. this.savedInstanceState = savedInstanceState; } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); // Preserve SearchView state. if (searchView != null) { outState.putCharSequence(BUNDLE_ID_SEARCH_QUERY, searchView.getQuery()); outState.putBoolean(BUNDLE_ID_SEARCH_VIEW_IS_EXPANDED, MenuItemCompat.isActionViewExpanded(searchMenuItem)); outState.putBoolean(BUNDLE_ID_SEARCH_VIEW_IS_FOCUSED, searchView.isFocused()); if (searchClient != null) { outState.putParcelable(BUNDLE_ID_SEARCH_CLIENT_SETTINGS, searchClient.getSettings()); } } } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.search, menu); // Set up action bar search view. setUpSearchView(menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. switch (item.getItemId()) { case R.id.action_settings: startActivity(new Intent(SearchActivity.this, SettingsActivity.class)); return true; default: return super.onOptionsItemSelected(item); } } //endregion //region ServiceDropdownAdapter.Listener methods (service dropdown) /** * Called when a new Search API is selected by the user from the action bar dropdown. * * @param settings Selected {@link SearchClient.Settings} object. * @param expandActionView Should the SearchView action view be expanded? */ @Override public void onSearchAPISelected(SearchClient.Settings settings, boolean expandActionView) { if (settings == null) { // The SearchClient setting database is empty. return; } // Show search action bar icon (if not already visible). if (searchMenuItem != null) { searchMenuItem.setVisible(true); } // Expand the SearchView when an API is selected manually by the user. // (and not automatically restored from previous state when the app is first launched) if (searchClientSettings != null && searchMenuItem != null && expandActionView) { MenuItemCompat.expandActionView(searchMenuItem); } searchClientSettings = settings; // If a SearchClient wasn't included in the Intent that started this activity, create one now and search for the default query. // Only do this if SearchSearch filter is enabled. if (searchClient == null && searchResultGridFragment.getSearchResult() == null) { searchClient = settings.createSearchClient(this); if (shouldLoadDefaultQuery()) { doSearch(searchClient.getDefaultQuery()); } else if (searchMenuItem != null) { MenuItemCompat.expandActionView(searchMenuItem); } } } //endregion //region SearchResultGridFragment.OnSearchResultGridFragmentInteractionListener methods (ImageViewerActivity, infinite scrolling) @Override public void onImageSelected(Image image, int position) { // Open ImageViewerActivity. final Intent intent = new Intent(SearchActivity.this, ImageViewerActivity.class); intent.putExtra(BUNDLE_ID_SEARCH_RESULT, searchResultGridFragment.getSearchResult().getSearchResultForPage(image.searchPage)); intent.putExtra(BUNDLE_ID_IMAGE_INDEX, image.searchPagePosition); intent.putExtra(BUNDLE_ID_SEARCH_CLIENT_SETTINGS, searchClient.getSettings()); startActivity(intent); } @Override public void fetchMoreImages(SearchResult searchResult) { // Ignore request if there is another API request pending. if (searchCallback != null) { return; } // Show progress bar in ActionBar. searchProgressBar.setVisibility(View.VISIBLE); // Request search result from API client. searchCallback = new SearchResultCallback(searchResult); searchClient.search(Tag.stringFromArray(searchResult.getQuery()), searchResult.getCurrentOffset() + 1, searchCallback); } @Override public void onRestoreSearchGridState(@NonNull String savedQuery, int firstVisiblePageOffset) { // Ignore request if there is another API request pending. if (searchCallback != null) { return; } // Show progress bar in ActionBar. searchProgressBar.setVisibility(View.VISIBLE); // Request previous SearchResult from API client. searchCallback = new SearchResultCallback(); searchClient.search(savedQuery, firstVisiblePageOffset, searchCallback); } //endregion //region UI Setup /** * Set up the action bar SearchView and its event handlers. * * @param menu Menu after being inflated in {@link #onCreateOptionsMenu(android.view.Menu)}. */ private void setUpSearchView(Menu menu) { // Extract SearchView from the MenuItem object. searchMenuItem = menu.findItem(R.id.action_search); searchMenuItem.setVisible(searchClientSettings != null); searchView = (SearchView) MenuItemCompat.getActionView(searchMenuItem); // Set Searchable XML configuration. SearchManager searchManager = (SearchManager) getSystemService(SEARCH_SERVICE); searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); // Set SearchView attributes. searchView.setFocusable(false); searchView.setQueryRefinementEnabled(true); // Set submit action and allow empty queries. SearchView.SearchAutoComplete searchTextView = (SearchView.SearchAutoComplete) searchView .findViewById(R.id.search_src_text); searchTextView.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView textView, int i, KeyEvent keyEvent) { CharSequence query = textView.getText(); if (query != null && searchClientSettings != null) { // Prepare a intent to send to a new instance of this activity. Intent intent = new Intent(SearchActivity.this, SearchActivity.class); intent.setAction(Intent.ACTION_SEARCH); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.putExtra(BUNDLE_ID_SEARCH_CLIENT_SETTINGS, searchClientSettings); intent.putExtra(BUNDLE_ID_SEARCH_QUERY, query.toString()); // Collapse the ActionView. This makes navigating through previous results using the back key less painful. MenuItemCompat.collapseActionView(searchMenuItem); // Start a new activity with the created Intent. startActivity(intent); } // Returns true to override the default behaviour and stop another search Intent from being sent. return true; } }); searchView.setOnSuggestionListener(new SearchView.OnSuggestionListener() { @Override public boolean onSuggestionSelect(int position) { return onSuggestionClick(position); } @Override public boolean onSuggestionClick(int position) { if (searchClientSettings != null) { // Get the SearchView's suggestion adapter. CursorAdapter adapter = searchView.getSuggestionsAdapter(); // Get the suggestion at given position. Cursor c = adapter.getCursor(); c.moveToPosition(position); // Create and send a search intent. Intent intent = new Intent(SearchActivity.this, SearchActivity.class); intent.setAction(Intent.ACTION_SEARCH); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.putExtra(BUNDLE_ID_SEARCH_CLIENT_SETTINGS, searchClientSettings); intent.putExtra(BUNDLE_ID_SEARCH_QUERY, c.getString(c.getColumnIndex(SearchSuggestionDatabase.COLUMN_NAME))); startActivity(intent); // Release native resources. c.close(); } // Return true to override default behaviour and prevent another intent from being sent. return true; } }); if (savedInstanceState != null) { // Restore state from saved instance state bundle (after screen rotation, app restored from background, etc.) if (savedInstanceState.containsKey(BUNDLE_ID_SEARCH_QUERY)) { // Restore search query from saved instance state. searchView.setQuery(savedInstanceState.getCharSequence(BUNDLE_ID_SEARCH_QUERY), false); } // Restore iconified/expanded search view state from saved instance state. if (savedInstanceState.getBoolean(BUNDLE_ID_SEARCH_VIEW_IS_EXPANDED, false)) { MenuItemCompat.expandActionView(searchMenuItem); // Restore focus state. if (!savedInstanceState.getBoolean(BUNDLE_ID_SEARCH_VIEW_IS_FOCUSED, false)) { searchView.clearFocus(); } } } else if (getIntent() != null && getIntent().getAction().equals(Intent.ACTION_SEARCH)) { // Set SearchView query string to match the one sent in the SearchIntent. searchView.setQuery(getIntent().getStringExtra(BUNDLE_ID_SEARCH_QUERY), false); } } /** Set up the {@link android.support.v7.app.ActionBar}, including the API service picker dropdown. */ private void setUpActionBar() { Toolbar toolBar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolBar); ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayShowHomeEnabled(false); actionBar.setDisplayShowTitleEnabled(false); } // Set up service list spinner. Spinner serviceSpinner = (Spinner) toolBar.findViewById(R.id.spinner_service); ServiceDropdownAdapter serviceDropdownAdapter = new ServiceDropdownAdapter(this, sharedPreferences, getSupportLoaderManager(), serviceSpinner, this); serviceSpinner.setAdapter(serviceDropdownAdapter); serviceSpinner.setOnItemSelectedListener(serviceDropdownAdapter); } //endregion //region Donation Dialog /** Show the donation dialog at defined app launch count thresholds. */ private void showDonationDialog() { // Get the number of times the dialog was already shown and current time. int donationDialogShownTimes = sharedPreferences .getInt(getString(R.string.preference_donation_dialog_count), 0); long currentTime = System.currentTimeMillis(); // Get Nori installation date. long installationDate; try { installationDate = getPackageManager().getPackageInfo(getPackageName(), 0).firstInstallTime; } catch (PackageManager.NameNotFoundException e) { return; } // Show the donation dialog 7 days after installation, 30 days thereafter and // then every 3 months thereafter. if (donationDialogShownTimes == 0 && (currentTime - installationDate) > 604800000L || donationDialogShownTimes == 1 && (currentTime - installationDate) > 3196800000L || (currentTime - installationDate) > (donationDialogShownTimes * 7776000000L + 3196800000L)) { new AlertDialog.Builder(this).setTitle(R.string.activity_donations) .setMessage(R.string.donation_summary).setCancelable(false) .setPositiveButton(R.string.donation_dialog_donate, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { startActivity(new Intent(SearchActivity.this, DonationActivity.class)); dialogInterface.cancel(); } }).setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { dialogInterface.cancel(); } }).create().show(); // Increment donation dialog count shared preference. sharedPreferences.edit() .putInt(getString(R.string.preference_donation_dialog_count), ++donationDialogShownTimes) .apply(); } } //endregion //region Search /** * Request a {@link SearchResult} object to be fetched from the background. * * @param query Query string (a space-separated list of tags). */ private void doSearch(String query) { // Show progress bar in ActionBar. if (searchProgressBar != null) { searchProgressBar.setVisibility(View.VISIBLE); } // Request a search result from the API client. searchCallback = new SearchResultCallback(); searchClient.search(query, searchCallback); } //endregion //region Default search query /** * Only load the default query on app launch if the SafeSearch filter is enabled. * * @return True if default query search results should be shown on first launch. */ protected boolean shouldLoadDefaultQuery() { String filters = sharedPreferences.getString(getString(R.string.preference_safeSearch_key), ""); return !(filters.contains("q") || filters.contains("x")); } //endregion //region Inner class: Search callback /** Callback waiting for a SearchResult received on a background thread from the Search API. */ private class SearchResultCallback implements SearchClient.SearchCallback { //region Instance methods /** Search result to extend when fetching more images for endless scrolling. */ private final SearchResult searchResult; /** Callback cancelled and should no longer respond to received SearchResult. */ private boolean isCancelled = false; //endregion //region Constructors /** Default constructor. */ public SearchResultCallback() { this.searchResult = null; } /** Constructor used to add more images to an existing SearchResult to implement endless scrolling. */ public SearchResultCallback(SearchResult searchResult) { this.searchResult = searchResult; } //endregion //region SearchClient.SearchCallback methods @Override public void onFailure(IOException e) { if (!isCancelled) { // Show error message to user. Snackbar.make(findViewById(R.id.root), String.format(getString(R.string.toast_networkError), e.getLocalizedMessage()), Snackbar.LENGTH_INDEFINITE).show(); // Clear callback and hide progress indicator in Action Bar. searchProgressBar.setVisibility(View.GONE); searchCallback = null; } } @Override public void onSuccess(SearchResult searchResult) { if (!isCancelled) { // Clear callback and hide progress indicator in Action Bar. searchProgressBar.setVisibility(View.GONE); searchCallback = null; // Filter the received SearchResult. final int resultCount = searchResult.getImages().length; if (sharedPreferences.contains(getString(R.string.preference_safeSearch_key)) && !TextUtils.isEmpty( sharedPreferences.getString(getString(R.string.preference_safeSearch_key), "").trim())) { // Get filter from shared preferences. searchResult.filter(Image.SafeSearchRating.arrayFromStrings(sharedPreferences .getString(getString(R.string.preference_safeSearch_key), "").split(" "))); } else { // Get default filter from resources. searchResult.filter(Image.SafeSearchRating.arrayFromStrings( getResources().getStringArray(R.array.preference_safeSearch_defaultValues))); } if (sharedPreferences.contains(getString(R.string.preference_tagFilter_key))) { // Get tag filters from shared preferences and filter the result. searchResult.filter(Tag.arrayFromString( sharedPreferences.getString(getString(R.string.preference_tagFilter_key), ""))); } if (this.searchResult != null) { // Set onLastPage if no more images were fetched. if (resultCount == 0) { this.searchResult.onLastPage(); } else { // Extend existing search result for endless scrolling. this.searchResult.addImages(searchResult.getImages(), searchResult.getCurrentOffset()); searchResultGridFragment.setSearchResult(this.searchResult); } } else { // Show search result. if (resultCount == 0) { searchResult.onLastPage(); } else { addSearchHistoryEntry(Tag.stringFromArray(searchResult.getQuery())); } searchResultGridFragment.setSearchResult(searchResult); } } } //endregion //region Search history /** * Adds a new entry to the {@link SearchSuggestionDatabase} on a background thread * (to prevent blocking the UI thread with database I/O). * * @param query Query string searched for by the user. */ private void addSearchHistoryEntry(final String query) { new Thread(new Runnable() { @Override public void run() { // Add query string to the database. SearchSuggestionDatabase searchSuggestionDatabase = new SearchSuggestionDatabase( SearchActivity.this); searchSuggestionDatabase.insert(query); searchSuggestionDatabase.close(); } }).start(); } //endregion //region Cancel method /** Cancels this callback. */ public void cancel() { this.isCancelled = true; } //endregion } //endregion }