DEV Community

Liriel
Liriel

Posted on

Building GitHub Companion taught me a few things about Android architecture...

One of the biggest challenges when learning Android development is moving beyond isolated code examples and building an application that resembles a production environment. Once an application starts interacting with external APIs, local databases, background synchronization, and asynchronous operations, concepts such as MVVM, repositories, dependency injection, and state management become essential rather than optional.

To explore these concepts in practice, I built GitHub Companion, a native Android application that consumes the GitHub REST API while combining modern Kotlin components with Java interoperability commonly found in real-world Android projects.

1. Why Java Still Matters in Android Development

Kotlin is currently the preferred language for Android, but Java remains a foundational pillar of the ecosystem:

  • Coexistence and Legacy Codebases: A vast number of production Android applications contain legacy systems written entirely in Java. Engineering teams rely on developers who can seamlessly navigate, refactor, and extend Java modules alongside newer Kotlin additions.
  • Interoperability: Kotlin and Java compile to the same JVM bytecode and are 100% interoperable. In modern projects, it is common to write UI layers in Kotlin (e.g., Jetpack Compose) while maintaining stable backend interfaces, models, and network libraries in Java.
  • Solid OOP Foundations: Understanding Java's strict object-oriented design, garbage collection, and class hierarchies makes it significantly easier to grasp advanced Android frameworks and design patterns.

2. Architecture: Demystifying MVVM and Coroutines

The application is built on the Model-View-ViewModel (MVVM) pattern, which enforces a strict separation of concerns:

  • Model: Encapsulates the domain logic, database entities, and API clients.
  • View: Handles user interaction and rendering (in our case, declarative Jetpack Compose).
  • ViewModel: Holds UI state and coordinates data operations. It communicates with the domain repositories and exposes states (Loading, Success, Error) to the View.

To handle asynchronous networking without blocking the UI thread, we utilize Kotlin Coroutines. Coroutines provide a non-blocking execution model. By launching network requests in a Coroutine scope, we suspend task execution while waiting for the network, and resume automatically when the data arrives, keeping the main thread free to draw the UI.


3. Under the Hood: Deep Technical Insights

To build reliable applications, we must understand how our frameworks operate underneath the abstraction layers. Here is how our app functions "under the hood."

A. Why Room Exists If SQLite Already Exists

SQLite is the engine under Android's local database, but using raw SQLite requires significant boilerplate. Developers must manually write SQL queries as strings, translate Cursor objects into Java/Kotlin objects, and handle database updates.

Room is an abstraction layer over SQLite that solves these issues:

  1. Compile-Time Verification: Room parses and validates your SQL queries during compilation. If you mistype a table name in a @Query annotation, the build fails immediately. With raw SQLite, this syntax error would only crash your app at runtime on the user's device.
  2. No Boilerplate Mapping: Room maps database rows directly to Java/Kotlin Data Objects (Entities) using annotation processors, eliminating cursor traversal loops.
  3. Reactive Streams: Room seamlessly integrates with Flow and LiveData. When a row in your SQLite table changes, Room automatically publishes the updated query results to any active subscribers, keeping the UI in sync.

B. What Actually Happens When Retrofit Executes a Request

When you define a networking interface in Retrofit, it is just an interface with annotations—there is no implementation class. How does it execute?

  1. Dynamic Proxy: When you initialize Retrofit and call retrofit.create(GitHubApiService.class), Java's reflection mechanism uses Proxy.newProxyInstance() to construct a dynamic proxy class at runtime.
  2. Annotation Processing: When a method (like getRepository()) is called, Retrofit intercepts the call, parses the annotations (such as @GET and @Path) using reflection, and builds an HTTP request configuration.
  3. OkHttp Core: Retrofit passes this request configuration to OkHttp, which manages the low-level connection pool, headers, and HTTP interceptors.
  4. Serialization: Once the raw bytes return from the server, Retrofit uses GsonConverterFactory to deserialize the JSON stream into a Kotlin/Java DTO (Data Transfer Object).
  5. Thread Switching: If using Java Call.enqueue(), Retrofit schedules the HTTP work on a background executor pool and posts the result back to the Android Main Thread. If using Kotlin suspend, it suspends the Coroutine and resumes on the dispatcher scope.

C. Coroutines Are Not Threads

A common misconception is that a Coroutine is simply a thread wrapper. It is not.

  • Threads are managed by the Operating System. Context switching between threads is expensive because the OS must save registers and CPU states, costing memory and time. You cannot run millions of threads concurrently.
  • Coroutines are managed by the runtime environment and are cooperative. They use suspension points where they yield the underlying thread to other coroutines without blocking it.
  • Lightweight: A coroutine is just a small object allocated on the heap. You can launch 100,000 coroutines on a single thread without memory pressure.
  • Dispatchers: Coroutines execute on dispatchers (like Dispatchers.IO for network/disk operations or Dispatchers.Main for UI changes). The dispatcher acts as a scheduler, mapping coroutine tasks to a thread pool underneath.

