Android-Programmierung: Content-Provider
Willemers Informatik-Ecke

Ein Content-Provider läuft im Hintergrund und bietet den Apps Daten an. Diese Technik wird auch vom System verwendet. So ist die in Android eingebaute Kontaktdatenbank über einen internen Content-Provider erreichbar. Der Content-Provider Browser stellt Favoriten und Bookmarks zur Verfügung. Aber auch die Foto-Galerie ist ein Content-Provider.

Ein eigener Content-Provider

Um eigene Daten in einem Content-Provider anzubieten, erweitert man die Klasse ContentProvider und macht einen Eintrag in der AndroidManifest.xml-Datei. Beides wird von Android-Studio vorbereitet, wenn man folgende Menüsequenz bemüht.

File | New | Other | Content Provider

Es erscheint ein Dialog, der als Klassennamen MyContentProvider vorschlägt. Im Beispiel wird HitProvider verwendet.

Als zweites fragt der Dialog nach URI Authorities. Hier wird eine umgekehrte Domain, dann der Provider und anschließend ein Bezeichner für die Content-Applikation angegeben, beispielsweise:

de.willemer.provider.hits
Später wird dieser Content-Provider über die folgende URL zugegriffen:
content://de.willemer.provider.hits

Der Manifestdatei-Eintrag

In der Manifestdatei AndroidManifest.xml-Datei wird automatisch der folgende Eintrag hinzugefügt:
<provider
    android:name=".HitProvider"
    android:authorities="de.willemer.provider.hits"
    android:enabled="true"
    android:exported="true">
</provider>

Die ContentProvider-Klasse anpassen

Nach dem Ausfüllen der Dialogbox legt Android-Studio reichlich Code für eine Klasse an, die ContentProvider erweitert. ContentProvider ist eine abstrakte Klasse, deren abstrakte Methoden hier erweitert werden.

onCreate schaltet ContentProvider aktiv

Die Methode onCreate muss auf den Rückgabewert true umgestellt werden, um anzuzeigen, dass der ContentProvider seine Aufgabe übernimmt. Sollte bei der Initialisierung etwas schieflaufen, gibt die Methode false zurück. Der Provider wird in dem Fall nicht aktiv.
    @Override
    public boolean onCreate() {
        return true;
    }

getType liefert den Datentyp

Die Methode getType liefert einen String zurück, der den MIME-Typ der Daten enthalten soll. Für den ersten Test verwenden wir einen einfachen String, der unter MIME als text/plain gekennzeichnet wird.
    @Override
    public String getType(Uri uri) {
        return "text/plain";
    }
Der Datentyp kann abhängig von der URI eingestellt werden.

Datenoperationen

Für die Datenoperationen gibt es die Methoden delete, insert, query und update. Die vom Android-Studio vorgelegten Methoden werfen alle eine Exception, damit klar wird, dass hier noch etwas erweitert werden muss.

Für den ersten Test werden wir einfach eine Meldung per Log.d absetzen, damit man sieht, dass die Anfrage stattgefunden hat.

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        Log.d("HitProvider", "insert() aufgerufen");
        return null;
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        Log.d("HitProvider", "delete() aufgerufen");
        return 0;
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {
        Log.d("HitProvider", "update() aufgerufen");
        return 0;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        Log.d("HitProvider", "query() aufgerufen");
        return null;
    }

Testaufruf des Providers

Die MainActivity wird durch einen Button ergänzt, der nur dazu dient, den ContentProvider einmal zu starten. Der Button muss natürlich zunächst in der Datei activity_main.xml angelegt werden. Am einfachsten geht das mit dem Designer. Sie können ihn aber auch von Hand einfügen.
    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />
Dieser Button wird in der Activity geladen und mit dem eigenen OnClickListener verbunden. Wählt jemand den Button an, wird die Methode onClick ausgelöst.
public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(this);
    }

    @Override
    public void onClick(View view) {
        ContentValues content = new ContentValues();
        getContentResolver().insert(Uri.parse("content://de.willemer.provider.hits/data"), content);
    }
}
Hier wird ein Objekt der Klasse ContentValues angelegt. Diese kann später mit Inhalten gefüllt werden, die übertragen werden. Zunächst soll aber nur getestet werden, ob der ContentProvider überhaupt arbeitet.

Sie holen sich den aktuellen ContentResolver und rufen darüber insert auf. Als Parameter wird die URI angegeben, die Sie zugreifen wollen.

Als Ergebnis sollte die Methode insert aufgerufen werden, die im Logcat eine Zeile wie diese hinterlässt:

