By Md. Sabuj Sarker | 11/1/2017 | General |Beginners

Working With Content Providers In Android

Working With Content Providers In Android

In a previous article I showed you various aspects of Content Providers in Android. That was a theoretical discussion. This article will follow a more practical approach with enough examples so that you will be able to work comfortably with Content Providers and Content Resolvers. Check out this article for a complete application working with the SQLite database in Android. In this article we are going to use that code as the base and extend that application to make it the provider. We also need another application to be the client and use the content resolver from it.

Getting Started

There are two major IDEs with which you can start your Android development. One is IntelliJ IDEA and the other is Android Studio. Both of these IDEs are from the same company. I like to use Android Studio. If you are not in Android development for a long time then you may be surprised to learn that the Android development plugin for Eclipse IDE is no longer in maintenance.

The Provider Application

We need one application that will act like the data store house. We need an interface for entering some data in there. So, create an android project the way you like and create the user interface (in the land of Android we call it Activity) with the following lines of XML. I am using me.sabuj.sqlitepart1 as the package name and sabuj.me as the domain.

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

   <EditText
       android:id="@+id/editName"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:ems="10"
       android:inputType="textPersonName"
      android:hint="Name"
       />

   <EditText
       android:id="@+id/editEmail"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:ems="10"
       android:inputType="textEmailAddress"
       android:hint="Email"
       />

   <EditText
       android:id="@+id/editProfession"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:ems="10"
       android:inputType="text"
       android:hint="Profession" />

   <Button
       android:id="@+id/save"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Save" />

   <Button
       android:id="@+id/searchByName"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Search By Email" />

   <TextView
       android:id="@+id/textInfo"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:textAlignment="center"
       />
</LinearLayout>

Here is a screenshot of what our app looks like:

SQLite Part1

The SQLite helper extension class called MyDbHelper.java looks like the following:

package me.sabuj.sqlitepart1;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;


public class MyDbHelper extends SQLiteOpenHelper {
   public MyDbHelper(Context ctx) {
       super(ctx, "db1.sqlite", null, 1);
   }

   @Override
   public void onCreate(SQLiteDatabase db) {
       // Create table
       db.execSQL("CREATE TABLE persons(id integer primary key autoincrement, name text NOT NULL, email text NOT NULL UNIQUE, profession text NOT NULL)");

   }

   @Override
   public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
       // We do not want to do anything on upgrade in this application
   }

   public String save(String name, String email, String profession){
       String msg = "";
       email = email.toLowerCase(); // Usually emails are case insensitive. So, I am converting it to lowercase to avoid duplication.
       ContentValues cvals = new ContentValues();
       cvals.put("name", name);
       cvals.put("email", email);
       cvals.put("profession", profession);
       long res = this.getWritableDatabase().insert("persons", null, cvals);
       if (res < 0){
           msg = "An error occurred";
       }else{
           msg = "Data inserted Successfully";
       }
       return msg;
   }

   public String search(String email){
       String msg = "";
       email = email.toLowerCase();
       Cursor cursor = this.getReadableDatabase().rawQuery("SELECT name, email, profession FROM persons WHERE email='" + email + "'", null);
       if (cursor.getCount() < 1){
           msg = "Person with email " + email + "NOT FOUND";
       }else{
           cursor.moveToNext();
           msg = "Name: " + cursor.getString(0) + "\n" +
                 "Email: " + cursor.getString(1) + "\n" +
                 "Position: " + cursor.getString(2) + "\n";
       }
       return msg;
   }
}

The main activity class called MainActivity.java looks like the following:

package me.sabuj.sqlitepart1;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {
   MyDbHelper myDbHelper;

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

       Button saveBtn = (Button) findViewById(R.id.save);
       Button searchBtn = (Button) findViewById(R.id.searchByName);

       final EditText editName = (EditText) findViewById(R.id.editName);
       final EditText editEmail = (EditText) findViewById(R.id.editEmail);
       final EditText editProfession = (EditText) findViewById(R.id.editProfession);
       final TextView infoView = (TextView) findViewById(R.id.textInfo);


       // Preparing the database
       myDbHelper = new MyDbHelper(this);

       // Setting up click listeners
       saveBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               String res = myDbHelper.save(
                       editName.getText().toString(),
                       editEmail.getText().toString(),
                       editProfession.getText().toString()
               );
               infoView.setText(res);
           }
       });
       searchBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               String res = myDbHelper.search(editEmail.getText().toString());
               infoView.setText(res);
           }
       });
   }

}

