‏ ‏ ‎ ‏ ‏ ‎

1. Prerequisites - What students have to prepare

  • install Android Studio

2. Reactive in Android

3. Creating the project

create project 001
create project 002

3.1. Add RxJava Library, Dagger/Hilt, resteasy-client, jackson-databind, smallrye-config

As kapt is in maintenance mode, we use KSP
build.gradle.kts(:app)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.jetbrains.kotlin.android)
    alias(libs.plugins.kotlinAndroidKsp)
    alias(libs.plugins.hiltAndroid)
}

android {
    namespace = "at.htl.todo"
    compileSdk = 34

    defaultConfig {
        applicationId = "at.htl.todo"
        minSdk = 30
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.13"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
            excludes += "/META-INF/INDEX.LIST"
            excludes += "/META-INF/DEPENDENCIES"
            excludes += "/META-INF/LICENSE.md"
            excludes += "/META-INF/NOTICE.md"
        }
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)

    // RxJava
    implementation (libs.rxjava)
    implementation(libs.rxandroid)
    implementation(libs.androidx.runtime.rxjava3)

    // Hilt
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)

    // Jackson
    implementation(libs.jackson.databind)

    // Resteasy
    implementation(libs.resteasy.client)

    // SmallRye Config
    //implementation("org.eclipse.microprofile.config:microprofile-config-api:3.1") // for application.properties config loader
    implementation(libs.smallrye.config)

}
build.gradle.kts(todo)
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.jetbrains.kotlin.android) apply false
    alias(libs.plugins.hiltAndroid) apply false
    alias(libs.plugins.kotlinAndroidKsp) apply false
}
libs.versions.toml
[versions]
agp = "8.4.0"
hiltVersion = "2.51.1"
jacksonDatabind = "2.17.1"
kotlin = "1.9.23"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.9.0"
composeBom = "2024.05.00"
resteasyClient = "6.2.8.Final"
rxjavaVersion = "3.1.8"
rxandroid = "3.0.2"
runtimeRxjava3 = "1.6.7"
ksp = "1.9.23-1.0.20"
smallryeConfig = "3.8.1"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltVersion" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltVersion" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabind" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
resteasy-client = { module = "org.jboss.resteasy:resteasy-client", version.ref = "resteasyClient" }
rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjavaVersion" }
rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" }
androidx-runtime-rxjava3 = { module = "androidx.compose.runtime:runtime-rxjava3", version.ref = "runtimeRxjava3" }
smallrye-config = { module = "io.smallrye.config:smallrye-config", version.ref = "smallryeConfig" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinAndroidKsp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hiltAndroid = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" }

4. Change to Project-View

intellij project view

5. Separate Java-Business-Classes and Kotlin-Compose-Classes

  • For separating Java and Kotlin classes we use Hilt as CDI-framework.

5.1. Configure Hilt - Add Hilt Application Class

hilt error name in manifest
hilt application container
at.htl.todo.TodoApplication
package at.htl.todo;

import android.app.Application;
import javax.inject.Singleton;
import dagger.hilt.android.HiltAndroidApp;

@HiltAndroidApp
@Singleton
public class TodoApplication extends Application {
}
  • To check, if it is working, we use the Android - Logger

at.htl.todo.TodoApplication with logging
package at.htl.todo;

import android.app.Application;
import javax.inject.Singleton;
import dagger.hilt.android.HiltAndroidApp;

@HiltAndroidApp
@Singleton
public class TodoApplication extends Application {

    static final String TAG = TodoApplication.class.getSimpleName();  (1)

    @Override
    public void onCreate() {
        super.onCreate();
        Log.i(TAG, "App started ..."); (2)
    }

}
1 Declare always the Logging-Tag
2 Use the logging
Add Application-Class and Internet-Access to manifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" /> (1)

    <application
        android:name=".TodoApplication"   (2)
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Todo"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.Todo">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
1 Add here the permission for internet access
2 Add here the name of the Hilt Application Class
hilt log app started
Figure 1. View in Logcat

5.2. @AndroidEntryPoint and Hilt Bindings

at.htl.todo.ui.layout.MainView
package at.htl.todo.ui.layout

import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.tooling.preview.Preview
import at.htl.todo.ui.theme.TodoTheme
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class MainView {

    @Inject (1)
    constructor(){}

    fun buildContent(activity: ComponentActivity) {
        activity.enableEdgeToEdge()
        activity.setContent {
            TodoTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    TodoTheme {
        Greeting("Android")
    }
}
1 Constructor injection (there are other ways, if constructor injection is not possible). This is constructor injection with a primary constructor
@Singleton
class MainView @Inject constructor() {
    //...
}
at.htl.todo.MainActivity
package at.htl.todo;

import android.os.Bundle;
import androidx.activity.ComponentActivity;
import javax.inject.Inject;
import at.htl.todo.ui.layout.MainView;
import dagger.hilt.android.AndroidEntryPoint;

@AndroidEntryPoint
public class MainActivity extends ComponentActivity {

    @Inject
    MainView mainView;  (1)

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mainView.buildContent(this);  (2)
    }
}
1 Now it is possible to inject the Jetpack Compose view
2 When calling the kotlin function for building the view, we have to pass the Context of the current activity.
app hello android

6. Add the Util-Classes

utils project tree

6.1. Immer

6.2. Mapper

mapper structure
at.htl.todo.model.
package at.htl.todo.util.mapper;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import java.io.IOException;

/** A Mapper that maps types to their json representation and back.
 * ... plus a convenient deep-clone function
 * @param <T> the Class that is mapped
 */
public class Mapper<T> {
    private Class<? extends T> clazz;
    private ObjectMapper mapper;

    public Mapper(Class<? extends T> clazz) {
        this.clazz = clazz;
        mapper = new ObjectMapper()
                .configure(SerializationFeature.INDENT_OUTPUT, true)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); // records
    }
    public String toResource(T model) {
        try {
            return mapper.writeValueAsString(model);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
    public T fromResource(String json) {
        T model = null;
        try {
            model = mapper.readValue(json.getBytes(), clazz);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return model;
    }
    /** deep clone an object by converting it to its json representation and back.
     *
     * @param thing the thing to clone, unchanged
     * @return the deeply cloned thing
     */
    public T clone(final T thing) {
        return fromResource(toResource(thing));
    }
}

6.4. Store

6.5. Add Configuration with application.properties

  • Because SmallRye Config - Library didn’t work, we use the assets - folder

  • First create the assets-folder with the application.properties-file

    config assets folder project tree
    main/assets/application.properties
    json.placeholder.baseurl=https://jsonplaceholder.typicode.com
  • Then create a java class

    at.htl.todo.util.Config
    package at.htl.todo.util;
    
    import android.content.Context;
    
    import java.io.IOException;
    import java.io.InputStream;
    import java.util.Properties;
    
    public class Config {
        private static Properties properties;
    
        public static void load(Context context) {
            try {
                InputStream inputStream = context.getAssets().open("application.properties");
                properties = new Properties();
                properties.load(inputStream);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        public static String getProperty(String key) {
            return properties.getProperty(key);
        }
    }
  • Finally, use your configuration i.e. in the MainActivity.java

    @AndroidEntryPoint
    public class MainActivity extends ComponentActivity {
    
        // ...
    
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            Config.load(this);
            var base_url = Config.getProperty("json.placeholder.baseurl");
            Log.i(TAG, "onCreate: " + base_url);
            mainView.buildContent(this);
        }
    }
config assets logcat entry
Because this always needs a context, it is not usable in context-free services.

7. Add the Model and Store

store project tree
at.htl.todo.model.Todo
package at.htl.todo.model;

public class Todo {
    public Long userId;
    public Long id;
    public String title;
    public boolean completed;

    public Todo() {
    }

    public Todo(Long userId, Long id, String title, boolean completed) {
        this.userId = userId;
        this.id = id;
        this.title = title;
        this.completed = completed;
    }
}
at.htl.todo.model.Model
package at.htl.todo.model;

import java.util.List;

public class Model {

    public Todo[] todos = {
            new Todo(1L, 1L, "Buy milk", true), (1)
            new Todo(2L, 2L, "Buy eggs", false),
            new Todo(2L, 3L, "Buy bread", false)
    };

}
1 For now, we use static data until implementing the rest client
at.htl.todo.model.ModelStore
package at.htl.todo.model;

import javax.inject.Inject;
import javax.inject.Singleton;
import at.htl.todo.util.store.Store;

@Singleton
public class ModelStore extends Store<Model>  {

    @Inject
    ModelStore() {
        super(Model.class, new Model());
    }

    public void setTodos(Todo[] todos) {
        apply(model -> model.todos = todos);
    }
}
at.htl.todo.util.store.Store
package at.htl.todo.util.store;

import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
import at.htl.todo.util.immer.Immer;
import io.reactivex.rxjava3.subjects.BehaviorSubject;

public class Store<T> {
    public final BehaviorSubject<T> pipe;
    public final Immer<T> immer;

    protected Store(Class<? extends T> type, T initialState) {
        try {
            pipe = BehaviorSubject.createDefault(initialState);
            immer = new Immer<T>(type);
        } catch (Exception e) {
            throw new CompletionException(e);
        }
    }
    public void apply(Consumer<T> recipe) {
        pipe.onNext(immer.produce(pipe.getValue(), recipe));
    }
}
MainView (partly)
@Singleton
class MainView @Inject constructor() {

    @Inject
    lateinit var store: ModelStore

    fun buildContent(activity: ComponentActivity) {
        activity.enableEdgeToEdge()
        activity.setContent {
            val viewModel = store
                .pipe
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeAsState(initial = Model())
                .value
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colorScheme.background
            ) {
                Todos(model = viewModel, modifier = Modifier.padding(all = 32.dp))
            }
        }
    }
}
at.htl.todo.ui.layout.MainView
package at.htl.todo.ui.layout

import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.htl.todo.model.Model
import at.htl.todo.model.ModelStore
import at.htl.todo.model.Todo
import at.htl.todo.ui.theme.TodoTheme
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class MainView @Inject constructor() {

    @Inject
    lateinit var store: ModelStore

    fun buildContent(activity: ComponentActivity) {
        activity.enableEdgeToEdge()
        activity.setContent {
            val viewModel = store
                .pipe
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeAsState(initial = Model())
                .value
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colorScheme.background
            ) {
                Todos(model = viewModel, modifier = Modifier.padding(all = 32.dp))
            }
        }
    }
}

@Composable
fun Todos(model: Model, modifier: Modifier = Modifier) {
    val todos = model.todos
    LazyColumn(
        modifier = modifier.padding(16.dp)
    ) {
        items(todos.size) { index ->
            TodoRow(todo  = todos[index])
            HorizontalDivider()
        }
    }
}

@Composable
fun TodoRow(todo: Todo) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = todo.title,
            style = MaterialTheme.typography.bodySmall
        )
        Spacer(modifier = Modifier.width(8.dp))
        Text(
            text = todo.id.toString(),
            style = MaterialTheme.typography.bodySmall
        )
        Spacer(modifier = Modifier.weight(1f))
        Checkbox(
            checked = todo.completed,
            onCheckedChange = { /* Update the completed status of the todo item */ }
        )
    }
}

