1 /*
2  * Copyright (C) 2011 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.example.android.supportv4.app;
18 
19 //BEGIN_INCLUDE(complete)
20 
21 import android.content.ContentProvider;
22 import android.content.ContentResolver;
23 import android.content.ContentUris;
24 import android.content.ContentValues;
25 import android.content.Context;
26 import android.content.UriMatcher;
27 import android.database.Cursor;
28 import android.database.SQLException;
29 import android.database.sqlite.SQLiteDatabase;
30 import android.database.sqlite.SQLiteOpenHelper;
31 import android.database.sqlite.SQLiteQueryBuilder;
32 import android.net.Uri;
33 import android.os.Bundle;
34 import android.provider.BaseColumns;
35 import android.text.TextUtils;
36 import android.util.Log;
37 import android.view.Menu;
38 import android.view.MenuInflater;
39 import android.view.MenuItem;
40 import android.view.View;
41 import android.widget.ListView;
42 
43 import androidx.core.database.DatabaseUtilsCompat;
44 import androidx.cursoradapter.widget.SimpleCursorAdapter;
45 import androidx.fragment.app.FragmentActivity;
46 import androidx.fragment.app.FragmentManager;
47 import androidx.fragment.app.ListFragment;
48 import androidx.loader.app.LoaderManager;
49 import androidx.loader.content.CursorLoader;
50 import androidx.loader.content.Loader;
51 
52 import org.jspecify.annotations.NonNull;
53 import org.jspecify.annotations.Nullable;
54 
55 import java.util.HashMap;
56 
57 /**
58  * Demonstration of bottom to top implementation of a content provider holding
59  * structured data through displaying it in the UI, using throttling to reduce
60  * the number of queries done when its data changes.
61  */
62 public class LoaderThrottleSupport extends FragmentActivity {
63     // Debugging.
64     static final String TAG = "LoaderThrottle";
65 
66     /**
67      * The authority we use to get to our sample provider.
68      */
69     public static final String AUTHORITY = "com.example.android.apis.supportv4.app.LoaderThrottle";
70 
71     /**
72      * Definition of the contract for the main table of our provider.
73      */
74     public static final class MainTable implements BaseColumns {
75 
76         // This class cannot be instantiated
MainTable()77         private MainTable() {}
78 
79         /**
80          * The table name offered by this provider
81          */
82         public static final String TABLE_NAME = "main";
83 
84         /**
85          * The content:// style URL for this table
86          */
87         public static final Uri CONTENT_URI =  Uri.parse("content://" + AUTHORITY + "/main");
88 
89         /**
90          * The content URI base for a single row of data. Callers must
91          * append a numeric row id to this Uri to retrieve a row
92          */
93         public static final Uri CONTENT_ID_URI_BASE
94                 = Uri.parse("content://" + AUTHORITY + "/main/");
95 
96         /**
97          * The MIME type of {@link #CONTENT_URI}.
98          */
99         public static final String CONTENT_TYPE
100                 = "vnd.android.cursor.dir/vnd.example.api-demos-throttle";
101 
102         /**
103          * The MIME type of a {@link #CONTENT_URI} sub-directory of a single row.
104          */
105         public static final String CONTENT_ITEM_TYPE
106                 = "vnd.android.cursor.item/vnd.example.api-demos-throttle";
107         /**
108          * The default sort order for this table
109          */
110         public static final String DEFAULT_SORT_ORDER = "data COLLATE LOCALIZED ASC";
111 
112         /**
113          * Column name for the single column holding our data.
114          * <P>Type: TEXT</P>
115          */
116         public static final String COLUMN_NAME_DATA = "data";
117     }
118 
119     /**
120      * This class helps open, create, and upgrade the database file.
121      */
122    static class DatabaseHelper extends SQLiteOpenHelper {
123 
124        private static final String DATABASE_NAME = "loader_throttle.db";
125        private static final int DATABASE_VERSION = 2;
126 
DatabaseHelper(Context context)127        DatabaseHelper(Context context) {
128 
129            // calls the super constructor, requesting the default cursor factory.
130            super(context, DATABASE_NAME, null, DATABASE_VERSION);
131        }
132 
133        /**
134         *
135         * Creates the underlying database with table name and column names taken from the
136         * NotePad class.
137         */
138        @Override
onCreate(SQLiteDatabase db)139        public void onCreate(SQLiteDatabase db) {
140            db.execSQL("CREATE TABLE " + MainTable.TABLE_NAME + " ("
141                    + MainTable._ID + " INTEGER PRIMARY KEY,"
142                    + MainTable.COLUMN_NAME_DATA + " TEXT"
143                    + ");");
144        }
145 
146        /**
147         *
148         * Demonstrates that the provider must consider what happens when the
149         * underlying datastore is changed. In this sample, the database is upgraded the database
150         * by destroying the existing data.
151         * A real application should upgrade the database in place.
152         */
153        @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)154        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
155 
156            // Logs that the database is being upgraded
157            Log.w(TAG, "Upgrading database from version " + oldVersion + " to "
158                    + newVersion + ", which will destroy all old data");
159 
160            // Kills the table and existing data
161            db.execSQL("DROP TABLE IF EXISTS notes");
162 
163            // Recreates the database with a new version
164            onCreate(db);
165        }
166    }
167 
168     /**
169      * A very simple implementation of a content provider.
170      */
171     public static class SimpleProvider extends ContentProvider {
172         // A projection map used to select columns from the database
173         private final HashMap<String, String> mNotesProjectionMap;
174         // Uri matcher to decode incoming URIs.
175         private final UriMatcher mUriMatcher;
176 
177         // The incoming URI matches the main table URI pattern
178         private static final int MAIN = 1;
179         // The incoming URI matches the main table row ID URI pattern
180         private static final int MAIN_ID = 2;
181 
182         // Handle to a new DatabaseHelper.
183         private DatabaseHelper mOpenHelper;
184 
185         /**
186          * Global provider initialization.
187          */
SimpleProvider()188         public SimpleProvider() {
189             // Create and initialize URI matcher.
190             mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
191             mUriMatcher.addURI(AUTHORITY, MainTable.TABLE_NAME, MAIN);
192             mUriMatcher.addURI(AUTHORITY, MainTable.TABLE_NAME + "/#", MAIN_ID);
193 
194             // Create and initialize projection map for all columns.  This is
195             // simply an identity mapping.
196             mNotesProjectionMap = new HashMap<String, String>();
197             mNotesProjectionMap.put(MainTable._ID, MainTable._ID);
198             mNotesProjectionMap.put(MainTable.COLUMN_NAME_DATA, MainTable.COLUMN_NAME_DATA);
199         }
200 
201         /**
202          * Perform provider creation.
203          */
204         @Override
onCreate()205         public boolean onCreate() {
206             mOpenHelper = new DatabaseHelper(getContext());
207             // Assumes that any failures will be reported by a thrown exception.
208             return true;
209         }
210 
211         /**
212          * Handle incoming queries.
213          */
214         @Override
query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)215         public Cursor query(Uri uri, String[] projection, String selection,
216                 String[] selectionArgs, String sortOrder) {
217 
218             // Constructs a new query builder and sets its table name
219             SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
220             qb.setTables(MainTable.TABLE_NAME);
221 
222             switch (mUriMatcher.match(uri)) {
223                 case MAIN:
224                     // If the incoming URI is for main table.
225                     qb.setProjectionMap(mNotesProjectionMap);
226                     break;
227 
228                 case MAIN_ID:
229                     // The incoming URI is for a single row.
230                     qb.setProjectionMap(mNotesProjectionMap);
231                     qb.appendWhere(MainTable._ID + "=?");
232                     selectionArgs = DatabaseUtilsCompat.appendSelectionArgs(selectionArgs,
233                             new String[] { uri.getLastPathSegment() });
234                     break;
235 
236                 default:
237                     throw new IllegalArgumentException("Unknown URI " + uri);
238             }
239 
240 
241             if (TextUtils.isEmpty(sortOrder)) {
242                 sortOrder = MainTable.DEFAULT_SORT_ORDER;
243             }
244 
245             SQLiteDatabase db = mOpenHelper.getReadableDatabase();
246 
247             Cursor c = qb.query(db, projection, selection, selectionArgs,
248                     null /* no group */, null /* no filter */, sortOrder);
249 
250             c.setNotificationUri(getContext().getContentResolver(), uri);
251             return c;
252         }
253 
254         /**
255          * Return the MIME type for an known URI in the provider.
256          */
257         @Override
getType(Uri uri)258         public String getType(Uri uri) {
259             switch (mUriMatcher.match(uri)) {
260                 case MAIN:
261                     return MainTable.CONTENT_TYPE;
262                 case MAIN_ID:
263                     return MainTable.CONTENT_ITEM_TYPE;
264                 default:
265                     throw new IllegalArgumentException("Unknown URI " + uri);
266             }
267         }
268 
269         /**
270          * Handler inserting new data.
271          */
272         @Override
insert(Uri uri, ContentValues initialValues)273         public Uri insert(Uri uri, ContentValues initialValues) {
274             if (mUriMatcher.match(uri) != MAIN) {
275                 // Can only insert into to main URI.
276                 throw new IllegalArgumentException("Unknown URI " + uri);
277             }
278 
279             ContentValues values;
280 
281             if (initialValues != null) {
282                 values = new ContentValues(initialValues);
283             } else {
284                 values = new ContentValues();
285             }
286 
287             if (values.containsKey(MainTable.COLUMN_NAME_DATA) == false) {
288                 values.put(MainTable.COLUMN_NAME_DATA, "");
289             }
290 
291             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
292 
293             long rowId = db.insert(MainTable.TABLE_NAME, null, values);
294 
295             // If the insert succeeded, the row ID exists.
296             if (rowId > 0) {
297                 Uri noteUri = ContentUris.withAppendedId(MainTable.CONTENT_ID_URI_BASE, rowId);
298                 getContext().getContentResolver().notifyChange(noteUri, null);
299                 return noteUri;
300             }
301 
302             throw new SQLException("Failed to insert row into " + uri);
303         }
304 
305         /**
306          * Handle deleting data.
307          */
308         @Override
delete(Uri uri, String where, String[] whereArgs)309         public int delete(Uri uri, String where, String[] whereArgs) {
310             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
311             String finalWhere;
312 
313             int count;
314 
315             switch (mUriMatcher.match(uri)) {
316                 case MAIN:
317                     // If URI is main table, delete uses incoming where clause and args.
318                     count = db.delete(MainTable.TABLE_NAME, where, whereArgs);
319                     break;
320 
321                     // If the incoming URI matches a single note ID, does the delete based on the
322                     // incoming data, but modifies the where clause to restrict it to the
323                     // particular note ID.
324                 case MAIN_ID:
325                     // If URI is for a particular row ID, delete is based on incoming
326                     // data but modified to restrict to the given ID.
327                     finalWhere = DatabaseUtilsCompat.concatenateWhere(
328                             MainTable._ID + " = " + ContentUris.parseId(uri), where);
329                     count = db.delete(MainTable.TABLE_NAME, finalWhere, whereArgs);
330                     break;
331 
332                 default:
333                     throw new IllegalArgumentException("Unknown URI " + uri);
334             }
335 
336             getContext().getContentResolver().notifyChange(uri, null);
337 
338             return count;
339         }
340 
341         /**
342          * Handle updating data.
343          */
344         @Override
update(Uri uri, ContentValues values, String where, String[] whereArgs)345         public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
346             SQLiteDatabase db = mOpenHelper.getWritableDatabase();
347             int count;
348             String finalWhere;
349 
350             switch (mUriMatcher.match(uri)) {
351                 case MAIN:
352                     // If URI is main table, update uses incoming where clause and args.
353                     count = db.update(MainTable.TABLE_NAME, values, where, whereArgs);
354                     break;
355 
356                 case MAIN_ID:
357                     // If URI is for a particular row ID, update is based on incoming
358                     // data but modified to restrict to the given ID.
359                     finalWhere = DatabaseUtilsCompat.concatenateWhere(
360                             MainTable._ID + " = " + ContentUris.parseId(uri), where);
361                     count = db.update(MainTable.TABLE_NAME, values, finalWhere, whereArgs);
362                     break;
363 
364                 default:
365                     throw new IllegalArgumentException("Unknown URI " + uri);
366             }
367 
368             getContext().getContentResolver().notifyChange(uri, null);
369 
370             return count;
371         }
372     }
373 
374     @Override
onCreate(Bundle savedInstanceState)375     protected void onCreate(Bundle savedInstanceState) {
376         super.onCreate(savedInstanceState);
377 
378         FragmentManager fm = getSupportFragmentManager();
379 
380         // Create the list fragment and add it as our sole content.
381         if (fm.findFragmentById(android.R.id.content) == null) {
382             ThrottledLoaderListFragment list = new ThrottledLoaderListFragment();
383             fm.beginTransaction().add(android.R.id.content, list).commit();
384         }
385     }
386 
387     public static class ThrottledLoaderListFragment extends ListFragment
388             implements LoaderManager.LoaderCallbacks<Cursor> {
389 
390         // Menu identifiers
391         static final int POPULATE_ID = Menu.FIRST;
392         static final int CLEAR_ID = Menu.FIRST+1;
393 
394         // This is the Adapter being used to display the list's data.
395         SimpleCursorAdapter mAdapter;
396 
397         // If non-null, this is the current filter the user has provided.
398         String mCurFilter;
399 
400         // Task we have running to populate the database.
401         android.os.AsyncTask<Void, Void, Void> mPopulatingTask;
402 
403         @Override
onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)404         public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
405             super.onViewCreated(view, savedInstanceState);
406 
407             setEmptyText("No data.  Select 'Populate' to fill with data from Z to A at a rate of 4 per second.");
408             setHasOptionsMenu(true);
409 
410             // Create an empty adapter we will use to display the loaded data.
411             mAdapter = new SimpleCursorAdapter(getActivity(),
412                     android.R.layout.simple_list_item_1, null,
413                     new String[] { MainTable.COLUMN_NAME_DATA },
414                     new int[] { android.R.id.text1 }, 0);
415             setListAdapter(mAdapter);
416 
417             // Start out with a progress indicator.
418             setListShown(false);
419 
420             // Prepare the loader.  Either re-connect with an existing one,
421             // or start a new one.
422             getLoaderManager().initLoader(0, null, this);
423         }
424 
onCreateOptionsMenu(Menu menu, MenuInflater inflater)425         @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
426             MenuItem populateItem = menu.add(Menu.NONE, POPULATE_ID, 0, "Populate");
427             populateItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
428             MenuItem clearItem = menu.add(Menu.NONE, CLEAR_ID, 0, "Clear");
429             clearItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
430         }
431 
432         @SuppressWarnings("deprecation") /* AsyncTask */
onOptionsItemSelected(MenuItem item)433         @Override public boolean onOptionsItemSelected(MenuItem item) {
434             final ContentResolver cr = getActivity().getContentResolver();
435 
436             switch (item.getItemId()) {
437                 case POPULATE_ID:
438                     if (mPopulatingTask != null) {
439                         mPopulatingTask.cancel(false);
440                     }
441                     mPopulatingTask = new android.os.AsyncTask<Void, Void, Void>() {
442                         @Override protected Void doInBackground(Void... params) {
443                             for (char c='Z'; c>='A'; c--) {
444                                 if (isCancelled()) {
445                                     break;
446                                 }
447                                 StringBuilder builder = new StringBuilder("Data ");
448                                 builder.append(c);
449                                 ContentValues values = new ContentValues();
450                                 values.put(MainTable.COLUMN_NAME_DATA, builder.toString());
451                                 cr.insert(MainTable.CONTENT_URI, values);
452                                 // Wait a bit between each insert.
453                                 try {
454                                     Thread.sleep(250);
455                                 } catch (InterruptedException e) {
456                                 }
457                             }
458                             return null;
459                         }
460                     };
461                     mPopulatingTask.execute((Void[]) null);
462                     return true;
463 
464                 case CLEAR_ID:
465                     if (mPopulatingTask != null) {
466                         mPopulatingTask.cancel(false);
467                         mPopulatingTask = null;
468                     }
469                     new android.os.AsyncTask<Void, Void, Void>() {
470                         @Override
471                         protected Void doInBackground(Void... params) {
472                             cr.delete(MainTable.CONTENT_URI, null, null);
473                             return null;
474                         }
475                     }.execute((Void[]) null);
476                     return true;
477 
478                 default:
479                     return super.onOptionsItemSelected(item);
480             }
481         }
482 
onListItemClick(ListView l, View v, int position, long id)483         @Override public void onListItemClick(ListView l, View v, int position, long id) {
484             // Insert desired behavior here.
485             Log.i(TAG, "Item clicked: " + id);
486         }
487 
488         // These are the rows that we will retrieve.
489         static final String[] PROJECTION = new String[] {
490             MainTable._ID,
491             MainTable.COLUMN_NAME_DATA,
492         };
493 
494         @Override
onCreateLoader(int id, Bundle args)495         public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
496             CursorLoader cl = new CursorLoader(getActivity(), MainTable.CONTENT_URI,
497                     PROJECTION, null, null, null);
498             cl.setUpdateThrottle(2000); // update at most every 2 seconds.
499             return cl;
500         }
501 
502         @Override
onLoadFinished(@onNull Loader<Cursor> loader, Cursor data)503         public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
504             mAdapter.swapCursor(data);
505 
506             // The list should now be shown.
507             if (isResumed()) {
508                 setListShown(true);
509             } else {
510                 setListShownNoAnimation(true);
511             }
512         }
513 
514         @Override
onLoaderReset(@onNull Loader<Cursor> loader)515         public void onLoaderReset(@NonNull Loader<Cursor> loader) {
516             mAdapter.swapCursor(null);
517         }
518     }
519 }
520 //END_INCLUDE(complete)
521