The Client Application

To interact with the content provider we need another application. We need to do the CRUD operations to the client application from this one. We need an interface similar to the client application with a few additional buttons for performing the CRUDs. I am using me.sabuj.sandroclient1 as the package name, sabuj.me as the domain and Sandro Client 1 as the application name.

Below is the screenshot of the interface I have designed:

The XML of this activity looks as follows:

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

   <EditText
       android:id="@+id/editName"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:ems="10"
       android:inputType="textPersonName"
       android:hint="Name"
       />

   <EditText
       android:id="@+id/editEmail"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:ems="10"
       android:inputType="textEmailAddress"
       android:hint="Email"
       />

   <EditText
       android:id="@+id/editProfession"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:ems="10"
       android:inputType="text"
       android:hint="Profession" />

   <Button
       android:id="@+id/insert"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Insert (C)" />

   <Button
       android:id="@+id/queryByEmail"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Query (R)" />

   <Button
       android:id="@+id/updateByEmail"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Update (U)" />



   <Button
       android:id="@+id/deleteByEmail"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Delete (D)" />

   <TextView
       android:id="@+id/textInfo"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:textAlignment="center"
       />
</LinearLayout>

The MainActivity.java should look like the following. After creating the click listeners I am keeping them empty to fill in later.

package me.sabuj.contentprovider1;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {

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

       EditText nameEdit = (EditText) findViewById(R.id.editName);
       EditText emailEdit = (EditText) findViewById(R.id.editEmail);
       EditText professionEdit = (EditText) findViewById(R.id.editProfession);

       TextView textInfo = (TextView) findViewById(R.id.textInfo);

       Button insertBtn = (Button) findViewById(R.id.insert);
       Button queryByEmailBtn = (Button) findViewById(R.id.queryByEmail);
       Button updateByEmailBtn = (Button) findViewById(R.id.updateByEmail);
       Button deleteByEmailBtn = (Button) findViewById(R.id.deleteByEmail);

       // Setup on click listeners
       insertBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {

           }
       });

       queryByEmailBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {

           }
       });

       updateByEmailBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {

           }
       });

       deleteByEmailBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {

           }
       });
   }
}

Now, we need to implement the code for those click listeners. Let's get our hands dirty with some real code.

Helper methods: For making our code more DRY, I have created three helper methods on the MainActivity class. The first one collects the texts from the input boxes for Name, Email, and Profession and returns them as an array of strings.

 

public String[] getInputValues(){
   return new String[]{
           nameEdit.getText().toString(),
           emailEdit.getText().toString().toLowerCase(),
           professionEdit.getText().toString()
   };
}

The second one returns a ContentValues object by taking the result of the first one.

public ContentValues getContentValues(String[] ip){
   ContentValues cv = new ContentValues();
   cv.put("name", ip[0]);
   cv.put("email", ip[1]);
   cv.put("profession", ip[2]);
   return cv;
}

The third is just for clearing the input fields on different occasions for providing user with clean input fields to fill up with new ones.

public void clearInputs(){
   nameEdit.setText("");
   emailEdit.setText("");
   professionEdit.setText("");
}

Insert click listener: When the user clicks the insert button, the texts from the input fields are taken and sent to the content provider with the help of content resolver's insert() method. I have coded the content provider's insert() method such that it returns an URI with a -1 appended at the end of it as an insert id, so that we can detect the failure of insertion from the client. The code below explains the idea more.

String[] ip = getInputValues();
Uri uri = getContentResolver().insert(theUri, getContentValues(ip));
if(ContentUris.parseId(uri) == -1){
   textInfo.setText("Error occurred: cannot insert the data! It seems like that a person with the same email already exists!");
   return;
}
textInfo.setText("Data Inserted:" +
       "\nName: " + ip[0] +
       "\nEmail: " + ip[1] +
       "\nProfession: " + ip[2]
   );
