Java tutorial
/* * Copyright (C) 2014 Murray Cumming * * This file is part of android-galaxyzoo. * * android-galaxyzoo is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * android-galaxyzoo is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with android-galaxyzoo. If not, see <http://www.gnu.org/licenses/>. */ package com.murrayc.galaxyzoo.app; import android.Manifest; import android.accounts.Account; import android.accounts.AccountManager; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.ConnectivityManager; import android.os.AsyncTask; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.app.FragmentManager; import android.support.v4.app.NavUtils; import android.support.v7.app.AlertDialog; import android.text.TextUtils; import android.view.MenuItem; import android.view.View; import com.murrayc.galaxyzoo.app.provider.Item; import com.murrayc.galaxyzoo.app.provider.ItemsContentProvider; import java.lang.ref.WeakReference; import java.util.ArrayList; /** * An activity showing a single subject. This * activity is only used on handset devices. On tablet-size devices, * item details are presented side-by-side with a list of items * in a {@link ListActivity}. * <p/> * This activity is mostly just a 'shell' activity containing nothing * more than a {@link ClassifyFragment}. */ public class ClassifyActivity extends ItemActivity implements SharedPreferences.OnSharedPreferenceChangeListener, ClassifyFragment.Callbacks, QuestionFragment.Callbacks { private static final String[] PERMISSIONS_REQUIRED = { /* These don't seem to be necessary on SDK 23 (Android Marshmallow 6.0) * though they were necessary, in AndroidManifest.xml, on SDK 21 (Android Lollipop 5.1). * even though they are documented as not being needed for getExternalCacheDir on * SDK versions >= 18. */ //Manifest.permission.WRITE_EXTERNAL_STORAGE, //Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.INTERNET, Manifest.permission.ACCESS_NETWORK_STATE, Manifest.permission.READ_SYNC_SETTINGS, Manifest.permission.WRITE_SYNC_SETTINGS //This is needed on SDK <= 22, for instance for AccountManager.getAccountsByType(). //Presumably it is not defined in android.Manifest because we don't need //to request it at runtime because requesting at runtime is only possible //since SDK 23. //Manifest.permission.GET_ACCOUNTS, //This is needed on SDK <= 22, for instance for AccountManager.getAuthToken(). //Presumably it is not defined in android.Manifest because we don't need //to request it at runtime because requesting at runtime is only possible //since SDK 23. //Manifest.permission.USE_CREDENTIALS, //This is needed on SDK <= 22, for instance for AccountManager.setAuthToken(), //addAccount() and removeAccount(). //Presumably it is not defined in android.Manifest because we don't need //to request it at runtime because requesting at runtime is only possible //since SDK 23. //Manifest.permission.MANAGE_ACCOUNTS, //This is needed on SDK <= 22, for instance for AccountManager.addAccountExplicitly() //and getUserData(). //Presumably it is not defined in android.Manifest because we don't need //to request it at runtime because requesting at runtime is only possible //since SDK 23. //Manifest.permission.AUTHENTICATE_ACCOUNTS, }; private static final int PERMISSION_REQUEST_CODE = 1; private boolean mIsStateAlreadySaved = false; private boolean mPendingClassificationFinished = false; private boolean mPendingWarnAboutNetworkProblemWithRetry = false; private AlertDialog mAlertDialog = null; private BroadcastReceiver mReceiverNetworkReconnection = null; // public class ItemsContentProviderObserver extends ContentObserver { // // /** // * Creates a content observer. // * // * @param handler The handler to run {@link #onChange} on, or null if none. // */ // public ItemsContentProviderObserver(final Handler handler) { // super(handler); // } // // @Override // public void onChange(boolean selfChange) { // super.onChange(selfChange); // // requestSync(); // } // // @Override // public void onChange(boolean selfChange, final Uri changeUri) { // //super.onChange(selfChange, changeUri); // // requestSync(); // } // } /** * Asynchronously discovers if we are logged in and offers a login if not. */ public static class CheckLoginTask extends AsyncTask<Void, Void, Boolean> { private final WeakReference<Context> mContextReference; CheckLoginTask(final Context context) { mContextReference = new WeakReference<>(context); } @Override protected Boolean doInBackground(final Void... params) { if (mContextReference == null) { return Boolean.FALSE; } final Context context = mContextReference.get(); if (context == null) { return Boolean.FALSE; } if (isCancelled()) { return Boolean.FALSE; } return LoginUtils.getLoggedIn(context); } @Override protected void onPostExecute(final Boolean result) { if (mContextReference == null) { return; } final Context context = mContextReference.get(); if (context == null) { return; } if (!result) { final Intent intent = new Intent(context, LoginActivity.class); context.startActivity(intent); } } } /** * Asynchronously gets the account and tells the SyncAdapter to sync it now: */ public static class RequestSyncTask extends AsyncTask<Void, Void, Void> { private final WeakReference<Context> mContextReference; RequestSyncTask(final Context context) { mContextReference = new WeakReference<>(context); } @Override protected Void doInBackground(final Void... params) { if (mContextReference == null) { return null; } final Context context = mContextReference.get(); if (context == null) { return null; } if (isCancelled()) { return null; } final AccountManager mgr = AccountManager.get(context); //Note this needs the GET_ACCOUNTS permission on //SDK <=22 // Ignore android-lint warnings about this: https://code.google.com/p/android/issues/detail?id=223244 final Account[] accts = mgr.getAccountsByType(LoginUtils.ACCOUNT_TYPE); if ((accts == null) || (accts.length < 1)) { //Log.error("getAccountLoginDetails(): getAccountsByType() returned no account."); return null; } if (isCancelled()) { return null; } final Account account = accts[0]; final Bundle extras = new Bundle(); extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); //Ask the framework to run our SyncAdapter. ContentResolver.requestSync(account, Item.AUTHORITY, extras); return null; } @Override protected void onCancelled() { } } private void requestSync() { final RequestSyncTask task = new RequestSyncTask(this); task.execute(); } private int mClassificationsDoneInSession = 0; // The authority for the sync adapter's content provider //public static final String AUTHORITY = Item.AUTHORITY; @Override public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == PERMISSION_REQUEST_CODE) { int permissionIndex = 0; for (final int result : grantResults) { if (result != PackageManager.PERMISSION_GRANTED) { if (permissionIndex < permissions.length) { Log.error("onRequestPermissionsResult(): failed for permission: " + permissions[permissionIndex]); } else { Log.error("onRequestPermissionsResult(): failed for a permission."); } } permissionIndex++; } } updateAfterPermissionsCheck(); } private void checkPermissions() { //Check for all the permissions, because we need them all. //TODO: Get the list of permissions from AndroidManifest.xml ? final ArrayList<String> permissionsMissing = new ArrayList<>(); for (final String permission : PERMISSIONS_REQUIRED) { if (ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { permissionsMissing.add(permission); } } if (!permissionsMissing.isEmpty()) { final String[] array = new String[permissionsMissing.size()]; permissionsMissing.toArray(array); Log.error( "ClassifyActivity.checkPermissions(): requesting permissions because checkSelfPermission() failed for permissions: " + permissionsMissing); //We will get the result asynchronously in onRequestPermissionsResult(). ActivityCompat.requestPermissions(this, array, PERMISSION_REQUEST_CODE); } } @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (TextUtils.isEmpty(getItemId())) { setItemId(ItemsContentProvider.URI_PART_ITEM_ID_NEXT); } //Make the SyncAdapter respond to preferences changes. //The SyncAdapter can't do this itself, because SharedPreferences //doesn't work across processes. try { PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this); } catch (final UnsupportedOperationException e) { //This happens during our test case, because the MockContext doesn't support this, //so ignore this. Log.info( "ClassifyActivity.onCreate(): Ignoring UnsupportedOperationException from getDefaultSharedPreferences()."); } /** * Ask the SyncProvider to update whenever anything in the ItemContentProvider's * Items table changes. This seems excessive, but maybe we can trust * the SyncAdapter framework to not try to do too much work. * * Register the observer for the data table. The table's path * and any of its subpaths trigger the observer. */ /* Disabled for now. Instead the ItemsContentProvider calls its requestSync() * when it is necessary. final ItemsContentProviderObserver observer = new ItemsContentProviderObserver(new Handler()); ContentResolver resolver = getContentResolver(); resolver.registerContentObserver(Item.ITEMS_URI, true, observer); */ //Our NetworkChangeReceiver should only wake up when necessary: stopListeningForNetworkReconnection(); //Make sure that the SyncAdapter starts to download items as soon as possible: requestSync(); setContentView(R.layout.activity_classify); UiUtils.showToolbar(this); // savedInstanceState is non-null when there is fragment state // saved from previous configurations of this activity // (e.g. when rotating the screen from portrait to landscape). // In this case, the fragment will automatically be re-added // to its container so we don't need to manually add it. // For more information, see the Fragments API guide at: // // http://developer.android.com/guide/components/fragments.html // if (savedInstanceState == null) { final FragmentManager fragmentManager = getSupportFragmentManager(); if (fragmentManager != null) { //We check to see if the fragment exists already, //because apparently it sometimes does exist already when the app has been //in the background for some time, //at least on Android 5.0 (Lollipop) // Create the detail fragment and add it to the activity // using a fragment transaction. final Bundle arguments = new Bundle(); arguments.putString(ItemFragment.ARG_ITEM_ID, getItemId()); ClassifyFragment fragment = (ClassifyFragment) fragmentManager.findFragmentById(R.id.container); if (fragment == null) { // TODO: Find a simpler way to just pass this through to the fragment. // For instance, pass the intent.getExtras() as the bundle?. fragment = new ClassifyFragment(); fragment.setArguments(arguments); fragmentManager.beginTransaction().replace(R.id.container, fragment).commit(); } else { Log.info("ClassifyActivity.onCreate(): The ClassifyFragment already existed."); fragment.setItemId(getItemId()); fragment.update(); } } //Check that we have permissions and asynchronously request them if necessary, //later updating our UI to use them: checkPermissions(); } /* // Show the Up button in the action bar. final ActionBar actionBar = getActionBar(); if (actionBar == null) return; actionBar.setDisplayHomeAsUpEnabled(true); */ final LoginUtils.GetExistingLogin task = new LoginUtils.GetExistingLogin(this) { @Override protected void onPostExecute(final LoginUtils.LoginDetails loginDetails) { super.onPostExecute(loginDetails); if (mException != null) { Log.error( "ClassifyActivity.onCreate(): GetExistingLogin asynctask failed, probably due to a missing permission:", mException); } onExistingLoginRetrieved(loginDetails); } }; task.execute(); } private void updateAfterPermissionsCheck() { requestSync(); } @Override public void onPause() { super.onPause(); mIsStateAlreadySaved = true; } /** This would ideally be in ClassifyFragment.onResume() or similar, * but we need to do it here to avoid this exception sometimes: * "java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState". * as suggested here: * http://www.androiddesignpatterns.com/2013/08/fragment-transaction-commit-state-loss.html */ @Override protected void onResumeFragments() { super.onResumeFragments(); mIsStateAlreadySaved = false; //Stop the NetworkChangeReceiver if it is active //We already try again to use the network after resume, //so we don't need to listen for a reconnection yet. stopListeningForNetworkReconnection(); //See onClassificationFinished(). if (mPendingClassificationFinished) { mPendingClassificationFinished = false; doClassificationFinished(); return; } //See warnAboutNetworkProblemWithRetry(). if (mPendingWarnAboutNetworkProblemWithRetry) { mPendingWarnAboutNetworkProblemWithRetry = false; doWarnAboutNetworkProblemWithRetry(); return; } final ClassifyFragment fragmentClassify = getChildFragment(); if (fragmentClassify != null) { if (TextUtils.equals(fragmentClassify.getItemId(), ItemsContentProvider.URI_PART_ITEM_ID_NEXT)) { //We are probably resuming again after a previous failure to get new items //from the network, so try again: fragmentClassify.update(); } } } private void onExistingLoginRetrieved(final LoginUtils.LoginDetails loginDetails) { if (loginDetails != null && !TextUtils.isEmpty(loginDetails.authApiKey)) { //Tell the user that we are logged in, //reassuring them that their classifications will be part of their profile: //TODO: Is there a better way to do this? //Hiding the login option menu item would still leave some doubt, //and graying it out would be against the Android design guidelines. final View parentLayout = findViewById(R.id.root_view); UiUtils.showLoggedInMessage(parentLayout); } //Request a sync in case sync has not happened before because there was not yet //an anonymous account (which is added in GetExistingLogin). requestSync(); } @Override public boolean onOptionsItemSelected(final MenuItem item) { // Handle presses on the action bar items final int id = item.getItemId(); if (id == android.R.id.home) { // This ID represents the Home or Up button. In the case of this // activity, the Up button is shown. Use NavUtils to allow users // to navigate up one level in the application structure. For // more details, see the Navigation pattern on Android Design: // // http://developer.android.com/design/patterns/navigation.html#up-vs-back // final Intent intent = new Intent(this, ListActivity.class); NavUtils.navigateUpTo(this, intent); return true; } return super.onOptionsItemSelected(item); } @Override public void onClassificationFinished() { // Avoid causing this exception when ClassifyFragment tries to show() an AlertDialog: // java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState if (mIsStateAlreadySaved) { //Do it later instead. this.mPendingClassificationFinished = true; return; } doClassificationFinished(); } private void doClassificationFinished() { //Suggest registering or logging in after a certain number of classifications, //as the web UI does, but don't ask again. if (mClassificationsDoneInSession == 3) { checkForLoginAsync(); } mClassificationsDoneInSession++; // Careful: This can cause an AlertDialog.show() if there are no more items and if the // network is not working properly. // That's a problem because this method (onClassificationFinished()) can result from an AsyncTask.onPostExecute(), // which is generally discouraged: // See http://www.androiddesignpatterns.com/2013/08/fragment-transaction-commit-state-loss.html startNextClassification(); } private void checkForLoginAsync() { final CheckLoginTask task = new CheckLoginTask(this); task.execute(); } private void startNextClassification() { //Start another classification: setItemId(ItemsContentProvider.URI_PART_ITEM_ID_NEXT); final ClassifyFragment fragmentClassify = getChildFragment(); if (fragmentClassify != null) { fragmentClassify.setItemId(ItemsContentProvider.URI_PART_ITEM_ID_NEXT); //Avoid using fragment transactions after onSaveInstanceState(), //by delaying it until onResumeFragments, which always checks for URI_PART_ITEM_ID_NEXT //and then does an update() anyway. //See https://github.com/murraycu/android-galaxyzoo/issues/21 if (!mIsStateAlreadySaved) { fragmentClassify.update(); } } } private ClassifyFragment getChildFragment() { return (ClassifyFragment) getSupportFragmentManager().findFragmentById(R.id.container); } @Override public void abandonItem() { //We don't bother reporting the itemId to the log here, //because it is generally just "next" - //we don't use a Cursor to discover the actual ID - The child ClassifyFragment does that. Log.error("ClassifyActivity(): Abandoning item."); startNextClassification(); } @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { Log.info("ClassifyActivity: onSharedPreferenceChanged()."); //Changes to these preferences would need us to do some work: //TODO: Do we need this check, or will we only be notified about the app's own preferences? if (TextUtils.equals(key, getString(R.string.pref_key_cache_size)) || TextUtils.equals(key, getString(R.string.pref_key_keep_count)) || TextUtils.equals(key, getString(R.string.pref_key_wifi_only))) { requestSync(); } } @Override public void warnAboutNetworkProblemWithRetry() { // Avoid causing this exception when we try to show() an AlertDialog: // java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState if (mIsStateAlreadySaved) { //Do it later instead. this.mPendingWarnAboutNetworkProblemWithRetry = true; return; } doWarnAboutNetworkProblemWithRetry(); } private void doWarnAboutNetworkProblemWithRetry() { //Dismiss any existing dialog: if (mAlertDialog != null) { mAlertDialog.dismiss(); mAlertDialog = null; } //Show the new one: final AlertDialog.Builder builder = new AlertDialog.Builder(this); // http://developer.android.com/design/building-blocks/dialogs.html // says "Most alerts don't need titles.": // builder.setTitle(activity.getString(R.string.error_title_connection_problem)); builder.setMessage(getString(R.string.error_no_subjects)); builder.setPositiveButton(getString(R.string.error_button_retry), new DialogInterface.OnClickListener() { @Override public void onClick(final DialogInterface dialog, final int which) { onClickListenerRetry(); } }); builder.setNegativeButton(getString(R.string.error_button_cancel), new DialogInterface.OnClickListener() { @Override public void onClick(final DialogInterface dialog, final int which) { dialog.cancel(); } }); builder.setOnCancelListener(new DialogInterface.OnCancelListener() { @Override public void onCancel(final DialogInterface dialog) { dialog.dismiss(); mAlertDialog = null; } }); mAlertDialog = builder.create(); mAlertDialog.show(); } private void onClickListenerRetry() { //Try to get the next item again. //It should succeed if we have a working network connection, //or fail again with the same message. final ClassifyFragment fragmentClassify = getChildFragment(); if (fragmentClassify != null) { fragmentClassify.update(); } } @Override public void listenForNetworkReconnection() { if (mReceiverNetworkReconnection != null) { Log.error("ClassifyActivity.listenForNetworkReconnection(): Already listening."); return; } mReceiverNetworkReconnection = new BroadcastReceiver() { @Override public void onReceive(final Context context, final Intent intent) { final Utils.NetworkConnected networkConnected = Utils.getNetworkIsConnected(context, Utils.getUseWifiOnlyFromSharedPrefs(ClassifyActivity.this)); if ((networkConnected != null) && (networkConnected.connected)) { //Try using the network again: ClassifyActivity.this.stopListeningForNetworkReconnection(); ClassifyActivity.this.startNextClassification(); } } }; final IntentFilter filter = new IntentFilter(); filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); registerReceiver(mReceiverNetworkReconnection, filter); } private void stopListeningForNetworkReconnection() { if (mReceiverNetworkReconnection == null) { return; } unregisterReceiver(mReceiverNetworkReconnection); mReceiverNetworkReconnection = null; } }