androidclientcursorloader

Why isn't the CursorLoader returning a Cursor when it has finished loading?


I have two experimental apps: BookStoreProvider which is has a ContentProvider which overlays an SQLiteDatabase containing dummy book data (_ID, _TITLE, _AUTHOR and _YEAR)

and:

BookStoreClient which is a separate client app that accesses the BookStore Provider.

When I access the ContentProvder from within the BookStoreProvider app, there's no problem. A table showing the dummy date is shown in the UI. However, when I try to access the provider from the client app (BookStoreClient) there is a NullPointerException: java.lang.NullPointerException: Attempt to invoke interface method 'boolean android.database.Cursor.moveToNext()' on a null object reference at com.mo.bookstoreclient.MainActivity.onLoadFinished(MainActivity.java:57)

It seems that the Cursor passed to onLoadFinished() method is null, but I can't understand why this is so.

For the sake of simplicity, there are no permissions specified. The provider is accessible to all apps.

The BookStoreProvider app classes: BooksContract AssetCopier (helper class, copies database from Assets folder into Internal Storage on intall) BookStoreProvider MainActivity

public class BooksContract {

    // Authority of provider
    public static final String AUTHORITY = "com.mo.bookstoreprovider";

    // Content Uri of the table called fiction
    public static final String URL = "content://" + AUTHORITY + "/fiction";
    public static Uri CONTENT_URI_TABLE = Uri.parse(URL);

    // Uri match codes:
    public static final int TABLE_MATCH_CODE = 1;
    public static final int ROW_MATCH_CODE = 2;

    // Uri MIME types - they will not be used they are not defined.

    // Database and table names, version and column names
    public static final String DB_NAME = "Books.db";
    public static final String TABLE = "fiction";
    public static final int VERSION = 1;

    public static final String ID_COLUMN = "_ID";
    public static final String TITLE_COLUMN = "_TITLE";
    public static final String AUTHOR_COLUMN = "_AUTHOR";
    public static final String YEAR_COLUMN = "_YEAR";

}
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.util.Log;

public class BookStoreProvider extends ContentProvider {
    public SQLiteDatabase db;
    public UriMatcher uriMatcher;
    public Cursor cursor;

    public BookStoreProvider() {
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        // Implement this to handle requests to delete one or more rows.
        throw new UnsupportedOperationException("Not yet implemented");
    }

    @Override
    public String getType(Uri uri) {
        // TODO: Implement this to handle requests for the MIME type of the data
        // at the given URI.
        throw new UnsupportedOperationException("Not yet implemented");
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        // TODO: Implement this to handle requests to insert a new row.
        throw new UnsupportedOperationException("Not yet implemented");
    }

    @Override
    public boolean onCreate() {
        // TODO: Implement this to initialize your content provider on startup.

        // Use DBHelper to obtain a reference to the underlying database
        DBHelper dbHelper = new DBHelper(getContext());
        db = dbHelper.getWritableDatabase();

        // Create a UriMatcher object and create a tree of Uri matches starting from no match
        uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        uriMatcher.addURI(BooksContract.AUTHORITY, "fiction", BooksContract.TABLE_MATCH_CODE); // matches the uri of the table
        uriMatcher.addURI(BooksContract.AUTHORITY, "fiction" + "/#", BooksContract.ROW_MATCH_CODE); // matches row number # in table

        return true;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        // TODO: Implement this to handle query requests from clients.


        switch (uriMatcher.match(uri)) {
            case (BooksContract.TABLE_MATCH_CODE) :
                // Call the database's query method
                cursor = db.query(BooksContract.TABLE, projection,
                        selection, selectionArgs, null, null, null);
                break;
            case (BooksContract.ROW_MATCH_CODE) :
                // Get the row number from the uri
                String rowNumber = uri.getLastPathSegment();
                Log.i("Message : ", " the row number was : " + rowNumber);

                cursor = db.query(BooksContract.TABLE, projection, "_ID = ?", new String[] {rowNumber},
                        null, null, null);
                break;

        }
        return cursor;
        //throw new UnsupportedOperationException("Not yet implemented");
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {
        // TODO: Implement this to handle requests to update one or more rows.
        throw new UnsupportedOperationException("Not yet implemented");
    }
}
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;

import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;

import java.io.IOException;

public class MainActivity extends FragmentActivity implements LoaderManager.LoaderCallbacks<Cursor> {

    private static final int LOADER_ID = 1; // A unique ID for the loader

    TextView tableView;

    @Override
    protected void onStart() {
        super.onStart();

        // Initialise the LoaderManager
        LoaderManager loaderManager = LoaderManager.getInstance(this);
        loaderManager.initLoader(LOADER_ID, null, this);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        tableView = (TextView) findViewById(R.id.textview_display_table);

        AssetCopier assetCopier = new AssetCopier(getApplicationContext(), "Books.db", "Books.db");
        try {
            assetCopier.copyFromAssets("Books.db");
        } catch (IOException e) {
            throw new RuntimeException("IOException Occurred" + e.toString());
        }

    }