clearInputs();

Query click listener: Clicking or touching the query button would execute the following lines of code. When success it will display all three pieces of information of a person in the text box at the bottom of all the buttons. On failure it will display an error message.

String[] ip = getInputValues();

Cursor cursor = getContentResolver().query(theUri, theProjection, "email=?", new String[]{ip[1]}, null);
if(cursor.getCount() == 0){
   textInfo.setText("No record with the email address found");
}else {
   cursor.moveToFirst();
   textInfo.setText("Query Result:" +
           "\nName: " + cursor.getString(1) +
           "\nEmail: " + cursor.getString(2) +
           "\nProfession: " + cursor.getString(3)
   );
}
cursor.close();
clearInputs();

Update click listener: To update a person's data we need to identify the person with the email address. To check if a person exists with some particular email address I am performing a query with the help of content resolver and then if a person exists I am updating his other two pieces of  data with the update method passing it with the proper id appended to the URI and the email address in the where clause.

String[] ip = getInputValues();

Cursor cursor = getContentResolver().query(theUri, theProjection, "email=?", new String[]{ip[1]}, null);
textInfo.setText("clicked update?");
if(cursor.getCount() == 0){
   textInfo.setText("No record with the email address found to delete!");
}else {

   cursor.moveToFirst();

   getContentResolver().update(ContentUris.withAppendedId(theUri, cursor.getInt(0)), getContentValues(ip),
           "email=?", new String[]{cursor.getString(2)});

   textInfo.setText("Data Updated For:" +
           "\nName: " + cursor.getString(1) + " --> " + ip[0] +
           "\nEmail: " + cursor.getString(2) + " --> " + ip[1] +
           "\nProfession: " + cursor.getString(3) + " --> " + ip[2]
   );
}

cursor.close();
clearInputs();

Delete click listeners: To delete a row we need to take the email address from the email input box and invoke the delete() method on the content resolver with other data presented in the code below:

String[] ip = getInputValues();
long ret = getContentResolver().delete(theUri, "email=?", new String[]{ip[1]});
if(ret <= 0) {
   textInfo.setText("The delete operation was not successful!");
}else{
   textInfo.setText("Deleted successfully!");
}
clearInputs();

Complete code: Our complete code of the main activity looks like below:

package me.sabuj.sandroclient1;

import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {

   public Uri theUri = Uri.parse("content://me.sabuj.sandroprovider1.SANDRO_PROVIDER1/persons");
   public EditText nameEdit;
   public EditText emailEdit;
   public EditText professionEdit;
   public String[] theProjection = {"id", "name", "email", "profession"};

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

       nameEdit = (EditText) findViewById(R.id.editName);
       emailEdit = (EditText) findViewById(R.id.editEmail);
       professionEdit = (EditText) findViewById(R.id.editProfession);

       final TextView textInfo = (TextView) findViewById(R.id.textInfo);

       Button insertBtn = (Button) findViewById(R.id.insert);
       Button queryByEmailBtn = (Button) findViewById(R.id.queryByEmail);
       Button updateByEmailBtn = (Button) findViewById(R.id.updateByEmail);
       Button deleteByEmailBtn = (Button) findViewById(R.id.deleteByEmail);

       // Setup on click listeners
       insertBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               String[] ip = getInputValues();
               Uri uri = getContentResolver().insert(theUri, getContentValues(ip));
               if(ContentUris.parseId(uri) == -1){
                   textInfo.setText("Error occurred: cannot insert the data! It seems like that a person with the same email already exists!");
                   return;
               }
               textInfo.setText("Data Inserted:" +
                       "\nName: " + ip[0] +
                       "\nEmail: " + ip[1] +
                       "\nProfession: " + ip[2]
                   );
               clearInputs();
           }
       });

       queryByEmailBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               String[] ip = getInputValues();

               Cursor cursor = getContentResolver().query(theUri, theProjection, "email=?", new String[]{ip[1]}, null);
               if(cursor.getCount() == 0){
                   textInfo.setText("No record with the email address found");
               }else {
                   cursor.moveToFirst();
                   textInfo.setText("Query Result:" +
                           "\nName: " + cursor.getString(1) +
                           "\nEmail: " + cursor.getString(2) +
                           "\nProfession: " + cursor.getString(3)
                   );
               }
               cursor.close();
               clearInputs();
           }
       });

       updateByEmailBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               // Get the row id first