... HitProvider: insert() aufgerufen
Falls dem nicht so ist, wurde vielleicht die URI nicht überall korrekt eingetragen.

Nun das Ganze mit Daten

Ein ContentProvider ohne Daten ist natürlich etwas unsinnig. Darum wollen wir das Gerippe mit Daten füllen. Der besseren Übersicht erstellen wir einen neuen ContentProvider wieder mit File | New | Other | Content Provider. Wir machen folgende Angaben in dem Dialog:

Datenbankvorbereitungen

Für die Daten wird eine kleine Datenbank aufgebaut. Das ist bei Android beinahe einfacher als eine Java-Collection, die überdies ein Programmende nicht überlebt.

Im ersten Schritt wird eine Datenbank-OpenHelper-Klasse erstellt. Die kümmert sich vor allem darum, dass die Datenbank angelegt wird, falls sie noch nicht existiert und ggf um Erweiterungen oder Veränderungen an der Datenbankstruktur.

public class LagerDbHelper extends SQLiteOpenHelper {

    static final String DATABASE_NAME = "Lager";
    static final String TABLE_NAME = "ware";
    static final int DATABASE_VERSION = 1;
    // Felder
    static final String _ID = "_id";
    static final String BEZ = "bezeichnung";
    static final String ANZAHL = "anzahl";

    public LagerDbHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        final String CREATE_DB_TABLE =
                " CREATE TABLE " + TABLE_NAME +
                        " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                        " bezeichnung TEXT NOT NULL, " +
                        " anzahl INTEGER);";
        db.execSQL(CREATE_DB_TABLE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int i, int i1) {
            db.execSQL("DROP TABLE IF EXISTS " +  TABLE_NAME);
            onCreate(db);
    }
}
Die eigentlich Datenbank wird in der ContentProvider-Klasse angelegt und in der Methode onCreate initialisiert.
public class LagerProvider extends ContentProvider {

    private SQLiteDatabase db;

    @Override
    public boolean onCreate() {
        Context context = getContext();
        LagerDbHelper dbHelper = new LagerDbHelper(context);
        db = dbHelper.getWritableDatabase();
        return (db == null)? false:true;
    }
    // ...
}
Ist das Erstellen der Datenbank nicht gelungen, dann meldet onCreate false zurück und der ContentProvider geht nicht in Produktion.

Einfügen eines Datensatzes

Gibt es etwas traurigeres als eine leere Datenbank? Wir wollen Abhilfe schaffen. Wir ändern die Methode insert so, dass sie die übergebenen Daten in die vorbereitete Datenbank füllt. Dazu ruft sie ihrerseits die Methode insert der Datenbank auf. Den Inhalt des Satzes bekommt sie vom Aufrufer in dem Objekt der Klasse ContentValues übergeben. Wie der entsteht, schauen wir uns gleich an. Hier brauchen wir diese einfach nur durchzureichen.

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        long rowID = db.insert( LagerDbHelper.TABLE_NAME, "", values);
        if (rowID > 0) {
            Uri _uri = ContentUris.withAppendedId(CONTENT_URI, rowID);
            getContext().getContentResolver().notifyChange(_uri, null);
            return _uri;
        }
        throw new SQLException("Failed to add a record into " + uri);
    }
Der größte Teil der Methode befasst sich mit dem Rückgabewert der Datenbankoperation. War diese erfolgreich, liefert sie einen Wert größer als 0. Der Aufrufer des ContentProviders will dann aber die URL des neu erstellten Elements wissen. Diese wird ihm als Uri zurückgegeben.

Sollte die Datenbank allerdings versagen, wirft der ContentProvider statt eines Handtuchs eine Exception.

Aufruf

In der MainActivity soll auf den Button-Klick hin ein Paket von 150 Lakritzschnecken in das Warenlager erfolgen. Wie oben versprochen, sehen Sie hier, wie ein Objekt von ContentValues erzeugt wird. Mit der Methode put können die Feldnamen und ihr Inhalt angegeben werden.

Über den ContentResolver wird insert aufgerufen. Als Parameter wird insert die URL des ContentProviders und der ContentValue übergeben. Als Rückgabewert erhält der Aufrufer die URI, unter der die Lakritzschnecken ansprechbar sind.

    public void onClick(View view) {
        ContentValues content = new ContentValues();
        content.put(LagerDbHelper.BEZ, "Lakritzschnecken");
        content.put(LagerDbHelper.ANZAHL, 150);
        Uri uri = getContentResolver().insert(Uri.parse(
                "content://"+LagerProvider.PROVIDER_NAME), content);
        Log.d("Abgespeichert unter: ", uri.toString());
    }

