3. Creating the project
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" }
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
-
We create a new class
TodoApplication.java
as application entry point. -
This class is now our application-level dependency container.
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
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 |
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 |
5.2. @AndroidEntryPoint and Hilt Bindings
-
Once Hilt is set up in your Application class and an application-level component is available, Hilt can provide dependencies to other Android classes that have the @AndroidEntryPoint annotation.
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
|
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. |
6. Add the Util-Classes
6.2. Mapper
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.3. MessageBodyWriter / MessageBodyReader
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
-filemain/assets/application.propertiesjson.placeholder.baseurl=https://jsonplaceholder.typicode.com
-
Then create a java class
at.htl.todo.util.Configpackage 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); } }
Because this always needs a context, it is not usable in context-free services. |
7. Add the Model and Store
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;
}
}
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 |
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);
}
}
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));
}
}
@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)
}
}
8. Add REST-Client
INFO: You could also use Retrofit
package at.htl.todo.model;
import java.util.List;
public class Model {
public Todo[] todos = new Todo[0];
}
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();
}
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).
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 |