I am reading this tutorial on implementing my own ContentProvide for working with SQLite. Int the ContentProvider.query there are a few thing that puzzles me. It seems very hardcoded to just one table (the todo table in the tutorial), but maybe Im just not getting it? Now if I wanted to query another table, lets say nodo, how would I change the ContentProvider?
Should I append the table names somehow in queryBuilder.setTables(String inTables)?
What about the CONTENT_TYPE and CONTENT_ITEM_TYPE, should there be one for each table?
That about the TODO and TODO_ID varibles and the switch in the query method?
It seems I need to have a lot of if/switch conditions to support multiple tables with the same ContentProvider, is this the way to go or am I on a wrong path?
Thank you
Søren
Now if I wanted to query another table, lets say nodo, how would I change the ContentProvider?
Querying a new table would mean that you need to add a new Uri, since the Uri selects the datasource, similar to using a different table.
You would be adding essentially all the hardcoded values that are already there for the todos for your other table. For example:
// ------- usually the same for all
private static final String AUTHORITY = "de.vogella.android.todos.contentprovider";
// ------- define some Uris
private static final String PATH_TODOS = "todos";
private static final String PATH_REMINDERS = "reminders";
public static final Uri CONTENT_URI_TODOS = Uri.parse("content://" + AUTHORITY
+ "/" + PATH_TODOS);
public static final Uri CONTENT_URI_REMINDERS = Uri.parse("content://" + AUTHORITY
+ "/" + PATH_REMINDERS);
// ------- maybe also define CONTENT_TYPE for each
// ------- setup UriMatcher
private static final int TODOS = 10;
private static final int TODO_ID = 20;
private static final int REMINDERS = 30;
private static final int REMINDERS_ID = 40;
private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
sURIMatcher.addURI(AUTHORITY, PATH_TODOS, TODOS);
sURIMatcher.addURI(AUTHORITY, PATH_TODOS + "/#", TODO_ID);
sURIMatcher.addURI(AUTHORITY, PATH_REMINDERS, REMINDERS);
sURIMatcher.addURI(AUTHORITY, PATH_REMINDERS + "/#", REMINDERS_ID);
}
//#Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
// Using SQLiteQueryBuilder instead of query() method
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
int uriType = sURIMatcher.match(uri);
switch (uriType) {
case TODO_ID:
// Adding the ID to the original query
queryBuilder.appendWhere(TodoTable.COLUMN_ID + "="
+ uri.getLastPathSegment());
//$FALL-THROUGH$
case TODOS:
queryBuilder.setTables(TodoTable.TABLE_TODO);
break;
case REMINDERS_ID:
// Adding the ID to the original query
queryBuilder.appendWhere(ReminderTable.COLUMN_ID + "="
+ uri.getLastPathSegment());
//$FALL-THROUGH$
case REMINDERS:
queryBuilder.setTables(ReminderTable.TABLE_REMINDER);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
Should I append the table names somehow in queryBuilder.setTables(String inTables)?
Yes, if different Uris read from different tables then set the table based on the Uri match.
What about the CONTENT_TYPE and CONTENT_ITEM_TYPE, should there be one for each table?
Depends on the actual content type. If they are different and you need a type yes. But you don't need to have them at all. That example defines them but doesn't even use them. It would need to return the type in getType, see documentation.
That about the TODO and TODO_ID varibles and the switch in the query method?
Those are constants defined for the UriMatcher which is explained nicely here. It's basically a simplification for String matching. A big ContentProvider can have 100 different Uris and selecting the right table in query would be painful if you would have to write if (uri.getPath().equals("todos") { /* code */ } else if (uri.. all the way.
Here's solution to your question, using UriMatcher, you can implement multiple tables in a content provider.
Content type and content item can be as follows and they can be wrapped in a separate class for each table
public static final String GENERAL_CONTENT_TYPE = "vnd.android.cursor.dir/vnd.myfirstapp.db.member" ;
public static final String SPECIFIC_CONTENT_TYPE = "vnd.android.cursor.item/vnd.myfirstapp.db.member" ;
`vnd.android.cursor.dir/vnd.yourownanything.anything.tablename'
this defines the general content type
`vnd.android.cursor.item/vnd.anthingasabove.table'
this also defines the specific and it is constant to any app those strings(words) vnd.android.cursor.dir and .item must be like that and after /vnd. must be like that
and in the class that extends contentprovider you just uset the same instance of UriMatcher to map the tables
Related
I want to delete the first item in my content provider. I'm trying to do this by deleting the row with id 0 (as shown below). This does not work--the app will not run with this code.
public void onClickDeleteExercise(View view){
int ret_val = getContentResolver().delete(MyProvider.CONTENT_URI, MyProvider.id+ " = ? ", new String[]{"0"});
Toast.makeText(getBaseContext(), "First exercise deleted", Toast.LENGTH_LONG).show();
}
My provider has defined these:
static final String PROVIDER_NAME = "com.example.contentproviderexample.MyProvider";
static final String URL = "content://" + PROVIDER_NAME + "/cte";
static final Uri CONTENT_URI = Uri.parse(URL);
static final String id = "id";
static final String name = "name";
static final int uriCode = 1;
How would I go about deleting from this? Thank you!!
app:
getContentResolver().delete(Provider.CONTENT_URI,Provider._ID + "=" + id, null);
provider:
public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY + "/")
public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_URI,
ENTRIES_TABLE_NAME);
public static final String _ID = "_id";
#Override
public int delete(Uri uri, String where, String[] whereArgs) {
database.delete(ENTRIES_TABLE_NAME, where, whereArgs);
return 0;
}
hint:
to exclude errors if u use android studio make breakpoint on
public int delete(..) {
database.delete() <= here breakpoint
}
and see if after execute in app getContentResolver() the debugger will move you to this breakpoint
if it fails u have not registered content provider properly
if u will hit breakpoint implementation of database.delete is incorect
If I want to delete the first item, would I just set id to 0?
depends if your _id is PRIMARY_KEY in table
SQlite database Engine has a mechanism that creates a unique ROWID for every new row you insert.
if you table have a PRIMARY_KEY then it will eventually becomes the alias for that ROW_ID
class SQLiteDatabase
/**
* Convenience method for deleting rows in the database.
*
* #param table the table to delete from
* #param whereClause the optional WHERE clause to apply when deleting.
* Passing null will delete all rows.
* #param whereArgs You may include ?s in the where clause, which
* will be replaced by the values from whereArgs. The values
* will be bound as Strings.
* #return the number of rows affected if a whereClause is passed in, 0
* otherwise. To remove all rows and get a count pass "1" as the
* whereClause.
*/
public int delete(String table, String whereClause, String[] whereArgs) {}
so to pas id as int u need:
database.delete(TABLE_NAME, KEY_ID + " = ?",new String[]{Long.toString(id)});
or simple:
String[] whereArgs = new String[] {String.valueOf(rowId)};
Caution: Rowids will change when the db is vacuumed
So please take extra care when you define a table and need to reference records using rowids.
From the official documentation:
“Rowids can change at any time and without notice. If you need to depend on your rowid, make it an INTEGER PRIMARY KEY, then it is guaranteed not to change”.
add also AUTOINCREMENT so you are sure that the same rowid(s) are not reused when rows are deleted.
In one of my tables
I got key message_id and it is beginning from value = 1
If u not sure about Key Value use on Android device SQLIte Debugger very excellent app
update: looking at "vnd.android.cursor.dir/vnd.google.note" and "vnd.android.cursor.item/vnd.google.note" it seemed to me as though the cursor was for one table.
From the examples it appears as though content provider were designed to work with one table. I do know how to use multiple tables in sqlite but it seems to me that the content provider seems to be about picking one row or multiple rows from one table.
see http://developer.android.com/guide/topics/providers/content-provider-creating.html
Also, see the notepad sample in adt-bundle-windows-x86-20131030\sdk\samples\android-19\legacy\NotePad\src\com\example\android\notepad
Suppose I want to have notes by topic.
I would like to have a Topics table with columns _id and Title_text.
I would like to have the Notes table with columns _id and foreign key Topic_id and Note_text.
How would one design the Topics and Notes?
But looking at the Notes sample, the content URIs and docs on content providers, it appears as though having multiple related tables is an afterthought and is not obvious to me.
from NotepadProvider.java, Notepad.java:
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.google.note";
/**
* The MIME type of a {#link #CONTENT_URI} sub-directory of a single
* note.
*/
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.google.note";
public static final Uri CONTENT_ID_URI_BASE
= Uri.parse(SCHEME + AUTHORITY + PATH_NOTE_ID);
/**
* The content URI match pattern for a single note, specified by its ID. Use this to match
* incoming URIs or to construct an Intent.
*/
public static final Uri CONTENT_ID_URI_PATTERN
= Uri.parse(SCHEME + AUTHORITY + PATH_NOTE_ID + "/#");
#Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
...
switch (sUriMatcher.match(uri)) {
// If the incoming URI is for notes, chooses the Notes projection
case NOTES:
qb.setProjectionMap(sNotesProjectionMap);
break;
/* If the incoming URI is for a single note identified by its ID, chooses the
* note ID projection, and appends "_ID = <noteID>" to the where clause, so that
* it selects that single note
*/
case NOTE_ID:
qb.setProjectionMap(sNotesProjectionMap);
qb.appendWhere(
NotePad.Notes._ID + // the name of the ID column
"=" +
// the position of the note ID itself in the incoming URI
uri.getPathSegments().get(NotePad.Notes.NOTE_ID_PATH_POSITION));
break;
When creating a ContentProvider, the expectation is that other apps are going to use your database, and with that I mean other people who know nothing about your database scheme. To make things easy for them, you create and document your URIs:
To access all the books
content://org.example.bookprovider/books
to access books by id
content://org.example.bookprovider/books/#
to access books by author name
content://org.example.bookprovider/books/author
Create as many URIs as you need, that’s up to you. This way the user of your Provider can very easily access your database info, and maybe that’s why you are getting the impression that the Provider is designed to work with one table databases, but no, internally is where the work is done.
In your ContentProvider subclass, you can use a UriMatcher to identify those different URIs that are going to be passed to your ContentProvider methods (query, insert, update, delete). If the data the Uri is requesting is stored in several tables, you can actually do the JOINs and GROUP BYs or whatever you need with SQLiteQueryBuilder , e.g.
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder mQueryBuilder = new SQLiteQueryBuilder();
. . .
String Joins = " t1 INNER JOIN table2 t2 ON t2._id = t1._id"
+ " INNER JOIN table3 t3 ON t3._id = t1._id";
switch (mUriMatcher.match(uri)) {
case DATA_COLLECTION_URI:
mQueryBuilder.setTables(YourDataContract.TABLE1_NAME + Joins);
mQueryBuilder.setProjectionMap(. . .);
break;
case SINGLE_DATA_URI:
mQueryBuilder.setTables(YourDataContract.TABLE1_NAME + Joins);
mQueryBuilder.setProjectionMap(. . .);
mQueryBuilder.appendWhere(Table1._ID + "=" + uri.getPathSegments().get(1));
break;
case . . .
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
. . .
SQLiteDatabase db = mOpenHelper.getReadableDatabase();
Cursor c = mQueryBuilder.query(db, projection, selection, selectionArgs, groupBy, having, orderBy);
return c;
}
Hope it helps.
Excuse me, but I don't understand your question.
ContentProvider is designed (a one of it's aims)to wrap access to your tabels. Design of database schema is up to you.
Generally, you need to:
Define your tables/ It should be made by execution of sql command in class which extends SQLiteOpenHelper
Define an uri for them
Define a logic for queries to this tables as it was made for NOTE_ID
Update
For JOIN operations SQLiteQueryBuilder is usually used. In setTables() you need to write names of tables with JOIN clause, e.g.
.setTables(NoteColumns.TABLENAME +
" LEFT OUTER JOIN " + TopicColumns.TABLENAME + " ON " +
NoteColumns.ID + " = " + TopicColumns.ID);
Here is my code for multiple table query in content provider with projectionMap
//HashMap for Projection
mGroupImageUri = new HashMap<>();
mGroupImageUri.put(RosterConstants.JID,RosterProvider.TABLE_ROSTER+"."+RosterConstants.JID);
mGroupImageUri.put(RosterConstants.USER_NAME,RosterProvider.TABLE_ROSTER+"."+RosterConstants.USER_NAME);
mGroupImageUri.put(ChatConstants.MESSAGE,"c."+ChatConstants.MESSAGE+ " AS "+ ChatConstants.MESSAGE);
mGroupImageUri.put(ChatConstants.SENDER,"c."+ChatConstants.SENDER+" AS "+ChatConstants.SENDER);
mGroupImageUri.put(ChatConstants.URL_LOCAL,"c."+ChatConstants.URL_LOCAL+" AS "+ChatConstants.URL_LOCAL);
//case for content type of uri
case IMAGE_URI:
qBuilder.setTables(RosterProvider.TABLE_ROSTER
+ " LEFT OUTER JOIN "+ TABLE_NAME + " c"
+ " ON c."+ ChatConstants.JID + "=" + RosterProvider.TABLE_ROSTER + "."+RosterConstants.JID);
qBuilder.setProjectionMap(mGroupImageUri);
break;
//ContentResolver query for Projection form, selection and selection args
String[] PROJECTION_FROM = new String[]{
RosterConstants.JID,
RosterConstants.USER_NAME,
ChatConstants.MESSAGE,
ChatConstants.SENDER,
ChatConstants.URL_LOCAL
};
String selection = RosterProvider.TABLE_ROSTER +"."+RosterConstants.JID+ "='" + jid + "' AND " + "c."+ChatConstants.FILE_TYPE+"="+ChatConstants.IMAGE;
String[] selectionArgu = null;
String order = "c."+ChatConstants.MESSAGE+" ASC";
Cursor cursor = mContentReolver.query(ChatProvider.CONTENT_URI_GROUP_IMAGE_URI,
PROJECTION_FROM,selection, null,order);
//#ChatProvider.CONTENT_URI_GROUP_IMAGE_URI = 'your content type uri'
//#TABLE_NAME = 'table1'
//#RosterProvider.TABLE_ROSTER ='table2'
I have a question regarding best practice for implementing complex queries with content providers. As I see from the android contact content provider it is recommended to use the fields selection, selectionArgs etc to make specific requests.
I am right now in the process of shaping my first own content provider. It has quite a complex beast behind with all the joins, full text tables etc constructs of a database with normalised tables. Of course I want to hide this complexity from the user of the content provider yet I just dont know yet how to implement this. If the user specifies his request with selection, selectionArgs etc it seems the content provider has to parse these and map it too the underlying structure. Are there some tools for this parsing or will I end up writing my own selection string parser etc.
I am afraid this is the way to go but before implementing it I would like to hear some advice from the android professionals around about it.
Thanks a lot
martin
Here comes where I am right now, at least some kind of solution I want to quickly share for inspiring expert feedback and maybe give some hint for other bloody newbies around. My basic mistake in the beginning as it seems now was not to introduce a "virtual" database like layer which the content provider offers to his users. Once this is done the complexity behind is hidden and the normal syntax of queries can be used.
So what I did is having a "virtual" database layer in the content provider contract class,
public class JustDharmaQuotesContract {
public class Quote {
public static final String TABLE_NAME = QuoteTable.TABLE_NAME;
public static final String _ID = TABLE_NAME + "." + QuoteTable._ID;
public static final String AUTHOR = TABLE_NAME + "." + QuoteTable.AUTHOR;
public static final String AUTHOR_FULL_NAME = TABLE_NAME + "." + QuoteTable.AUTHOR_FULL_NAME;
public static final String TITLE = TABLE_NAME + "." + QuoteTable.TITLE;
public static final String QUOTE = TABLE_NAME + "." + QuoteTable.QUOTE;
public static final String QUOTE_LENGTH = TABLE_NAME + "." + QuoteTable.QUOTE_LENGTH;
public static final String BLOG_POST_LINK = TABLE_NAME + "." + QuoteTable.BLOG_POST_LINK;
}
public class QuoteFTS {
public static final String TABLE_NAME = QuoteFtsTable.TABLE_NAME;
public static final String _ID = TABLE_NAME + "." + QuoteFtsTable._ID;
public static final String TITLE = TABLE_NAME + "." + QuoteFtsTable.TITLE;
public static final String QUOTE = TABLE_NAME + "." + QuoteFtsTable.QUOTE;
public static final String SNIPPET = "snippet";
public static final String AUTHOR = Quote.TABLE_NAME + "." + QuoteTable.AUTHOR;
public static final String AUTHOR_FULL_NAME = Quote.TABLE_NAME + "." + QuoteTable.AUTHOR_FULL_NAME;
public static final String QUOTE_LENGTH = Quote.TABLE_NAME + "." + QuoteTable.QUOTE_LENGTH;
public static final String BLOG_POST_LINK = Quote.TABLE_NAME + "." + QuoteTable.BLOG_POST_LINK;
}
}
with the Quote layer basically mapping one to one to the QuoteTable of the database and the QuoteFTS layer representing the join between FTS table and the Quote table. (the snippet column for the respective fts snippet result) Here I use the the same column names as the database column names so that the projections and selections of the user fits to the database. (including the table names so that the join works well)
The user of the content provider can now do all kind of queries on this two virtual database layers provided, for example
Uri uri = QuoteContentProvider.FTS_URI;
switch (order) {
case 1: {
sortOrder = QuoteFTS.TITLE + " COLLATE NOCASE";
break;
}
case 2: {
sortOrder = QuoteFTS.AUTHOR + ", " + QuoteFTS.TITLE
+ " COLLATE NOCASE";
break;
}
case 3: {
sortOrder = QuoteFTS.QUOTE_LENGTH;
break;
}
}
String[] projection = new String[] { QuoteFTS._ID,
QuoteFTS.TITLE, QuoteFTS.AUTHOR, QuoteFTS.SNIPPET};
String selection = titleOnly ? QuoteFTS.TITLE + " MATCH ? " : QuoteFTS.TABLE_NAME + " MATCH ? ";
String[] selectionArgs = {appendWildcard(query)};
Loader<Cursor> loader = CursorLoader(this, uri, projection,selection, selectionArgs, sortOrder);
It is still necessary to have proper knowledge of how to use the MATCH syntax etc to get this to work. This point makes me feel a bit uncomfortable and I would highly appreciate feedback on this.
Thanks buddies
martin
Am I misunderstanding something here? I'm trying to implement a ContentProvider in Android and for some reason the calling URI is not being matched.
In my ContentProvider I define the following:
private static final int GET_COURSES = 100;
public static final Uri COURSES_URI = Uri.withAppendedPath(CONTENT_URI, CourseTable.NAME);
private static final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH);
static
{
matcher.addURI(AUTHORITY, COURSES_URI.toString(), GET_COURSES);
}
Then, in my query call:
public Cursor query(Uri uri, ...)
{
int type = matcher.match(uri);
.
.
Here, type is always -1... In the debug window I've viewed both the passing in uri and COURSES_URI and the string representations are identical...
Any suggestions?
Thanks
Update:
I call the Content Provider using:
new CursorLoader(this, CoursesProvider.COURSES_URI, null, null, null, null);
... this is boggling my mind... just got uri.equals(COURSES_URI) == true, so something must be incorrect in the UriMatcher
Problem solved...
The initial problem was that COURSES_URI also contained the AUTHORITY path:
private static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + DBManager.DB_NAME);
private static final Uri COURSES_URI = Uri.withAppendedPath(CONTENT_URI, CourseTable.NAME);
In the matcher.AddURI(authority,path,code) method the authority portion of path should be removed.
This can be obtained using COURSES_URI.getPath().substring(1) (substring to remove the leading '/' returned by getPath())
I have an Android ContentProvider which allows to do LEFT OUTER JOIN queries on a SQLite database.
Let's assume in the database I have 3 tables, Users, Articles and Comments. The ContentProvider is something like the following:
public class SampleContentProvider extends ContentProvider {
private static final UriMatcher sUriMatcher;
public static final String AUTHORITY = "com.sample.contentprovider";
private static final int USERS_TABLE = 1;
private static final int USERS_TABLE_ID = 2;
private static final int ARTICLES_TABLE = 3;
private static final int ARTICLES_TABLE_ID = 4;
private static final int COMMENTS_TABLE = 5;
private static final int COMMENTS_TABLE_ID = 6;
private static final int ARTICLES_USERS_JOIN_TABLE = 7;
private static final int COMMENTS_USERS_JOIN_TABLE = 8;
// [...] other ContentProvider methods
#Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
String table = getTableName(uri);
// SQLiteWrapper is a wrapper class to manage a SQLiteHelper
Cursor c = SQLiteWrapper.get(getContext()).getHelper().getReadableDatabase()
.query(table, projection, selection, selectionArgs, null, null, sortOrder);
c.setNotificationUri(getContext().getContentResolver(), uri);
return c;
}
#Override
public Uri insert(Uri uri, ContentValues values) {
String table = getTableName(uri);
// SQLiteWrapper is a wrapper class to manage a SQLiteHelper
long id = SQLiteWrapper.get(getContext()).getHelper().getWritableDatabase()
.insert(table, null, values);
Uri itemUri = ContentUris.withAppendedId(uri, id);
getContext().getContentResolver().notifyChange(itemUri, null);
return itemUri;
}
private String getTableName(Uri uri) {
switch (sUriMatcher.match(uri)) {
case USERS_TABLE:
case USERS_TABLE_ID:
return "Users";
case ARTICLES_TABLE:
case ARTICLES_TABLE_ID:
return "Articles";
case COMMENTS_TABLE:
case COMMENTS_TABLE_ID:
return "Comments";
case ARTICLES_USERS_JOIN_TABLE:
return "Articles a LEFT OUTER JOIN Users u ON (u._id = a.user_id)";
case COMMENTS_USERS_JOIN_TABLE:
return "Comments c LEFT OUTER JOIN Users u ON (u._id = c.user_id)";
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(AUTHORITY, "users", USERS_TABLE);
sUriMatcher.addURI(AUTHORITY, "articles", ARTICLES_TABLE);
sUriMatcher.addURI(AUTHORITY, "comments", COMMENTS_TABLE);
sUriMatcher.addURI(AUTHORITY, "users" + "/#", USERS_TABLE_ID);
sUriMatcher.addURI(AUTHORITY, "articles" + "/#", ARTICLES_TABLE_ID);
sUriMatcher.addURI(AUTHORITY, "comments" + "/#", COMMENTS_TABLE_ID);
sUriMatcher.addURI(AUTHORITY, "???", ARTICLES_USERS_JOIN_TABLE); // what uri here?
sUriMatcher.addURI(AUTHORITY, "???", COMMENTS_USERS_JOIN_TABLE); // what uri here?
}
}
What's the best URI scheme to notify all CursorAdapters listening on joined and non-joined queries every time I insert (or update) a row in the Users table?
In other words, if I add or update a new row in one of the tables, I want to send a single notification with getContext().getContentResolver().notifyChange(itemUri, null) so that all the CursorAdapters listening on any query (USERS_TABLE, ARTICLES_USERS_JOIN_TABLE, COMMENTS_USERS_JOIN_TABLE) receive a notification to update their content.
If this is not possible, is there an alternative way to notify all the observers?
You can have special Uri's to query with:
sUriMatcher.addURI(AUTHORITY, "articlesusers", ARTICLES_USERS_JOIN_TABLE);
sUriMatcher.addURI(AUTHORITY, "commentsusers", COMMENTS_USERS_JOIN_TABLE);
But I can't think of a way to send a single notification. It seems your best choice is to send a notification for each Uri that refers to the table being modified. So your insert/update/delete methods would call notifyChange multiple times depending on the table affected. For changes to "users" it would be 3 notifications--users, articlesusers and commentsusers--since they all depend on the "users" table.
As answered by prodaea, here is another alternative you can use for notification Uri. This is not a perfect solution, but it uses only one Uri for notification.
The solution is to use the main Uri without any table name (e.g:content://com.example.app.provider/) as the notification Uri in the query method for ARTICLES_USERS_JOIN_TABLE and COMMENTS_USERS_JOIN_TABLE. So, the related cursor will be notified whenever there is change in any table. There is one limitation though. That is, ARTICLES_USERS_JOIN_TABLE cursor will be notified even when there is change in Articles table.
For tables, Users' andArticles', you can use their specific Uris for notification.