Ein paar Konstanten

So wie die Strings für die Datenbank in der Helper-Klasse als Konstanten definiert haben, können wir diese auch im ContentProvider gut gebrauchen.
static final String PROVIDER_NAME = "de.willemer.provider.ware";
static final String PATH = "lager";
static final String URL = "content://" + PROVIDER_NAME + "/" + PATH;

Auslesen der Daten

Das Auslesen von Daten ist etwas komplizierter als das Einfügen. Die ContentProvider-Klasse überschreibt dazu die Methode query. Diese muss zwei grundlegend unterschiedliche Arten von Anfragen beantworten. Einmal die allgemeine Bitte, den kompletten oder gefilterten Lagerbestand aufzulisten oder auch die Anfrage, ein spezielles Paket von Lakritzschnecken zu liefern. Was von beiden benötigt wird, erschließt sich aus der URI.

Der UriMatcher

Ja nachdem, wie die URL aussieht, kann eine Liste aller Elemente oder ein speziellen Elements über ihre ID gefordert werden. Es ist auch denkbar, dass eine andere Tabelle im Pfad angefordert wird. Anstatt den String der URL selbst zu zerlegen, hilft der UriMatcher. Er wird gern in einem ContentProvider als statische Variable gesetzt und dann im statischen Konstruktor erzeugt und mit Pfaden bestückt. Jedem Pfad wird dann eine Nummer zugeordnet.
    static final UriMatcher uriMatcher;
    static{
        uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        uriMatcher.addURI(PROVIDER_NAME, PATH, 1);
        uriMatcher.addURI(PROVIDER_NAME, PATH+"/#", 2);
    }
Hier wurde einerseits dem Pfad ohne Schlüssel der Wert 1 und dem Pfad mit einem Schlüssel der Wert 2. Anschließend kann der Uri-Matcher befragt werden, welchen Typs der Pfad ist. Der Ausdruck uriMatcher.match(uri) liefert also bei Pfaden mit Schlüsseln eine 2.

Die Methode query im ContentProvider

Um die query an die Datenbank weiterzuleiten, wird ein QueryBuilder eingesetzt. Dieser wird mit der Methode setTables auf die Tabelle gesetzt. Da in unserem Beispiel nur eine Tabelle existiert, kann das ohne nähere Betrachtung der Uri erfolgen.

Anschließend wird anhand der Uri festgestellt, ob über den Primärschlüssel zugegriffen werden soll. Dazu steht der UriMatcher hilfreich zur Seite. Wird nur ein Element benötigt, muss genau dieses anhand seiner Id ermittelt werden. Dazu muss eine dem QueryBuilder eine Where-Bedingung mit appendWhere hinzugefügt werden.

Im nächsten Schritt wird über den QueryBuilder die Methode query aufgerufen, die dann einen Cursor liefert, der einfach an den Aufrufer zurückgegeben wird. Soll der sich doch mit dem Auslesen der Ergebnisse befassen.

  @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
        queryBuilder.setTables(LagerDbHelper.TABLE_NAME);
        switch (uriMatcher.match(uri)) {
            case 1:
                break;
            case 2:
                queryBuilder.appendWhere(LagerDbHelper._ID
                        + "=" + uri.getPathSegments().get(1));
                break;
            default:
                throw new IllegalArgumentException("Unknown URI " + uri);
        }
        Cursor c = queryBuilder.query(db, projection, selection,
                                      selectionArgs,null, null, sortOrder);
        c.setNotificationUri(getContext().getContentResolver(), uri);
        return c;
    }

Der Aufruf der ContentProvider-query

Wir kommen zur MainActivity zurück. Dort wollen wir nun den Inhalt des ContentProviders auslesen. Dazu wird der ContentResolver verwendet. Er besitzt auch eine Methode query. Dieser benötigt als Parameter:
  1. die Uri
  2. die Projektion
  3. die Selektion
  4. die Sektionsargumente
  5. die Sortierreihenfolge
