losttime
losttime

Reputation: 1141

Changing device orientation crashes Activity when using AutoCompleteTextView and CursorAdapter

Situation

I have an Activity with an AutoCompleteTextView. As you type, the AutoCompleteTextView finds matching names from your contacts and displays them in a list. If the device orientation is changed while this list is displayed, the Activity crashes (Error message provided after source code).

Notes

I am developing for ICS 4.0.3 and testing on a Nexus S device. I am trying to follow best practices of using LoaderManager to generate and manage cursors. My understanding is that the LoaderManager should preserve cursor data across an orientation change (http://developer.android.com/guide/topics/fundamentals/loaders.html#callback), but that doesn't seem to be the case.

Because the CursorAdapter wants me to return the original, unfiltered cursor when the filtering constraint is too small to use, I am:

  1. Saving the cursor as a static variable of the Activity for use when filtering criteria is not met (sorry for improper terminology. I'm a Java newbie).
  2. Preventing the CursorAdapter from closing cursors after it replaces them, unless it confirms that it's not the original cursor by comparing it with the saved cursor.

The problem appears to be that the onLoadFinished LoaderManager callback is being called after an orientation change, but the cursor it's passing (the original cursor?) was closed during the reorientation.

If I configure my Activity to manage orientation changes itself by adding the following to the activity declaration in my manifest:

android:configChanges="orientation|screenSize"

the saved original cursor should be preserved across orientation changes (right?). While the app does not crash, another related problem occurs:

It appears that my original cursor is gone in this case also. I'm guessing the app didn't crash because the onLoadFinished callback is not called when my activity is configured to manage orientation changes itself

My Questions

  1. Am I right in assuming my cursor is being destroyed when device orientation changes?
  2. How can a preserve a cursor and/or its data when device orientation changes?

Source Code

View - home.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <AutoCompleteTextView
        android:id="@+id/newPlayer_edit"
        android:inputType="text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:hint="Contact"
        android:singleLine="true" >
        <requestFocus />
    </AutoCompleteTextView>

</LinearLayout>

Activity - Home.java

public class Home extends Activity implements LoaderManager.LoaderCallbacks<Cursor> {

// Constants
private static final String TAG = "HOME";
private static final boolean DEBUG = true;
public static final int LOADER_CONTACTS_CURSOR = 1;

// Variables
private AdapterContacts adapter;
public static Cursor originalCursor = null;


/**
 * Overrides
 */

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Set the view
    setContentView(R.layout.home);

    // Initialize CursorAdapter
    adapter = new AdapterContacts(this, null, 0);

    // Attach CursorAdapter to AutoCompleteTextView field
    AutoCompleteTextView field = (AutoCompleteTextView) findViewById(R.id.newPlayer_edit);
    field.setAdapter(adapter);

    // Initialize Cursor using LoaderManager
    LoaderManager.enableDebugLogging(true);
    getLoaderManager().initLoader(LOADER_CONTACTS_CURSOR, null, this);
}

@Override
public void onDestroy() {
    if (DEBUG) Log.i(TAG, "Destroying activity");
    super.onDestroy();
}

@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
    if (DEBUG) Log.i(TAG, "Loader Callback: creating loader");
    return new CursorLoader(this, ContactsContract.Contacts.CONTENT_URI, null, null, null, null);
}

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
    if (DEBUG) Log.i(TAG, "Loader Callback: load finished");
    // If no cursor has been loaded before, reserve this cursor as the original
    // It will be returned by the CursorAdapter when the filter constraint is null 
    if (originalCursor == null) {
        originalCursor = cursor;
    }

    // add the cursor to the adapter
    adapter.swapCursor(cursor);
}

@Override
public void onLoaderReset(Loader<Cursor> loader) {
    if (DEBUG) Log.i(TAG, "Loader Callback: resetting");
    adapter.swapCursor(null);
}
}

CursorAdapter - AdapterContacts.java

public class AdapterContacts extends CursorAdapter {

// Constants
private static final String TAG = "AdapterContacts";
private static final boolean DEBUG = true;

// Variables
private TextView mName;
private ContentResolver mContent;

/**
 * Constructor
 */
public AdapterContacts(Context context, Cursor c, int flags) {
    super(context, c, flags);
    mContent = context.getContentResolver();
}

/**
 * Overrides
 */

@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
    // Inflate the views that create each row of the dropdown list
    final LayoutInflater inflater = LayoutInflater.from(context);
    final LinearLayout ret = new LinearLayout(context);
    ret.setOrientation(LinearLayout.VERTICAL);