//                textInfo.setText("clicked update?");
               String[] ip = getInputValues();

               Cursor cursor = getContentResolver().query(theUri, theProjection, "email=?", new String[]{ip[1]}, null);
               textInfo.setText("clicked update?");
               if(cursor.getCount() == 0){
                   textInfo.setText("No record with the email address found to delete!");
               }else {

                   cursor.moveToFirst();

                   getContentResolver().update(ContentUris.withAppendedId(theUri, cursor.getInt(0)), getContentValues(ip),
                           "email=?", new String[]{cursor.getString(2)});

                   textInfo.setText("Data Updated For:" +
                           "\nName: " + cursor.getString(1) + " --> " + ip[0] +
                           "\nEmail: " + cursor.getString(2) + " --> " + ip[1] +
                           "\nProfession: " + cursor.getString(3) + " --> " + ip[2]
                   );
               }

               cursor.close();
               clearInputs();
           }
       });

       deleteByEmailBtn.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               String[] ip = getInputValues();
               long ret = getContentResolver().delete(theUri, "email=?", new String[]{ip[1]});
               if(ret <= 0) {
                   textInfo.setText("The delete operation was not successful!");
               }else{
                   textInfo.setText("Deleted successfully!");
               }
               clearInputs();
           }
       });
   }

   public String[] getInputValues(){
       return new String[]{
               nameEdit.getText().toString(),
               emailEdit.getText().toString().toLowerCase(),
               professionEdit.getText().toString()
       };
   }

   public ContentValues getContentValues(String[] ip){
       ContentValues cv = new ContentValues();
       cv.put("name", ip[0]);
       cv.put("email", ip[1]);
       cv.put("profession", ip[2]);
       return cv;
   }

   public void clearInputs(){
       nameEdit.setText("");
       emailEdit.setText("");
       professionEdit.setText("");
   }
}

The Content Provider

Now, in the provider application we need to create a class that extends ContentProvider named SandroProvider.java. I am setting the authority as me.sabuj.sandroprovider1.SANDRO_PROVIDER1. Initially, the content of this should look like the following:

package me.sabuj.sqlitepart1;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;

public class SandroProvider extends ContentProvider {
   public SandroProvider() {
   }

   @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.
       return false;
   }

   @Override
   public Cursor query(Uri uri, String[] projection, String selection,
                       String[] selectionArgs, String sortOrder) {
       // TODO: Implement this to handle query requests from clients.
       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");
   }
}

(I took the help of the IDE to generate it automatically.)

Making the provider class is not everything. You need to add the provider to the manifest file. The provider section should look like the following:

<provider
   android:name=".SandroProvider"
   android:authorities="me.sabuj.sandroprovider1.SANDRO_PROVIDER1"
   android:enabled="true"
   android:exported="true">
</provider>

Additionally, we can create permission stuffs in the manifest like below:

   <permission
       android:name="me.sabuj.sandroprovider1.READ_PERMISSION"
       android:protectionLevel="normal" />

   <permission
       android:name="me.sabuj.sandroprovider1.WRITE_PERMISSION"
       android:protectionLevel="normal" />

And, add the permissions in the provider block:

<provider
   android:name=".SandroProvider"
   android:authorities="me.sabuj.sandroprovider1.SANDRO_PROVIDER1"
   android:enabled="true"
   android:exported="true"
   android:grantUriPermissions="true"
   android:readPermission="me.sabuj.sandroprovider1.READ_PERMISSION"
   android:writePermission="me.sabuj.sandroprovider1.WRITE_PERMISSION"/>

The provider class is really simple if you have properly understood the SQLite mechanisms described in a previous article. It is just the URI matcher and the CRUD method stuffs. The code is more self explanatory:

package me.sabuj.sqlitepart1;

import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;

