Project: A Networked Android App
This final lesson puts every concept from the tutorial together into one working Android application. You will build a News Headlines app that fetches articles from the public NewsAPI, caches them in a local Room database, and displays them in a RecyclerView. When the device has no network, the user still sees the last-fetched data. When the network returns, the app refreshes silently in the background.
Pattern used throughout: the Repository pattern with a single-source-of-truth strategy. The UI always reads from Room; the repository decides when to fetch from the network and write fresh data back to Room. This prevents flickering, handles offline gracefully, and keeps Activity code clean.
Project Setup
Create a new Android project (Empty Activity, Java, min SDK 21). Add these dependencies to app/build.gradle:
dependencies {
// Retrofit + Gson converter
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Room
implementation 'androidx.room:room-runtime:2.6.1'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
// ViewModel + LiveData
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata:2.7.0'
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.3.2'
// Glide (image loading)
implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
}
Add the Internet permission to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Step 1 — Define the Room Entity
A Room @Entity maps directly to a table row. Only store what you display or query on — do not mirror every API field.
// Article.java
@Entity(tableName = "articles")
public class Article {
@PrimaryKey
@NonNull
public String url; // unique identifier from the API
public String title;
public String source;
public String urlToImage;
public String publishedAt;
public Article(@NonNull String url, String title,
String source, String urlToImage, String publishedAt) {
this.url = url;
this.title = title;
this.source = source;
this.urlToImage = urlToImage;
this.publishedAt = publishedAt;
}
}
Step 2 — Room DAO and Database
// ArticleDao.java
@Dao
public interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY publishedAt DESC")
LiveData<List<Article>> getAllArticles();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertAll(List<Article> articles);
@Query("DELETE FROM articles")
void deleteAll();
}
// AppDatabase.java
@Database(entities = {Article.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
private static volatile AppDatabase INSTANCE;
public abstract ArticleDao articleDao();
public static AppDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (AppDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
AppDatabase.class,
"news_db"
).build();
}
}
}
return INSTANCE;
}
}
Double-checked locking in getInstance() is the classic thread-safe singleton pattern for Android. The volatile keyword prevents the JVM from reordering the write before the object is fully constructed.
Step 3 — Retrofit API Interface
Model the JSON response with two simple POJOs, then declare the endpoint:
// NewsResponse.java (matches the JSON shape from NewsAPI)
public class NewsResponse {
public List<ArticleDto> articles;
}
// ArticleDto.java
public class ArticleDto {
public String url;
public String title;
public String urlToImage;
public String publishedAt;
public Source source;
public static class Source {
public String name;
}
}
// NewsApiService.java
public interface NewsApiService {
// Replace YOUR_KEY with a free key from newsapi.org
@GET("v2/top-headlines?country=us&apiKey=YOUR_KEY")
Call<NewsResponse> getTopHeadlines();
}
// RetrofitClient.java
public class RetrofitClient {
private static Retrofit instance;
public static NewsApiService getService() {
if (instance == null) {
instance = new Retrofit.Builder()
.baseUrl("https://newsapi.org/")
.addConverterFactory(GsonConverterFactory.create())
.build();
}
return instance.create(NewsApiService.class);
}
}
Step 4 — Repository (Single Source of Truth)
The repository is the only class that knows about both Room and Retrofit. The UI never touches either directly.
// ArticleRepository.java
public class ArticleRepository {
private final ArticleDao dao;
private final NewsApiService api;
private final Executor executor = Executors.newSingleThreadExecutor();
public ArticleRepository(Application app) {
dao = AppDatabase.getInstance(app).articleDao();
api = RetrofitClient.getService();
}
/** The LiveData the UI observes — always comes from Room. */
public LiveData<List<Article>> getArticles() {
return dao.getAllArticles();
}
/** Triggers a background network fetch; writes results to Room. */
public void refresh() {
api.getTopHeadlines().enqueue(new Callback<NewsResponse>() {
@Override
public void onResponse(Call<NewsResponse> call,
Response<NewsResponse> response) {
if (response.isSuccessful() && response.body() != null) {
List<Article> list = mapToEntities(response.body().articles);
executor.execute(() -> {
dao.deleteAll();
dao.insertAll(list);
});
}
}
@Override
public void onFailure(Call<NewsResponse> call, Throwable t) {
// Network unavailable — Room still has the last-cached data
Log.w("ArticleRepository", "Fetch failed: " + t.getMessage());
}
});
}
private List<Article> mapToEntities(List<ArticleDto> dtos) {
List<Article> result = new ArrayList<>();
for (ArticleDto dto : dtos) {
result.add(new Article(
dto.url,
dto.title,
dto.source != null ? dto.source.name : "Unknown",
dto.urlToImage,
dto.publishedAt
));
}
return result;
}
}
Step 5 — ViewModel
// ArticleViewModel.java
public class ArticleViewModel extends AndroidViewModel {
private final ArticleRepository repository;
public final LiveData<List<Article>> articles;
public ArticleViewModel(Application app) {
super(app);
repository = new ArticleRepository(app);
articles = repository.getArticles();
}
public void refresh() {
repository.refresh();
}
}
Step 6 — RecyclerView Adapter
// ArticleAdapter.java
public class ArticleAdapter extends RecyclerView.Adapter<ArticleAdapter.VH> {
private List<Article> data = Collections.emptyList();
public void submitList(List<Article> list) {
data = list;
notifyDataSetChanged();
}
@NonNull @Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_article, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
Article a = data.get(position);
holder.title.setText(a.title);
holder.source.setText(a.source);
Glide.with(holder.image.getContext())
.load(a.urlToImage)
.placeholder(R.drawable.ic_placeholder)
.into(holder.image);
}
@Override public int getItemCount() { return data.size(); }
static class VH extends RecyclerView.ViewHolder {
TextView title, source;
ImageView image;
VH(View v) {
super(v);
title = v.findViewById(R.id.tvTitle);
source = v.findViewById(R.id.tvSource);
image = v.findViewById(R.id.ivThumbnail);
}
}
}
Step 7 — MainActivity
// MainActivity.java
public class MainActivity extends AppCompatActivity {
private ArticleViewModel viewModel;
private ArticleAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
RecyclerView rv = findViewById(R.id.recyclerView);
rv.setLayoutManager(new LinearLayoutManager(this));
adapter = new ArticleAdapter();
rv.setAdapter(adapter);
// SwipeRefreshLayout lets the user manually trigger a refresh
SwipeRefreshLayout srl = findViewById(R.id.swipeRefresh);
viewModel = new ViewModelProvider(this).get(ArticleViewModel.class);
viewModel.articles.observe(this, articles -> {
adapter.submitList(articles);
srl.setRefreshing(false);
});
srl.setOnRefreshListener(() -> viewModel.refresh());
// Initial fetch on first launch
viewModel.refresh();
}
}
Never perform Room database operations on the main thread. Room throws an exception if you try. Always use a background thread (Executor, AsyncTask, Thread) or a coroutine-aware approach. In this project the repository uses a dedicated Executor for all DAO writes.
How the Data Flow Works End-to-End
MainActivity.onCreate() calls viewModel.refresh().
- The ViewModel delegates to
repository.refresh(), which calls Retrofit on a background thread via enqueue().
- When the response arrives, the repository deletes the old rows and inserts fresh ones via Room on the
Executor thread.
- Room notifies the
LiveData returned by dao.getAllArticles().
- The
observe() callback on the main thread delivers the new list to the adapter, which refreshes the RecyclerView.
If step 2 fails (no network), step 3 never runs, so Room still holds the previous data and the UI continues showing it — no extra offline-handling code needed.
Summary
You have built a production-quality data pipeline in Android Java: Retrofit fetches, Room persists, LiveData propagates, ViewModel survives configuration changes, and the Activity stays as thin as possible. This architecture — often called MVVM with Repository — scales to any size of app and is the foundation recommended in the official Android Architecture Guide. From here, you can add pagination, WorkManager for background refresh on a schedule, or DiffUtil for efficient list updates without calling notifyDataSetChanged().