    mName = (TextView) inflater.inflate(android.R.layout.simple_dropdown_item_1line, parent, false);
    ret.addView(mName, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));

    /*
    int nameIdx = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY);
    mName.setText(cursor.getString(nameIdx));
    */

    return ret;
}

@Override
public void bindView(View view, Context context, Cursor cursor) {
    // Fill the dropdown row with data from the cursor
    int nameIdx = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY);
    String name = cursor.getString(nameIdx);
    ((TextView) ((LinearLayout) view).getChildAt(0)).setText(name);
}

@Override
public String convertToString(Cursor cursor) {
    // Convert the dropdown list entry that the user clicked on
    // into a string that will fill the AutoCompleteTextView
    int nameCol = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY);
    return cursor.getString(nameCol);
}

@Override
public void changeCursor(Cursor newCursor) {
    // Because a LoaderManager is used to initialize the originalCursor
    // changeCursor (which closes cursors be default when they're released)
    // is overridden to use swapCursor (which doesn't close cursors). 
    Cursor oldCursor = swapCursor(newCursor);

    // Any swapped out cursors that are not the original cursor must 
    // then be closed manually.
    if (oldCursor != Home.originalCursor) {
        oldCursor.close();
    }
}

@Override
public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
    // If their is a constraint, generate and return a new cursor
    if (constraint != null) {
        // I'd love to use a LoaderManager here too,
        // but haven't quite figured out the best way.
        if (DEBUG) Log.i(TAG, "Constraint is not null: " + constraint.toString());
        Uri uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_FILTER_URI, constraint.toString());
        return mContent.query(uri, null, null, null, null);
    }

    // If no constraint, return the originalCursor
    if (DEBUG) Log.i(TAG, "Constraint is null");
    return Home.originalCursor;
}
}

Error Message

03-16 10:39:34.839: E/AndroidRuntime(22097): java.lang.RuntimeException: Unable to start activity ComponentInfo{com.myapp.basic/com.myapp.basic.Home}: android.database.StaleDataException: Attempted to access a cursor after it has been closed.
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:1956)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:1981)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread.handleRelaunchActivity(ActivityThread.java:3351)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread.access$700(ActivityThread.java:123)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1151)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.os.Handler.dispatchMessage(Handler.java:99)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.os.Looper.loop(Looper.java:137)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread.main(ActivityThread.java:4424)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at java.lang.reflect.Method.invokeNative(Native Method)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at java.lang.reflect.Method.invoke(Method.java:511)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:784)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:551)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at dalvik.system.NativeStart.main(Native Method)
03-16 10:39:34.839: E/AndroidRuntime(22097): Caused by: android.database.StaleDataException: Attempted to access a cursor after it has been closed.
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.database.BulkCursorToCursorAdaptor.throwIfCursorIsClosed(BulkCursorToCursorAdaptor.java:75)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.database.BulkCursorToCursorAdaptor.getColumnNames(BulkCursorToCursorAdaptor.java:170)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.database.AbstractCursor.getColumnIndex(AbstractCursor.java:248)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.database.AbstractCursor.getColumnIndexOrThrow(AbstractCursor.java:265)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.database.CursorWrapper.getColumnIndexOrThrow(CursorWrapper.java:78)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.widget.CursorAdapter.swapCursor(CursorAdapter.java:338)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at com.myapp.basic.Home.onLoadFinished(Home.java:70)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at com.myapp.basic.Home.onLoadFinished(Home.java:1)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.LoaderManagerImpl$LoaderInfo.callOnLoadFinished(LoaderManager.java:438)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.LoaderManagerImpl$LoaderInfo.finishRetain(LoaderManager.java:309)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.LoaderManagerImpl.finishRetain(LoaderManager.java:765)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.Activity.performStart(Activity.java:4485)
03-16 10:39:34.839: E/AndroidRuntime(22097):    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:1929)
03-16 10:39:34.839: E/AndroidRuntime(22097):    ... 12 more

Warning Message - when Activity is configured to manage orientation changes itself