public class SandroProvider extends ContentProvider {
   SQLiteOpenHelper dbHelper;
   // authority
   public static String AUTORITY = "me.sabuj.sandroprovider1.SANDRO_PROVIDER1";

   // paths
   public static String PERSONS_PATH = "persons";
   public static String SINGLE_PERSON_PATH = "persons/#";

   // types
   public static final int PERSONS_TYPE = 1;
   public static final int SINGLE_PERSON_TYPE = 2;

   // MIME TYPES
   public static String MIME_PERSONS = ContentResolver.CURSOR_DIR_BASE_TYPE + "/" + "vnd.me.sabuj.sqlitepart1.persons";
   public static String MIME_SINGLE_PERSON = ContentResolver.CURSOR_DIR_BASE_TYPE + "/" + "vnd.me.sabuj.sqlitepart1.singleperson";

   // The Matcher
   public static UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
   static{
       uriMatcher.addURI(AUTORITY, PERSONS_PATH, PERSONS_TYPE);
       uriMatcher.addURI(AUTORITY, SINGLE_PERSON_PATH, SINGLE_PERSON_TYPE);
   }


   @Override
   public int delete(Uri uri, String selection, String[] selectionArgs) {
       return dbHelper.getWritableDatabase().delete("persons", selection, selectionArgs);
   }

   @Override
   public String getType(Uri uri) {
       switch (uriMatcher.match(uri)){
           case(PERSONS_TYPE):
               return MIME_PERSONS;
           case(SINGLE_PERSON_TYPE):
               return MIME_SINGLE_PERSON;
           default:
               return null;
       }
   }

   @Override
   public Uri insert(Uri uri, ContentValues values) {
       long ret = dbHelper.getWritableDatabase().insert("persons", null, values);
       Uri insertUri = Uri.parse("content://" + AUTORITY + "/" + PERSONS_PATH + "/" + ret);
       return insertUri;
   }

   @Override
   public boolean onCreate() {
        dbHelper = new MyDbHelper(getContext());
       return true;
   }

   @Override
   public Cursor query(Uri uri, String[] projection, String selection,
                       String[] selectionArgs, String sortOrder) {
       return dbHelper.getWritableDatabase().query("persons", projection, selection, selectionArgs, null, null, sortOrder);
   }

   @Override
   public int update(Uri uri, ContentValues values, String selection,
                     String[] selectionArgs) {
       return dbHelper.getWritableDatabase().update("persons", values, selection, selectionArgs);
   }
}

Complete Project

To help you get the full project of the provider and client application I have created two Github repositories. You need the code from the fourth commit for the provider application and second commit for the client application.

Provider: https://github.com/SabujXi/People-Store/commit/109c4a680663c198e1243d2834283271258515bd

Client: https://github.com/SabujXi/Sandro-Client/commit/41ee9b202a0d33b376450d243190d5d3f146b897

The code may be improved or refactored in future. In future articles on different topics I will use these repositories as a foundation for them. To provide the proper set of code I will provide you with different commit link for different lessons.

Conclusions

Content provider is not only a very important component of Android, but also a bridge that fills the gap of sharing data in a centralized manner keeping data security tight. Also it eliminates the need for a database server systems. A few articles on this does not feel enough to me. But I am not going to write a lot of articles on a single topic. Instead, I am going to bring content providers in whatever other types of lessons we need it. Keep practicing with database in android along with content providers.

By Md. Sabuj Sarker | 11/1/2017 | General

{{CommentsModel.TotalCount}} Comments

Your Comment

{{CommentsModel.Message}}

Recent Stories

Top DiscoverSDK Experts

User photo
3355
Ashton Torrence
Web and Windows developer
GUI | Web and 11 more
View Profile
User photo
3220
Mendy Bennett
Experienced with Ad network & Ad servers.
Mobile | Ad Networks and 1 more
View Profile
User photo
3060
Karen Fitzgerald
7 years in Cross-Platform development.
Mobile | Cross Platform Frameworks
View Profile
Show All
X

Compare Products

Select up to three two products to compare by clicking on the compare icon () of each product.

{{compareToolModel.Error}}

Now comparing:

{{product.ProductName | createSubstring:25}} X
Compare Now