= Android Lab: Reactive App with Java Thomas W. Stütz 0.0.1, 2024-05-08 : Android - ToDo App ifndef::imagesdir[:imagesdir: images] //:toc-placement!: // prevents the generation of the doc at this position, so it can be printed afterwards :sourcedir: ../src/main/java :icons: font :sectnums: // Nummerierung der Überschriften / section numbering :toc: left :toclevels: 5 :experimental: // https://mrhaki.blogspot.com/2014/06/awesome-asciidoc-use-link-attributes.html :linkattrs: //Need this blank line after ifdef, don't know why... ifdef::backend-html5[] // https://fontawesome.com/v4.7.0/icons/ icon:file-text-o[link=https://raw.githubusercontent.com/htl-leonding-college/android-reactive-java-todo/main/asciidocs/docs/{docname}.adoc] ‏ ‏ ‎ icon:github-square[link=https://github.com/htl-leonding-college/android-reactive-java-todo] ‏ ‏ ‎ icon:home[link=http://edufs.edu.htl-leonding.ac.at/~t.stuetz/hugo/] endif::backend-html5[] // print the toc here (not at the default position) toc::[] == Prerequisites - What students have to prepare * install Android Studio == Reactive in Android * https://github.com/ReactiveX/RxAndroid[RxAndroid: Reactive Extensions for Android^] * https://github.com/ReactiveX/RxJava[RxJava^], https://github.com/ReactiveX/RxJava/wiki[Wiki^] == Creating the project image::create-project-001.png[] image::create-project-002.png[] === Add RxJava Library, Dagger/Hilt, resteasy-client, jackson-databind, smallrye-config * https://stackoverflow.com/a/78328837/9818338[Hilt dependency in toml file with KSP^] * additional info here: https://medium.com/@duaaawan/hilt-for-android-a-beginners-guide-to-dependency-injection-7f9cadc5526b[Hilt For Android: A Beginner’s Guide to Dependency Injection^] IMPORTANT: As kapt is in maintenance mode, we use KSP .build.gradle.kts(:app) [%collapsible] ==== [source,kotlin] ---- 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) [%collapsible] ==== [source,kotlin] ---- // 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 [%collapsible] ==== [source,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" } ---- ==== == Change to Project-View image::intellij-project-view.png[] == Separate Java-Business-Classes and Kotlin-Compose-Classes * For separating Java and Kotlin classes we use Hilt as CDI-framework. === Configure Hilt - Add Hilt Application Class image::hilt-error-name-in-manifest.png[] * We create a new class `TodoApplication.java` as application entry point. * This class is now our https://developer.android.com/training/dependency-injection/hilt-android#application-class[application-level dependency container^]. image::hilt-application-container.png[] .at.htl.todo.TodoApplication [source,java] ---- 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 [source,java] ---- 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(); // <.> @Override public void onCreate() { super.onCreate(); Log.i(TAG, "App started ..."); // <.> } } ---- <.> Declare always the Logging-Tag <.> Use the logging .Add Application-Class and Internet-Access to `manifest.xml` [source,xml,highlight=6] ---- <.> 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"> ---- <.> Add here the permission for internet access <.> Add here the name of the Hilt Application Class .View in Logcat image::hilt-log-app-started.png[] === @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. * https://developer.android.com/training/dependency-injection/hilt-android#android-classes[Inject dependencies into Android classes^] * https://developer.android.com/training/dependency-injection/hilt-android#define-bindings[Define Hilt bindings^] .at.htl.todo.ui.layout.MainView [source,kotlin] ---- 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 // <.> 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") } } ---- <.> Constructor injection (there are other ways, if constructor injection is not possible). This is constructor injection with a primary constructor + [source,kotlin] ---- @Singleton class MainView @Inject constructor() { //... } ---- .at.htl.todo.MainActivity [source,java] ---- 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; // <.> @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mainView.buildContent(this); // <.> } } ---- <.> Now it is possible to inject the Jetpack Compose view <.> When calling the kotlin function for building the view, we have to pass the Context of the current activity. image::app-hello-android.png[] == Add the Util-Classes * link:files/util.zip[Download these files] image::utils-project-tree.png[] === Immer // TODO: Fundamentals for working with immutable states (immer) === Mapper // TODO: Fundamentals ObjectMapper image::mapper-structure.png[] .at.htl.todo.model. [source,java] ---- 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 the Class that is mapped */ public class Mapper { private Class clazz; private ObjectMapper mapper; public Mapper(Class 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)); } } ---- === MessageBodyWriter / MessageBodyReader .source: https://www.hameister.org/JEE7_JAXRS2_MesssageBodyReaderWriterList.html[MessageBodyReader und MessageBodyWriter für List- JAX-RS 2.0^] image::https://www.hameister.org/images/JEE7_JAXRS_items.png[] * https://javadoc.io/doc/jakarta.ws.rs/jakarta.ws.rs-api/latest/jakarta.ws.rs/jakarta/ws/rs/ext/MessageBodyReader.html[javadoc: MessageBodyReader^] * https://javadoc.io/doc/jakarta.ws.rs/jakarta.ws.rs-api/latest/jakarta.ws.rs/jakarta/ws/rs/ext/MessageBodyWriter.html[javadoc: MessageBodyWriter^] * https://www.examclouds.com/java/web-services/jax-rs-entity-providers[JAX-RS Entity Providers^] === Store // TODO: Fundamentals Reactive Programming //// === Add microprofile config * As in Quarkus we use the https://mvnrepository.com/artifact/io.smallrye.config/smallrye-config/3.8.1[SmallRye Config - Library^] which is following the https://microprofile.io/specifications/microprofile-config/[MicroProfile Config^] * In the utils we already have a class `ConfigModule.java` for configuring SmallRye config. image::config-project-tree.png[] * Now it is possible to config our application in an `application.properties`-file. + .resources/application.properties [source,properties] ---- json.placeholder.baseurl=https://jsonplaceholder.typicode.com ---- * microprofile-config.properties is an empty file //// === 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 + image::config-assets-folder-project-tree.png[] + .main/assets/application.properties [source,properties] ---- json.placeholder.baseurl=https://jsonplaceholder.typicode.com ---- * Then create a java class + .at.htl.todo.util.Config [source,java] ---- 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 + [source,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); } } ---- image::config-assets-logcat-entry.png[] IMPORTANT: Because this always needs a context, it is not usable in context-free services. * https://github.com/smallrye/smallrye-config/issues/1057[Retrieving a SmallRyeConfig instance fails on Android^] == Add the Model and Store * https://redux.js.org/understanding/thinking-in-redux/three-principles[Three Principles^] * https://reactivex.io/intro.html[ReactiveX^] * https://jsonplaceholder.typicode.com/todos[Todos-Endpoint^] image::store-project-tree.png[] .at.htl.todo.model.Todo [source,java] ---- 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 [source,java] ---- package at.htl.todo.model; import java.util.List; public class Model { public Todo[] todos = { new Todo(1L, 1L, "Buy milk", true), // <.> new Todo(2L, 2L, "Buy eggs", false), new Todo(2L, 3L, "Buy bread", false) }; } ---- <.> For now, we use static data until implementing the rest client .at.htl.todo.model.ModelStore [source,java] ---- 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 { @Inject ModelStore() { super(Model.class, new Model()); } public void setTodos(Todo[] todos) { apply(model -> model.todos = todos); } } ---- .at.htl.todo.util.store.Store [source,java] ---- 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 { public final BehaviorSubject pipe; public final Immer immer; protected Store(Class type, T initialState) { try { pipe = BehaviorSubject.createDefault(initialState); immer = new Immer(type); } catch (Exception e) { throw new CompletionException(e); } } public void apply(Consumer recipe) { pipe.onNext(immer.produce(pipe.getValue(), recipe)); } } ---- .MainView (partly) [source,kotlin] ---- @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 [%collapsible] ==== [source,kotlin] ---- 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) } } ---- ==== image::store-app-started.png[] == Add REST-Client INFO: You could also use https://square.github.io/retrofit/[Retrofit^] image::rest-client-project-tree.png[] .at.htl.todo.model.Model [source,java] ---- package at.htl.todo.model; import java.util.List; public class Model { public Todo[] todos = new Todo[0]; } ---- .at.htl.todo.model.TodoClient [source,java] ---- 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 [source,java] ---- 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); } } ---- === 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 [source,java] ---- public void getAll() { CompletableFuture .supplyAsync(() -> todoClient.all()) .thenAccept(store::setTodos) .exceptionally((e) -> { // <.> Log.e(TAG, "Error loading todos", e); return null; }); } ---- <.> add here the exception handling image::internet-access-error.png[] image::rest-client-app-started.png[]