Android application: Difference between revisions
No edit summary |
|||
| (14 intermediate revisions by the same user not shown) | |||
| Line 2: | Line 2: | ||
== Requirements == | == Requirements == | ||
== Architecture == | == Architecture == | ||
The app is written completely in Kotlin using Jetpack Compose | The app is written completely in Kotlin | ||
* using Jetpack Compose | |||
* using [https://github.com/evant/kotlin-inject Kotlin-inject] for dependency injection | |||
* using [https://ktor.io Ktor] for server communication | |||
* aiming to implement clean architecture | |||
=== Module structure === | === Module structure === | ||
Besides the main app module, the app consists of the following library modules: | Besides the main app module, the app consists of the following library modules: | ||
| Line 11: | Line 16: | ||
* heating | * heating | ||
* info | * info | ||
* | * summary | ||
* refill | * refill | ||
* schedule | * schedule | ||
| Line 27: | Line 32: | ||
* <code>WebApiParams</code>, <code>WebApiPostRequest</code>, <code>WebApiPostRequestExecution</code>, <code>WebApiUrls</code>: Classes and objects used for communication from the app to the server. | * <code>WebApiParams</code>, <code>WebApiPostRequest</code>, <code>WebApiPostRequestExecution</code>, <code>WebApiUrls</code>: Classes and objects used for communication from the app to the server. | ||
* <code>ControllerProfileDao</code>, <code>ControllerProfileDatabase</code>: Classes which provide Room database functionality. | * <code>ControllerProfileDao</code>, <code>ControllerProfileDatabase</code>: Classes which provide Room database functionality. | ||
* Various classes used for dependency injection | * Various classes used for dependency injection | ||
* <code>SetValsSanityCheckResult</code>: Class used for to communicate status of set values for <code>heating</code> and <code>ventilation</code> module. | * <code>SetValsSanityCheckResult</code>: Class used for to communicate status of set values for <code>heating</code> and <code>ventilation</code> module. | ||
* <code>ControllerProfile</code>: Class containing the profile data of the server (address, port, credentials, ...) | * <code>ControllerProfile</code>: Class containing the profile data of the server (address, port, credentials, ...) | ||
| Line 92: | Line 97: | ||
└── build.gradle // Plugins, setting, dependencies | └── build.gradle // Plugins, setting, dependencies | ||
</code> | </code> | ||
=== Screen navigation === | |||
The screen navigation is controlled by a <code>NavHostController</code> which is created in <code>MainActivity</code> and remembered using <code>rememberNavController</code>. | |||
==== Modular navigation ==== | |||
Each module adds its own navigation function to <code>NavHostController</code>. | |||
These extension functions are stored in <code>[module]/presentation/navigation/[module]Navigator.kt</code>. | |||
They encapsulate the routing logic for that specific module. | |||
==== Event-Driven Navigation ==== | |||
The functions receive as parameter a specific sealed class <code>[module]NavigationEvent</code>. | |||
These sealed classes contain objects for each navigation event inside the module. | |||
==== Implementation pattern ==== | |||
The navigation functions are used in the following locations: | |||
* inside the lambda function passed to <code>NavHost</code> in <code>MainActivity</code> for each screen. | |||
* inside composables themselves being passed as lambda function from within <code>MainActivity</code> | |||
:* The composable remain unaware of <code>NavHostController</code> | |||
==== Routes and arguments ==== | |||
Source and destination of the routing is defined using strings from the object <code>ViewNavigationRoutes</code> where data that needs to be transferred between screens is embedded in the strings. | |||
== Example workflow for adding API communication == | == Example workflow for adding API communication == | ||
| Line 124: | Line 150: | ||
* Added <code>PullToRefreshBox</code> | * Added <code>PullToRefreshBox</code> | ||
* Added parsing of <code>RefillUiState</code> and display of either | * Added parsing of <code>RefillUiState</code> and display of either | ||
:* <code>RefillStateComposable</code> in case of success | |||
:* Text-based error message in case of error | |||
:* <code>CircularProgressIndicator</code> in case of loading | |||
Inject <code>RefillUiState</code> into <code>RefillScreen</code> in the <code>MainActivity</code> and <code>RefillScreenTest</code> as well as in previews of <code>RefillScreen</code> | Inject <code>RefillUiState</code> into <code>RefillScreen</code> in the <code>MainActivity</code> and <code>RefillScreenTest</code> as well as in previews of <code>RefillScreen</code> | ||
| Line 133: | Line 159: | ||
=== Testing === | === Testing === | ||
Update existing screenshot testing | Update existing screenshot testing (compose screenshot testing and instrumented tests) | ||
Add compose screenshot testing for new composable | |||
== Build == | |||
Sync gradle and build in Android Studio. | |||
Run <code>./gradlew :shared:assemble</code> to assemble the shared KMP module. | |||
== Test strategy == | == Test strategy == | ||
The app uses different testing approaches: | The app uses different testing approaches: | ||
* Linting | |||
* Unit tests | |||
* Compose preview screenshot testing | * Compose preview screenshot testing | ||
* Screenshot tests running as instrumented tests on emulator | * Screenshot tests running as instrumented tests on emulator | ||
=== Linting === | |||
Run <code>./gradlew lint</code>: This command shall yield no warnings. | |||
Use the Android Studio code inspection for more detailed linting - some of the warnings cannot be resolved. | |||
=== Compose preview screenshot tests === | === Compose preview screenshot tests === | ||
| Line 167: | Line 202: | ||
The tests run comparably long and are bound to an emulator device. | The tests run comparably long and are bound to an emulator device. | ||
As of November 2025, a Pixel 5 API S is used for the instrumented test. | As of November 2025, a Pixel 5 API S is used for the instrumented test. | ||
The instrumented tests | The instrumented tests contain | ||
* screenshot testing for the complete screens (<code>ScreenNameTest</code>) | |||
* verification if the composable triggers the right event when tapping on buttons bound to navigation to another screen (<code>ScreenNameUITest</code>) | |||
Limitations: | Limitations: | ||
| Line 177: | Line 214: | ||
Omitting the module will execute all tests. | Omitting the module will execute all tests. | ||
A switch called <code>recordMode</code> inside each test class determines if the image is generated <code>(true)</code> or if the image is validated <code>(false)</code>. The images are generated on the device <code>(/data/data/com.laimburggasse.aquariumcontrol.[module].test/files/screenshots_output/)</code> and need to be downloaded to the repository. | A switch called <code>recordMode</code> inside each test class for screenshot testing determines if the image is generated <code>(true)</code> or if the image is validated <code>(false)</code>. The images are generated on the device <code>(/data/data/com.laimburggasse.aquariumcontrol.[module].test/files/screenshots_output/)</code> and need to be downloaded to the repository. | ||
=== Unit tests === | === Unit tests === | ||
As of | As of April 2026, unit tests are developed for: | ||
* all view models | |||
* all repositories | |||
The unit test cases for the view models make extensive use of the <code>MockK</code> library. | |||
The unit test cases for the repositories use mock implementations of the <code>Retrofit</code> interface. | |||
All unit test cases are executed using the command: <code>./gradlew :[module]:testDebugUnitTest</code> | |||
The code coverage report can be created using: <code>./gradlew createDebugUnitTestCoverageReport</code> | |||
The gradle task removes the injected code from the test coverage report: <code>./gradlew jacocoTestReport</code> | |||
The report is stored in <code>[module]/build/reports/jacoco/jacocoTestReport/html</code> | |||
== Release procedure == | == Release procedure == | ||
Latest revision as of 19:46, 21 May 2026
The app is available in the Play store.
Requirements
Architecture
The app is written completely in Kotlin
- using Jetpack Compose
- using Kotlin-inject for dependency injection
- using Ktor for server communication
- aiming to implement clean architecture
Module structure
Besides the main app module, the app consists of the following library modules:
- balling
- common
- controller
- feed
- heating
- info
- summary
- refill
- schedule
- timedata
- ventilation
The main app module contains the AndroidManifest and the main activity (As of November 2025, the app only uses one activity).
The activity contains the navigation NavHost in onCreate.
None of the other modules has a dependency to the main app module.
The common module provides low-level functionalities and layout elements used in various places.
The following functionalities are located in common module:
DataFetchResult: A wrapper class indicating the status of data fetching operation (Success,Error,Loading,Empty).WebApiParams,WebApiPostRequest,WebApiPostRequestExecution,WebApiUrls: Classes and objects used for communication from the app to the server.ControllerProfileDao,ControllerProfileDatabase: Classes which provide Room database functionality.- Various classes used for dependency injection
SetValsSanityCheckResult: Class used for to communicate status of set values forheatingandventilationmodule.ControllerProfile: Class containing the profile data of the server (address, port, credentials, ...)GlobalConstants: Mainly UI-related strings (non-context-related) and some minor functional constants- Composable functions for the main drop down menu
- Composable functions for the theme
- Composable functions for hyperlinks, text edit fields, headline
ViewNavigationRoutes: Object containing the routing information processed in mainappmoduleScreenshotTestHelper: Helper class for executing the instrumented snapshot testing using the emulator.
Layer structure
The code is separated into the following layers:
[module]/
├── src/main/kotlin/com/laimburggasse/aquariumcontrol/[module]/data/
│ ├── remote/ // Folder for data retrieval functionality
│ │ └── dto/ // Folder for Data Transfer Objects
│ │ └── [module][name]Import.kt
│ │ └── [module][name]Reader.kt // Implementation of GET server request
│ ├── [module][name]RepositoryImpl.kt // Repositories (implementation of interface)
├── src/main/kotlin/com/laimburggasse/aquariumcontrol/[module]/domain/
│ ├── model/ // Folder for data definition classes
│ │ └── [module][name].kt. // Classes for data definition
│ ├── repository/ // Folder containing repository interface definitions
│ │ └── [module][name]Repository.kt. // Interface defining the repository, used by view model
├── src/main/kotlin/com/laimburggasse/aquariumcontrol/[module]/presentation/
│ ├── entry/ // First screen of feature
│ ├── [components]/ // Shared code between different screens
│ ├── [screen name]/ // Folder for each screen
│ │ └── [module][name]Screen.kt // Composable showing the complete screen
│ │ └── [module][name]Composable.kt // Composable showing elements inside the screen
│ │ └── [module][name]UiEvent.kt // Sealed interface describing the communication from the composable to the view model
│ │ └── [module][name]UiState.kt // Data class describing the content to be rendered by the composable
│ │ └── [module][name]ViewModel.kt // Class implementing the view model
│ ├── navigation/ // Folder containing class and functionality related to screen navigation
│ │ └── [module]NavigationEvent.kt // One sealed class per module containing the navigation intent of the user
│ │ └── [module]Navigator.kt // Module-specific navigation functionality
│
└── build.gradle // Plugins, setting, dependencies
The screen navigation is controlled by a NavHostController which is created in MainActivity and remembered using rememberNavController.
Each module adds its own navigation function to NavHostController.
These extension functions are stored in [module]/presentation/navigation/[module]Navigator.kt.
They encapsulate the routing logic for that specific module.
The functions receive as parameter a specific sealed class [module]NavigationEvent.
These sealed classes contain objects for each navigation event inside the module.
Implementation pattern
The navigation functions are used in the following locations:
- inside the lambda function passed to
NavHostinMainActivityfor each screen. - inside composables themselves being passed as lambda function from within
MainActivity
- The composable remain unaware of
NavHostController
- The composable remain unaware of
Routes and arguments
Source and destination of the routing is defined using strings from the object ViewNavigationRoutes where data that needs to be transferred between screens is embedded in the strings.
Example workflow for adding API communication
Implementation
Create the model: RefillState as enum
Add a reader class RefillStateReader which uses the injected interface (Retrofit)
Update the RefillStateApiService: Add a function to load the state via Retrofit
Add the label for the JSON property in WebApiParams
Add the URL for the end point in WebApiUrls
Add a wrapper RefillStateResponse for the model to allow Retrofit parsing the JSON
Update the repository interface and the repository interface implementation:
- Add a flow that delivers a
DataFetchResultof theRefillState - Add a function that allows the view model to trigger a refresh
Add a wrapper class RefillStateUiState
Update the view model
- Add a flow that delivers a
RefillStateUiStatebased on theDataFetchResultfrom repository - Handle the new UI event for refreshing
Add composable RefillStateComposable for displaying the RefillState
Update RefillUiEvent with an object for the refresh
Update RefillScreen:
- additional parameter (
RefillStateUiState) in composable function and in previews - Added
PullToRefreshBox - Added parsing of
RefillUiStateand display of either
RefillStateComposablein case of success- Text-based error message in case of error
CircularProgressIndicatorin case of loading
Inject RefillUiState into RefillScreen in the MainActivity and RefillScreenTest as well as in previews of RefillScreen
(Optional) Check if RefillDataModule needs update (not required in this case)
Testing
Update existing screenshot testing (compose screenshot testing and instrumented tests)
Add compose screenshot testing for new composable
Build
Sync gradle and build in Android Studio.
Run ./gradlew :shared:assemble to assemble the shared KMP module.
Test strategy
The app uses different testing approaches:
- Linting
- Unit tests
- Compose preview screenshot testing
- Screenshot tests running as instrumented tests on emulator
Linting
Run ./gradlew lint: This command shall yield no warnings.
Use the Android Studio code inspection for more detailed linting - some of the warnings cannot be resolved.
Compose preview screenshot tests
These tests run comparably fast and device-independent.
The compose preview screenshot tests cover portrait mode/landscape mode, light/dark mode (four variants).
Limitations:
- Two tests within
feedmodule can only run in landscape mode (FeedProfileScheduleScreenTest,FeedHistoryScreenTest) due to excessive heap memory utilisation. Infoscreen is not tested due toLibrariesContainer(dynamic content which cannot be mocked).
The tests are located in [module]/src/screenshotTest/kotlin/com/aquariumcontrol/[module]/.
The references are located in [module]/src/screenshotTestDebug/kotlin/com/aquariumcontrol/[module]/.
The test reports are located in build/reports/screenshotTest/preview/debug/. The test reports for the compose screenshot tests contain reference, actual and delta images.
The images are generated with ./gradlew [module]:updateDebugScreenshotTest.
The images are validated with ./gradlew [module]:validateDebugScreenshotTest.
Omitting the module will execute all tests.
Instrumented tests
The tests run comparably long and are bound to an emulator device. As of November 2025, a Pixel 5 API S is used for the instrumented test. The instrumented tests contain
- screenshot testing for the complete screens (
ScreenNameTest) - verification if the composable triggers the right event when tapping on buttons bound to navigation to another screen (
ScreenNameUITest)
Limitations:
Infoscreen is not tested due toLibrariesContainer(dynamic content which cannot be mocked and also does not allow execution of instrumented test).
The test reports are located in build/reports/androidTests/connected/debug/. The test reports for the compose screenshot tests do not contain any images.
The tests are executed with ./gradlew [module]:connectedDebugAndroidTest.
Omitting the module will execute all tests.
A switch called recordMode inside each test class for screenshot testing determines if the image is generated (true) or if the image is validated (false). The images are generated on the device (/data/data/com.laimburggasse.aquariumcontrol.[module].test/files/screenshots_output/) and need to be downloaded to the repository.
Unit tests
As of April 2026, unit tests are developed for:
- all view models
- all repositories
The unit test cases for the view models make extensive use of the MockK library.
The unit test cases for the repositories use mock implementations of the Retrofit interface.
All unit test cases are executed using the command: ./gradlew :[module]:testDebugUnitTest
The code coverage report can be created using: ./gradlew createDebugUnitTestCoverageReport
The gradle task removes the injected code from the test coverage report: ./gradlew jacocoTestReport
The report is stored in [module]/build/reports/jacoco/jacocoTestReport/html
Release procedure
- Update
versionCodeandversionNameinbuild.gradle.ktsofappmodule. - Create a release branch (
release/[version]), checkout, commit and push. - Change to release build variant.
- Build
- Generate a signed App bundle
- Upload to play store