03-16 10:47:50.804: W/Filter(22739): An exception occured during performFiltering()!
03-16 10:47:50.804: W/Filter(22739): android.database.StaleDataException: Attempted to access a cursor after it has been closed.
03-16 10:47:50.804: W/Filter(22739):    at android.database.BulkCursorToCursorAdaptor.throwIfCursorIsClosed(BulkCursorToCursorAdaptor.java:75)
03-16 10:47:50.804: W/Filter(22739):    at android.database.BulkCursorToCursorAdaptor.getCount(BulkCursorToCursorAdaptor.java:81)
03-16 10:47:50.804: W/Filter(22739):    at android.database.CursorWrapper.getCount(CursorWrapper.java:57)
03-16 10:47:50.804: W/Filter(22739):    at android.widget.CursorFilter.performFiltering(CursorFilter.java:53)
03-16 10:47:50.804: W/Filter(22739):    at android.widget.Filter$RequestHandler.handleMessage(Filter.java:234)
03-16 10:47:50.804: W/Filter(22739):    at android.os.Handler.dispatchMessage(Handler.java:99)
03-16 10:47:50.804: W/Filter(22739):    at android.os.Looper.loop(Looper.java:137)
03-16 10:47:50.804: W/Filter(22739):    at android.os.HandlerThread.run(HandlerThread.java:60)

Upvotes: 3

Views: 2828

Answers (2)

Qubitium
Qubitium

Reputation: 842

If you are using a Loader to manage your Adapter's Cursor then you need to avoid adapter.filter() usage at all cost. Since the filter() within adapter expects a Cursor in return which is impossible since loaders are async and runs in a background thread.

How to Replace adapter.filter() with Loaders:

  1. Within onSelect/Item/TextInput listener, store the selected/input values in a class variable.

  2. Replace all calls to myAdapter.filter() with myLoadManager.restartLoader(...).

  3. In your onCreateLoader() method, using the stored selected/inputted values you now have because of step 1, dynamically generate your sql/cursor query and run it.

This is how the sequence of events will play out:

  1. User selects a spinner drop down and selects "USA".

  2. You store "USA" in a class variable and then call myLoadManager.restartLoader(...).

  3. Load manager destroys the previous load manager and create a new one calling onCreateLoader() which has your auto-generate query code.

  4. New loader runs the updated query and notifies your adapter to refresh & redraw it's data.

Why do you want to use Loaders?

  1. Free/easier async management of non-blocking UI updates based on slow data. Much easier than AsyncTasks.

  2. Free memory management of your cursors. Will auto clean/close Cursors during re-query or activity close.

Upvotes: 0

losttime
losttime

Reputation: 1141

I found the solution (a solution) to my problem came in two parts:

  1. I couldn't continue generating cursors manually in my CursorAdapter. I had to start using the Loader Manager
  2. I didn't need to hang on to any cursors.

Note: For anyone following, I'm still getting a bit of an error while running this code, but it's not fatal and it doesn't seem to be related to cursors, so I'm not addressing it here.

Generating Cursors within the CursorAdapter