D. Understanding Dependency Injection Through Hilt

Without Dependency Injection (DI), classes instantiate their own dependencies. For example, a MainActivity would instantiate a Repository, which instantiates a RoomDatabase and a Retrofit client. This creates tight coupling, makes testing impossible, and violates the Single Responsibility Principle.

Hilt (built on Dagger) is a compile-time DI library:

  1. Service Decoupling: Instead of writing new GitHubRepositoryImpl(...), we annotate the class constructor with @Inject and Hilt automatically provides the dependency.
  2. Codegen Graph: During compilation, Hilt parses our annotations and generates the dependency graph. It validates that every class has access to the instances it needs. If a dependency is missing, the app fails to compile.
  3. Scope Management: Hilt couples dependencies to Android lifecycles (e.g., @Singleton lives for the app's duration, while @ActivityScoped exists only as long as the Activity).

E. Why Repository Exists

The Repository pattern serves a vital architectural purpose: it isolates the domain layer from data origin details.
Without a Repository, the ViewModel would need to coordinate Room database transactions and Retrofit network calls directly. If we decided to migrate our caching engine from Room to Realm, we would have to rewrite the ViewModel.

The Repository provides a Single Source of Truth (SSOT). The ViewModel simply requests a repository record, and the Repository decides whether to fetch it from SQLite or trigger a web request. During tests, we mock the Repository interface using mock libraries (like Mockito) to return local test data, verifying our UI states without hitting real APIs.

F. The WorkManager + Hilt Crash That Took Hours to Debug

When setting up background syncing, we encountered a runtime crash: NoSuchMethodException: SyncFavoritesWorker.<init>.

  • The Cause: By default, Android's androidx.startup library automatically initializes WorkManager. During startup, WorkManager uses reflection to look for a default two-argument constructor (Context, WorkerParameters). Because our worker uses @HiltWorker to inject dependencies, it lacks this constructor, causing reflection to fail.
  • The Resolution: We had to disable the automatic WorkManager initializer in the AndroidManifest.xml via:

    <meta-data
        android:name="androidx.work.WorkManagerInitializer"
        android:value="androidx.startup"
        tools:node="remove" />
    

    Then, we implemented Configuration.Provider in our Application class, manually injecting the custom HiltWorkerFactory to handle the instantiation.

G. Cache Invalidation Is Harder Than It Looks

Offline synchronization sounds simple: save data locally and show it. But what happens if the data on the server changes?
If the app never checks for updates, the user sees stale information forever. If the app updates too often, it defeats the purpose of caching and drains the battery.

To solve this, we implemented a Time-Based Invalidation Policy:

  • Every database record includes a lastSync timestamp.
  • When a user searches for a repository, the repository queries Room and checks if System.currentTimeMillis() - lastSync < 30 minutes.
  • If valid, it returns the cached data. If expired, it attempts a network fetch. If the network call fails (e.g., no internet), it falls back gracefully to the expired cache.

H. Why GitHub API Rate Limits Matter

The GitHub REST API enforces a strict rate limit of 60 requests per hour for unauthenticated requests.

If our app did not cache data locally, every tab switch (Commits -> Branches -> Issues) or list reload would count against the user's rate limit. The user would quickly receive a 403 Forbidden error. By caching details in Room for 30 minutes and grouping repository data, we minimize API calls, saving our network budget.

I. Why ANRs Happen

An Application Not Responding (ANR) dialog is shown when the Main Thread (UI Thread) is blocked.
In Android, the Main Thread has a message loop managed by a Looper. It processes layout draws, touch events, and system notifications. If any event takes longer than 5 seconds to execute, the OS assumes the app is frozen and shows the ANR dialog.

Running a database query or an API request synchronously blocks the Thread until the socket completes or disk I/O completes. In our application, we prevent this by executing database queries asynchronously using Room's Flow bindings and performing Retrofit calls inside Kotlin Coroutines or Java's background executor thread pools.


4. Java-Kotlin Interoperability: The Date Formatter

To show how Java classes integrate with Kotlin, we implemented DateFormatterUtil.java in Java to parse ISO-8601 strings into user-friendly relative dates (e.g., "3 minutes ago").

In our Kotlin presentation layer, we consume this Java helper directly:

val relativeDate = DateFormatterUtil.formatGithubDate(commit.date)
Enter fullscreen mode Exit fullscreen mode

5. Architectural Code Walkthrough

Below are the streamlined core components of the application. The full, concrete implementations can be viewed directly in the project repository.

A. Consuming REST APIs (Retrofit Interface)

Retrofit abstracts network endpoints. In a mixed codebase, we can define our endpoints in Java using Call<T> or in Kotlin using suspend functions:

  • Kotlin (using Coroutines):

    interface GitHubApiService {
        @GET("repos/{owner}/{repo}")
        suspend fun getRepository(
            @Path("owner") owner: String,
            @Path("repo") repo: String
        ): RepositoryDto
    }
    
  • Java Equivalent (using Call<T>):

    public interface GitHubApiService {
        @GET("repos/{owner}/{repo}")
        Call<RepositoryDto> getRepository(
            @Path("owner") String owner,
            @Path("repo") String repo
        );
    }
    

B. The Repository Pattern (Cache Management)

The Repository acts as a mediator between data sources, providing a single source of truth.

  • Kotlin Implementation:

    `  class GitHubRepositoryImpl(
        private val apiService: GitHubApiService,
        private val repositoryDao: RepositoryDao
    ) : GitHubReposit[](url)ory {
        override suspend fun getRepository(owner: String, name: String, forceRefresh: Boolean): Repository {
            val localRepo = repositoryDao.getFavoriteById("$owner/$name")
            val isCacheValid = localRepo != null && (System.currentTimeMillis() - localRepo.lastSync < 30 * 60 * 1000)
    
            if (isCacheValid && !forceRefresh) return localRepo!!.toDomain()
    
            return try {
                val dto = apiService.getRepository(owner, name)
                dto.toDomain().also { repositoryDao.insertFavorite(RepositoryEntity.fromDomain(it)) }
            } catch (e: Exception) {
                localRepo?.toDomain() ?: throw e
            }
        }
    }
    


    `

C. State Management (ViewModel)

The ViewModel hosts the application state, pushing states lifecycle-safely to the View.

  • Java LiveData ViewModel:
    `java
    public class RepositorySearchViewModel extends ViewModel {
    private final GitHubRepositoryImpl repository;
    private final MutableLiveData> searchResult = new MutableLiveData<>();

    public LiveData<Resource<Repository>> getSearchResult() { return searchResult; }
    
    public void searchRepository(String owner, String repo) {
        searchResult.setValue(Resource.loading());
        new Thread(() -> {
            try {
                Repository result = repository.getRepository(owner, repo, true);
                searchResult.postValue(Resource.success(result));
            } catch (Exception e) {
                searchResult.postValue(Resource.error(e.getMessage()));
            }
        }).start();
    }
    

    }
    `


6. Key Concepts Comparison: Kotlin vs. Java

Developers working in mixed codebases must understand the structural equivalents across both languages:

Concept Kotlin Pattern Java Equivalent
Async Operations Coroutines (suspend) Executors, Thread, or Retrofit Call.enqueue()
State Observation StateFlow or SharedFlow LiveData or RxJava Observable
Data Binding Jetpack Compose State LiveData.observe() or View Binding
Data Classes data class Repository(...) Standard Java Class with private fields, getters, and constructor

7. Application Branding & Interface Preview

To give the application a premium identity, we designed a custom logo combining a robotic companion concept with the GitHub symbol:

GitHub Companion Logo

Below is a preview of the search and detailed views of the app:

GitHub Companion Mockup


8. Clean Code & Mobile Best Practices

  • DRY (Don't Repeat Yourself): Network responses (DTOs) are mapped to domain models in a centralized mapping layer, ensuring parsing logic is never repeated.
  • SOLID Principles: We respect the Single Responsibility Principle. For example, database DAOs are solely responsible for SQL queries, while the Repository handles cache management logic.
  • Version Control (Git): Adopted a feature-branch workflow (e.g., feature/retrofit-api) to keep the main development branch clean, employing semantic commit logs.

9. Conclusion & Learnings

Building GitHub Companion highlighted the value of structured architecture. By leveraging the Repository Pattern and MVVM, writing clean, encapsulated Java models, and integrating Kotlin Coroutines and Retrofit REST APIs, we built a codebase that is highly testable, scalable, and easy to maintain in a mixed-language development environment.

Working directly on these layers shows that mobile development is more than just writing UI; it requires understanding JVM thread synchronization, memory architectures, caching thresholds, and how frameworks integrate.


Video

10. References & Further Reading

To learn more about Android architectures, JVM interoperability, and best practices, check out these official developer resources:

You can explore the full implementation code of this project in my GitHub Repository:

Top comments (0)