    @NonNull
    @Override
    public Loader<Cursor> onCreateLoader(int id, @Nullable Bundle args) {
        // Define the data you want to retrieve from the ContentProvider
        Uri contentUri = BooksContract.CONTENT_URI_TABLE;
        String[] projection = {BooksContract.ID_COLUMN,
                BooksContract.TITLE_COLUMN, BooksContract.AUTHOR_COLUMN, BooksContract.YEAR_COLUMN};
        return new CursorLoader(this, contentUri, projection, null, null, null);
    }

    @Override
    public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
        // This method is called when the data is loaded. You can update your UI here.
        if (loader.getId() == LOADER_ID) {
            if (data.moveToNext()) {
                StringBuilder sb = new StringBuilder();
                while (!data.isAfterLast()) {
                    sb.append("\n" + data.getString(data.getColumnIndex(BooksContract.ID_COLUMN))
                            + " " + data.getString(data.getColumnIndex((BooksContract.TITLE_COLUMN)))
                            + " " +data.getString(data.getColumnIndex(BooksContract.AUTHOR_COLUMN))
                            + " " +data.getString(data.getColumnIndex(BooksContract.YEAR_COLUMN)));

                    Log.i("Message :", "the value of sb is : " + sb);
                    data.moveToNext();
                }
                tableView.setText(sb);
            }
        }
    }

    @Override
    public void onLoaderReset(@NonNull Loader<Cursor> loader) {
        // This method is called when the loader is being reset.
        if (loader.getId() == LOADER_ID) {
            // Handle any cleanup if necessary
        }

    }
}

The BookStoreClient App is a single activity app which is identical to the MainActivity of the BookStoreProvider app. Here it is for completeness:

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;

import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;

public class MainActivity extends FragmentActivity implements LoaderManager.LoaderCallbacks<Cursor> {

    private static final int LOADER_ID = 1;

    public TextView tableView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        tableView = (TextView) findViewById(R.id.textview_display_table);
    }

    @Override
    protected void onStart() {
        super.onStart();

        // Initialize the LoaderManager
        LoaderManager loaderManager = LoaderManager.getInstance(this);
        loaderManager.initLoader(LOADER_ID, null, this);
    }


    @NonNull
    @Override
    public Loader<Cursor> onCreateLoader(int id, @Nullable Bundle args) {
        // define the data you want to retrieve from the ContentProvider
        Uri contentUri = BooksContract.CONTENT_URI_TABLE;
        String[] projection = {BooksContract.ID_COLUMN, BooksContract.TITLE_COLUMN, BooksContract.AUTHOR_COLUMN,
                                    BooksContract.YEAR_COLUMN};

        return new CursorLoader(this, contentUri, projection, null, null, null);
    }

    @Override
    public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {

        // This method is called when the data is loaded. You update your UI here
        if (loader.getId() == LOADER_ID) {
            if (data.moveToNext()) {
                StringBuilder sb = new StringBuilder();
                while (!data.isAfterLast()) {
                    sb.append("\n" + data.getString(data.getColumnIndex(BooksContract.ID_COLUMN))
                            + " " + data.getString(data.getColumnIndex((BooksContract.TITLE_COLUMN)))
                            + " " +data.getString(data.getColumnIndex(BooksContract.AUTHOR_COLUMN))
                            + " " +data.getString(data.getColumnIndex(BooksContract.YEAR_COLUMN)));
                    Log.i("Message : ", "The value of sb is : " + sb);
                    data.moveToNext();
                }
                tableView.setText(sb);
            }
        }
    }

    @Override
    public void onLoaderReset(@NonNull Loader<Cursor> loader) {

    }
}

I was expecting the client app to return and display the Books table in its UI. The client app's UI and MainActivity are identical to the one in the provider.


Solution

  • I have found the answer.

    My apps (BookStoreProvider and BookStoreClient) target Android API Level 33 and the problem is related to provider app visibility.

    According to these Android Developer Guides: Know which packages are visible automatically and Declare Package Visibility Needs, the <queries> tag specifies other apps that your app (e.g a client) might interact with. These apps can be specified by by package name, intent-filter signature or provider authority.

    So in my case adding the following inside the BookStore Client element:

    <queries>
        <provider android:authorities = "com.mo.bookstoreprovider"/>
    </queries>
    

    explicitly declares that it will be accessing the BookStore provider. This solved the problem completely.

    However, there is one puzzling issue - and that is about mutual visibility. The guides mentioned above state that "any app that accesses a content provider in your app" should be automatically visible. So the BookStoreProvider should be aware of the BookStore Client, but is the BookStore Client aware of the BookStore Provider? Clearly not, otherwise there would be no need to specify a <queries> element. Perhaps it should be a question for a separate post.