The big complication is that the runQueryOnBackgroundThread method in the CursorAdapter requires that a cursor be returned. When using LoaderManager's, you don't get your hands on a cursor until an asynchronous callback, and that has downsides:

  1. You can't generate and retrieve the cursor from within the runQueryOnBackgroundThread method.
  2. You can't generate the cursor early because runQueryOnBackgroundThread is the first method called with the new filtering constraint.
  3. The runQueryOnBackgroundThread method hands its cursor off to the changeCursor method, which closes the cursors that are changed out (something we don't do when working with a LoaderManager/CursorLoader), so we don't want to follow that workflow anyway.

By default, the runQueryOnBackgroundThread method of the CursorAdapter just calls the runQuery method of the CursorAdapter's FilterQueryProvider if it has been defined. I opted to define the FilterQueryProvider rather than override the runQueryOnBackgroundThread method for a few reasons:

  • The FilterQueryProvider could be defined from the Activity that instantiated the CursorAdapter, and utilizing the LoaderManager from the Activity was easier that utilizing it from within the CursorAdapter.
  • I prefer to let the code do what it was meant to do, if at all possible.

Note: The runQuery method still requires that a cursor be returned, so we don't get to skirt that issue.

I decided to generate a dummy cursor in my FilterQueryProvider's runQuery method. Then, since that dummy cursor would be handed off to the CursorAdapter's changeCursor method, I overrode changeCursor to simply close every cursor it was passed.

The runQuery method also initiates the asynchronous LoaderManager call that includes the filtering constraint. Then the LoaderManager callbacks take care of swapping in the new cursors that are generated.

Note: Ideally, I suppose you could override the function that calls runQueryOnBackgroundThread, and have it make the asynchronous LoaderManager call, but I couldn't figure out what that was.

Hanging on to Cursors

I was trying to distinguish between unfiltered and filtered cursors to the unfiltered cursor could be used when the filtering constraint was null. After reading Android 3.0 - what are the advantages of using LoaderManager instances exactly? for the umpteenth time, I realized that the accepted answer was using the same CursorLoader to generate all cursors.

Instead of trying to hang on to the original unfiltered cursor, I decided I'd just generate a new unfiltered cursor whenever I needed it. The onCreateLoader LoaderManager callback became a bit more complex (but more like the examples I had been seeing), and the onLoadFinished callback became much more simple (like the examples I had been seeing).

Source Code

Activity - home.java

public class Home extends Activity implements LoaderManager.LoaderCallbacks<Cursor> {

// Constants
private static final String TAG = "Home";
private static final boolean DEBUG = true;
public static final int LOADER_CONTACTS_CURSOR = 1;

// Variables
private AdapterContacts adapter;



/**
 * Overrides
 */

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Set the view
    setContentView(R.layout.home);

    // Initialize CursorAdapter
    adapter = new AdapterContacts(this, null, 0);
    final LoaderManager.LoaderCallbacks<Cursor> iFace = this;
    adapter.setFilterQueryProvider(new FilterQueryProvider() {
        public Cursor runQuery(CharSequence constraint) {
            if (constraint != null) {
                Bundle bundle = new Bundle();
                bundle.putCharSequence("constraint", constraint);
                getLoaderManager().restartLoader(Home.LOADER_CONTACTS_CURSOR, bundle, iFace);
            } else {
                getLoaderManager().restartLoader(Home.LOADER_CONTACTS_CURSOR, null, iFace);
            }
            return getContentResolver().query(ContactsContract.Contacts.CONTENT_URI, null, null, null, null);
        }
    });

    // Attach CursorAdapter to AutoCompleteTextView field
    AutoCompleteTextView field = (AutoCompleteTextView) findViewById(R.id.newPlayer_edit);
    field.setAdapter(adapter);

    // Initialize Cursor using LoaderManagers
    LoaderManager.enableDebugLogging(true);
    getLoaderManager().initLoader(LOADER_CONTACTS_CURSOR, null, this);
}

@Override
public void onDestroy() {
    if (DEBUG) Log.i(TAG, "Destroying activity");
    super.onDestroy();
}

@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
    if (DEBUG) Log.i(TAG, "Loader Callback: creating loader");
    Uri baseUri;

    if (args != null) {
        CharSequence constraint = args.getCharSequence("constraint");
        if (DEBUG) Log.i(TAG, "Constraint: " + constraint.toString());
        baseUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_FILTER_URI, Uri.encode(constraint.toString()));
    } else {
        if (DEBUG) Log.i(TAG, "No Constraint");
        baseUri = ContactsContract.Contacts.CONTENT_URI;
    }
    return new CursorLoader(this, baseUri, null, null, null, null);
}

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
    if (DEBUG) Log.i(TAG, "Loader Callback: load finished");
    adapter.swapCursor(cursor);
}

@Override
public void onLoaderReset(Loader<Cursor> loader) {
    if (DEBUG) Log.i(TAG, "Loader Callback: resetting");
    adapter.swapCursor(null);
}
}

CursorAdapter - AdapterContacts.java

public class AdapterContacts extends CursorAdapter {

// Constants
private static final String TAG = "AdapterContacts";
private static final boolean DEBUG = true;

// Variables
private TextView mName;

/**
 * Constructor
 */
public AdapterContacts(Context context, Cursor c, int flags) {
    super(context, c, flags);
}

/**
 * Overrides
 */

@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
    // Inflate the views that create each row of the dropdown list
    final LayoutInflater inflater = LayoutInflater.from(context);
    final LinearLayout ret = new LinearLayout(context);
    ret.setOrientation(LinearLayout.VERTICAL);

    mName = (TextView) inflater.inflate(android.R.layout.simple_dropdown_item_1line, parent, false);
    ret.addView(mName, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));

    /*
    int nameIdx = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY);
    mName.setText(cursor.getString(nameIdx));
    */

    return ret;
}

@Override
public void bindView(View view, Context context, Cursor cursor) {
    // Fill the dropdown row with data from the cursor
    int nameIdx = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY);
    String name = cursor.getString(nameIdx);
    ((TextView) ((LinearLayout) view).getChildAt(0)).setText(name);
}

@Override
public String convertToString(Cursor cursor) {
    // Convert the dropdown list entry that the user clicked on
    // into a string that will fill the AutoCompleteTextView
    int nameCol = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY);
    return cursor.getString(nameCol);
}

@Override
public void changeCursor(Cursor newCursor) {
    newCursor.close();
}
}

Upvotes: 6

Related Questions