Android Data, Networking & APIs

Project: A Networked Android App

18 min Lesson 10 of 12

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

  1. MainActivity.onCreate() calls viewModel.refresh().
  2. The ViewModel delegates to repository.refresh(), which calls Retrofit on a background thread via enqueue().
  3. When the response arrives, the repository deletes the old rows and inserts fresh ones via Room on the Executor thread.
  4. Room notifies the LiveData returned by dao.getAllArticles().
  5. 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().