Build a photo feed in Android
This tutorial assumes a basic knowledge of how to make Android apps.
When I was a kid, my parents had a Kodak camera that they only used on vacations or special events. It used film rolls, and you had to take them to a specialty shop to have them developed so you could get your photos a few days later. Sometimes, we couldn’t even fill a 100-pocket photo album in an entire year.
Nowadays, the number of the photos we take has exploded exponentially. With cameras in even the most basic mobile phones, you can easily take hundreds of photos in a day without any issues. And sites like Instagram, Flickr, and 500px, among others, made specifically to share, comment, and like photos are very popular.
So why not build a feed to track a stream of photos in our Android device in realtime?
In this tutorial, we’re going to get the photos from Reddit (in particular, from the r/pics subreddit), taking advantage of the Pusher Realtime Reddit API.
To keep things simple, we’ll implement the feed without any other feature. This is how the final app will look:
Let’s get started!
Using the Pusher Realtime Reddit API
You can learn more about the Pusher Realtime Reddit API here, but basically the idea is that any subreddit has its own Pusher channel to which you can subscribe to get new listings events.
You can see an interactive code example of this on JSBin.
For our needs, we can try this simple Javascript snippet:
// Open a Pusher connection to the Realtime Reddit API
var pusher = new Pusher("50ed18dd967b455393ed");
// Subscribe to the pics subreddit (lowercase)
var subredditChannel = pusher.subscribe("pics");
// Listen for new stories
subredditChannel.bind("new-listing", function(listing) {
// Output listing to the browser console
console.log(listing);
});
The Pusher app key you have to use is 50ed18dd967b455393ed
. Here’s a sample of the information that we can get from Reddit:
{
approved_by: null,
archived: false,
author: "PHIL-yes-PLZ",
author_flair_css_class: null,
author_flair_text: null,
banned_by: null,
brand_safe: true,
clicked: false,
contest_mode: false,
created: 1489494725,
created_utc: 1489465925,
distinguished: null,
domain: "i.redd.it",
downs: 0,
edited: false,
gilded: 0,
hidden: false,
hide_score: true,
id: "5za4q7",
is_self: false,
likes: null,
link_flair_css_class: null,
link_flair_text: null,
locked: false,
media: null,
media_embed: [object Object] { ... },
mod_reports: [],
name: "t3_5za4q7",
num_comments: 0,
num_reports: null,
over_18: false,
permalink: "/r/pics/comments/5za4q7/the_beauty_of_budding_stained_glass/",
post_hint: "image",
preview: [object Object] {
enabled: true,
images: [[object Object] {
id: "rqR81Yj7Fud7Y8P94e8ZftEZyTEO4Q3ufVQ7f-9QNSM",
resolutions: [[object Object] {
height: 81,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?fit=crop&crop=faces%2Centropy&arh=2&w=108&s=c174d33e47fa3e585c46622dfca12dd5",
width: 108
}, [object Object] {
height: 162,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?fit=crop&crop=faces%2Centropy&arh=2&w=216&s=22711bde5e57c38f93e99de27bb2f1ee",
width: 216
}, [object Object] {
height: 240,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?fit=crop&crop=faces%2Centropy&arh=2&w=320&s=a22e5a2857b40d0205e07724a89d4182",
width: 320
}, [object Object] {
height: 480,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?fit=crop&crop=faces%2Centropy&arh=2&w=640&s=7dc827127272f6aa530faa8b29a8298f",
width: 640
}, [object Object] {
height: 720,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?fit=crop&crop=faces%2Centropy&arh=2&w=960&s=8402002d064b3283742f8bc86163d552",
width: 960
}, [object Object] {
height: 810,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?fit=crop&crop=faces%2Centropy&arh=2&w=1080&s=ac9b66669198d0eb4bb8a47e1cc79e48",
width: 1080
}],
source: [object Object] {
height: 2448,
url: "https://i.redditmedia.com/Lw666GULI7dJMKrB3IKn8G95A0MuP-ztXwsmvIdhlsE.jpg?s=50a1044924ba1e0aa39a7f5f5ab33d8e",
width: 3264
},
variants: [object Object] { ... }
}]
},
quarantine: false,
removal_reason: null,
report_reasons: null,
saved: false,
score: 1,
secure_media: null,
secure_media_embed: [object Object] { ... },
selftext: "",
selftext_html: null,
spoiler: false,
stickied: false,
subreddit: "pics",
subreddit_id: "t5_2qh0u",
subreddit_name_prefixed: "r/pics",
subreddit_type: "public",
suggested_sort: null,
thumbnail: "https://b.thumbs.redditmedia.com/JlIMJkuHQsCnp4Gn7h_OT2AedCJd_QQ-otJm1PUi1cc.jpg",
title: "The beauty of budding stained glass.",
ups: 1,
url: "https://i.redd.it/vo690nyiwaly.jpg",
user_reports: [],
visited: false
}
With this in mind, let’s create the Android app.
The Android app
Open Android Studio and create a new project:
We’re not going to use anything special, so we can safely support a low API level:
Next, create an initial empty activity:
And use the default name of MainActivity
with backward compatibility:
Once everything is set up, let’s install the project dependencies. In the dependencies
section of the build.gradle
file of your application module add:
dependencies {
...
compile 'com.android.support:recyclerview-v7:25.1.1'
compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'com.pusher:pusher-java-client:1.4.0'
compile 'com.google.code.gson:gson:2.4'
...
}
At the time of writing, the latest SDK version is 25, so that’s my target SDK version.
We’re going to use the RecyclerView
component from the Support Library, so make sure you have it installed (in Tools -> Android -> SDK Manager -> SDK Tools tab the Android Support Repository must be installed).
To download the images we’re going to use Glide, one of the most popular open-source Android libraries for loading images.
By default, Glide uses a custom implementation of HttpURLConnection to load images over the network. This is what we’ll be using here. However, Glide also provides plugins to other popular networking libraries such as Volley or OkHttp, you just need to add the corresponding dependencies:
dependencies {
...
compile 'com.github.bumptech.glide:glide:3.7.0'
...
// Volley
compile 'com.github.bumptech.glide:volley-integration:1.4.0@aar'
compile 'com.android.volley:volley:1.0.0'
// okhttp 3
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
compile 'com.squareup.okhttp3:okhttp:3.6.0'
// okhttp 2
compile 'com.github.bumptech.glide:okhttp-integration:1.4.0@aar'
compile 'com.squareup.okhttp:okhttp:2.7.2'
...
}
Sync the Gradle project so the modules can be installed and the project built.
Also, don’t forget to add the INTERNET
permission to the AndroidManifest.xml
file. This is required so we can connect to Pusher and get the events in realtime:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.pusher.photofeed">
<uses-permission android:name="android.permission.INTERNET" />
<application>
...
</application>
</manifest>
Now, modify the layout file activity_main.xml so it looks like this:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.pusher.photofeed.MainActivity">
<android.support.v7.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/recycler_view" />
</RelativeLayout>
We’re going to use a RecyclerView to display the images, which we’ll store in a list. Each item in this list is displayed in an identical manner, so let’s define another layout file to inflate them.
Create the file item.xml with the following content:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/photo"
android:adjustViewBounds="true"
android:layout_height="200dp"
android:scaleType="centerCrop"
android:layout_margin="2dp"
android:layout_width="match_parent"/>
</LinearLayout>
Here, we’re just using an ImageView component to display the image, with a height of 200dp
and a scaleType
equal to centerCrop
, to scale the image uniformly (maintain the image’s aspect ratio) so both dimensions (width and height) will be equal to or larger than the corresponding dimension of the view (minus padding), among other properties.
Now, to store the information for each image, which right now is just its URL, let’s create a class, com.pusher.photofeed.Photo:
public class Photo {
private String url;
public Photo(String url) {
this.url = url;
}
public String getUrl() {
return url;
}
}
RecyclerView
works with an Adapter to manage the items of its data source (in this case a list of Photo
instances), and a ViewHolder to hold a view representing a single list item, so first create the class com.pusher.photofeed.PhotoAdapter with the following code:
public class PhotoAdapter extends RecyclerView.Adapter<PhotoAdapter.PhotoViewHolder> {
private List<Photo> photos;
private Context context;
public PhotoAdapter(Context context, List<Photo> photos) {
this.photos = photos;
this.context = context;
}
public void addPhoto(Photo photo) {
// Add the event at the beggining of the list
photos.add(0, photo);
// Notify the insertion so the view can be refreshed
notifyItemInserted(0);
}
@Override
public int getItemCount() {
return photos.size();
}
}
We initialize the class with a list of Photo
instances and a Context
(Glide will need it), provide a method to add Photo
instances at the beginning of the list (addPhoto(Photo)
) and then notify the insertion so the view can be refreshed, and implement getItemCount
so it returns the size of the list.
Then, let’s add the ViewHolder
as an inner class. It references the ImageView
component for each item in the list:
public class PhotoAdapter extends RecyclerView.Adapter<PhotoAdapter.PhotoViewHolder> {
...
public static class PhotoViewHolder extends RecyclerView.ViewHolder {
public ImageView photoImageView;
public PhotoViewHolder(View v) {
super(v);
photoImageView = (ImageView) v.findViewById(R.id.photo);
}
}
}
And implement the methods onCreateViewHolder
and onBindViewHolder
:
public class PhotoAdapter extends RecyclerView.Adapter<PhotoAdapter.PhotoViewHolder> {
...
@Override
public PhotoViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
View v = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item, viewGroup, false);
return new PhotoViewHolder(v);
}
@Override
public void onBindViewHolder(PhotoViewHolder holder, int position) {
Photo photo = photos.get(position);
String url = photo.getUrl();
Glide.with(context)
.load(url)
.asBitmap()
.error(R.drawable.logo)
.fitCenter()
.into(holder.photoImageView);
}
}
In the onCreateViewHolder
method, we inflate the layout with the content of the event_row.xml
file we created earlier, and in onBindViewHolder
, we use Glide to fetch the image and display it in the ImageView
of the item with the following method calls:
with(Context)
initializes the loading processing passing the context.load(String)
loads the image from the specified URL.asBitmap()
makes sure that Glide receives an image that can be converted to a bitmap, otherwise the load will fail (for example if the URL represents an HTML page) and theDrawable
passed to theerror
method will be shown instead.error(Drawable)
shows theDrawable
if the load fails (in the GitHub version of this app, the Pusher logo, but you can add your own error image).fitCenter()
scales the image uniformly (maintaining the image’s aspect ratio) so the image will fit in the given area.into(ImageView)
specifies the target image view into which the image will be placed.
In the class com.pusher.photofeed.MainActivity, let’s start by defining the private fields we’re going to need:
public class MainActivity extends AppCompatActivity {
private RecyclerView.LayoutManager lManager;
private PhotoAdapter adapter;
private Pusher pusher = new Pusher("50ed18dd967b455393ed");
private static final String CHANNEL_NAME = "pics";
@Override
protected void onCreate(Bundle savedInstanceState) {
....
}
}
RecyclerView
works with a LayoutManager to handle the layout and scroll direction of the list. We declare the PhotoAdapter
, the Pusher
object and the identifier for the Pusher channel.
Inside the onCreate
method, let’s assign a LinearLayoutManager to the RecyclerView
and create the EventAdapter
with an empty list:
public class MainActivity extends AppCompatActivity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Get the RecyclerView
RecyclerView recycler = (RecyclerView) findViewById(R.id.recycler_view);
// Use LinearLayout as the layout manager
lManager = new LinearLayoutManager(this);
recycler.setLayoutManager(lManager);
// Set the custom adapter
List<Photo> photoList = new ArrayList<>();
adapter = new PhotoAdapter(this, photoList);
recycler.setAdapter(adapter);
}
}
For the Pusher part, we first subscribe to the channel:
public class MainActivity extends AppCompatActivity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
...
Channel channel = pusher.subscribe(CHANNEL_NAME);
}
Then, we create the listener that will be executed when a photo arrives:
public class MainActivity extends AppCompatActivity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
...
SubscriptionEventListener eventListener = new SubscriptionEventListener() {
@Override
public void onEvent(String channel, final String event, final String data) {
runOnUiThread(new Runnable() {
@Override
public void run() {
System.out.println("Received event with data: " + data);
Gson gson = new Gson();
Photo photo = gson.fromJson(data, Photo.class);
adapter.addPhoto(photo);
((LinearLayoutManager)lManager).scrollToPositionWithOffset(0, 0);
}
});
}
};
}
}
Here, the JSON string that we receive is converted to a Photo
object and is added to the adapter. Finally, we move to the top of the list.
Next, bind the events to this listener and call the connect
method on the Pusher object:
public class MainActivity extends AppCompatActivity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
...
channel.bind("new-listing", eventListener);
pusher.connect();
}
}
The connect
method can take a listener that can be helpful to debug problems you might have:
pusher.connect(new ConnectionEventListener() {
@Override
public void onConnectionStateChange(ConnectionStateChange change) {
System.out.println("State changed to " + change.getCurrentState() +
" from " + change.getPreviousState());
}
@Override
public void onError(String message, String code, Exception e) {
System.out.println("There was a problem connecting!");
e.printStackTrace();
}
});
Finally, MainActivity
also needs to implement the onDestroy()
method so we can have the opportunity to unsubscribe from Pusher when the activity is destroyed:
public class MainActivity extends AppCompatActivity {
...
@Override
public void onDestroy() {
super.onDestroy();
pusher.disconnect();
}
}
And that’s it. Let’s test it.
Testing the app
Execute the app, either on a real device or a virtual one:
You’ll be presented with an almost blank screen:
When a new image is uploaded to Reddit, it will show up in the app (it may take a while, depending on the amount of activity at the time):
Conclusion
Hopefully, this tutorial has shown you how simple it is to build a realtime photo feed in Android and Pusher. You can improve the app by changing the design, showing more information, or saving it to a database.
Further reading
16 March 2017
by Esteban Herrera