@Preview(showBackground = true)
@Composable
fun TodoPreview() {
    val model = Model()
    val todo = Todo()
    todo.id = 1
    todo.title = "First Todo"
    model.todos = arrayOf(todo)

    TodoTheme {
        Todos(model)
    }
}
store app started

8. Add REST-Client

INFO: You could also use Retrofit

rest client project tree
at.htl.todo.model.Model
package at.htl.todo.model;

import java.util.List;

public class Model {

    public Todo[] todos = new Todo[0];

}
at.htl.todo.model.TodoClient
package at.htl.todo.model;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;

@Path("/todos")
@Consumes(MediaType.APPLICATION_JSON)
public interface TodoClient {
    @GET
    Todo[] all();
}
at.htl.todo.model.TodoService
package at.htl.todo.model;


import android.util.Log;

import java.util.concurrent.CompletableFuture;

import javax.inject.Inject;
import javax.inject.Singleton;

import at.htl.todo.util.resteasy.RestApiClientBuilder;

@Singleton
public class TodoService {
    static final String TAG = TodoService.class.getSimpleName();
    public static String JSON_PLACEHOLDER_BASE_URL = "https://jsonplaceholder.typicode.com";
    public final TodoClient todoClient;
    public final ModelStore store;

    @Inject
    TodoService(RestApiClientBuilder builder, ModelStore store) {
        Log.i(TAG, "Creating TodoService with base url: " + JSON_PLACEHOLDER_BASE_URL);
        todoClient = builder.build(TodoClient.class, JSON_PLACEHOLDER_BASE_URL);
        this.store = store;
    }
    public void getAll() {
        CompletableFuture
                .supplyAsync(() -> todoClient.all())
                .thenAccept(store::setTodos);
    }
}

8.1. Add Exception Handling

  • When the result of the access to the rest-endpoint is empty and there is no error in Logcat, it is recommended not to swallow the error message (you should NEVER swallow an error message).

at.htl.todo.model.TodoService
public void getAll() {
    CompletableFuture
            .supplyAsync(() -> todoClient.all())
            .thenAccept(store::setTodos)
            .exceptionally((e) -> {  (1)
                Log.e(TAG, "Error loading todos", e);
                return null;
            });
}
1 add here the exception handling
internet access error
rest client app started