Die nicht benötigten Parameter können wir auf null setzen. In diesem Fall ermitteln wir alle Spalten und Zeilen der Ergebnismenge. Allerdings sortieren wir sie nach der Id.
    Uri queryUri = Uri.parse(LagerProvider.URL);
    Cursor c = getContentResolver().query(queryUri, null, null, null, LagerDbHelper._ID);
    if (c.moveToFirst()) {
        do {
            Log.d("MainActivity-Query",
                    c.getString(c.getColumnIndex(LagerDbHelper._ID)) +
                    ", " + c.getString(c.getColumnIndex(LagerDbHelper.BEZ)) +
                    ", " + c.getString(c.getColumnIndex(LagerDbHelper.ANZAHL)));
        } while (c.moveToNext());
    }
    c.close();
Das Ergebnis ist ein Cursor, den man als Zeiger auf die Ergebnismenge begreifen kann. Wir setzen ihn mit moveToFirst auf das erste Element und führen in einer Schleife moveToNext aus, bis es keine Elemente mehr gibt.

Der Zugang zu den Daten erfolgt über die Methode getString. Deren Parameter ist der Spaltenindex, den wir wiederum über den Namen der Spalten per getColumnIndex ermitteln.

Es gehört sich, den Cursor nach Gebrauch mit close wieder zu schnließen.

Update - Änderungen

Für einen Update gibt es wie beim Query zwei Varianten. Einmal über den Primärschlüssel, um ein Element zu ändern, einmal über eine Where-Klausel und eine Menge von Daten.

ContentProvider-update

Die Unterscheidung fällt mit dem Uri-Matcher, wie oben gesehen. Im Falle, dass alle Elemente betroffen sind, werden die Paramter einfach an das Datenbank-Update weitergereicht.

Im anderen Fall, wird die WHERE-Bedingung für eine Suche nach dem Primärschlüssel erzeugt und der übergebenen Selektion vorangestellt.

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {

        int aenderungen = 0;
        switch (uriMatcher.match(uri)) {
            case 1: // alle
                aenderungen = db.update(LagerDbHelper.TABLE_NAME,
                                        values, selection, selectionArgs);
                break;
            case 2: // Einzelfall über Schlüssel
                String zeile = uri.getLastPathSegment();
                selection = LagerDbHelper._ID + "=" + zeile
                        + (!TextUtils.isEmpty(selection) ? " AND ("
                        + selection + ')' : "");
                aenderungen = db.update(LagerDbHelper.TABLE_NAME,
                        values, selection, selectionArgs);
                break;
        }
        // informiere die Observer über Datenänderung
        getContext().getContentResolver().notifyChange(uri, null);
        return aenderungen;
    }

Aufruf des Updates per ContentResolver

Der folgende Aufruf wird dafür sorgen, dass von allen Einträgen ab sofort die Anzahl auf 120 gesetzt wird. Dazu wird über den ContentResolver die Methode update aufgerufen. Diese wiederum erhält die URL und den Inhalt in dem Objekt von ContentValues.
ContentValues content = new ContentValues();
content.put(LagerDbHelper.ANZAHL, 120);
int aenderungen = getContentResolver().update(Uri.parse(LagerProvider.URL), content, null, null);
Log.d("Anzahl der Änderungen: ", ""+aenderungen);
Soll nur das Element mit dem Primärschlüssel 15 erreicht werden, wird mit einem Schrägstrich getrennt, der Primärschlüssel an die URL gehängt. Das führt dazu, dass der UriMatcher im ContentProvider umschaltet.
int aenderungen = getContentResolver().update(Uri.parse(LagerProvider.URL+"/15"), content, null, null);
Sollen alle Lakritzschnecken auf einen Bestand auf 65 verändert werden, dann wird eine Where-Bedingung gesetzt. Diese lautet bezeichnung = 'Lakritzschnecken'. Da die vollständige Bedingung im selection-String enthalten ist, sind Selektions-Argumente nicht mehr nötig. Sie können auf null gesetzt werden.
ContentValues contval = new ContentValues();
contval.put(LagerDbHelper.ANZAHL, 65);
String where = LagerDbHelper.BEZ + " = 'Lakritzschnecken'";
aenderungen = getContentResolver().update(Uri.parse(LagerProvider.URL), contval, where, null);
Die Konstanten in der Selektionsbedingung können allerdings auch durch ein Fragezeichen als Platzhalter offen gehalten werden. Dann werden die Werte für die Fragezeichen vom ContentProvider bzw. der Datenbank einem String-Array entnommen, das als Selektionsargumente übergeben wird.
ContentValues contval = new ContentValues();
contval.put(LagerDbHelper.ANZAHL, 65);
String where = LagerDbHelper.BEZ + " = ?"; 
String[] args = {"Lakritzschnecken"};
aenderungen = getContentResolver().update(Uri.parse(LagerProvider.URL), contval, where, args);