Skip to content

Commit 0520861

Browse files
committed
1.9.1 / versioncode 36
- Refactor to combine modules - Add GH Action - Add Dependabot - GitHub workflow: Update branch references from master to main - Clean up lints - Add README.md - Don't refer to resources in viewmodel - Add UI test - Add DataStore Hilt module - Dependency updates: - JDK 21 → 24 - Gradle: 9.0.0 → 9.1.0 - NDK: 28.2.13676358 → 29.0.14206865 - Compose BOM: 2025.09.00 → 2025.10.00 - Lifecycle: 2.9.3 → 2.9.4 - Navigation: 2.9.4 → 2.9.5 - Gradle Versions Plugin: 0.52.0 → 0.53.0 - Hilt: 2.57.1 → 2.57.2 - KSP: 2.2.20-2.0.3 → 2.2.20-2.0.4 - MockK: 1.14.5 → 1.14.6
1 parent 3dd3273 commit 0520861

141 files changed

Lines changed: 1537 additions & 622 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/dependabot.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "gradle"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"

.github/workflows/android.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Android CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: checkout
15+
uses: actions/checkout@v4
16+
17+
- name: set up JDK 24
18+
uses: actions/setup-java@v4
19+
with:
20+
java-version: '24'
21+
distribution: 'temurin'
22+
cache: gradle
23+
24+
- name: Grant execute permission for gradlew
25+
run: chmod +x gradlew
26+
27+
- name: Enable KVM group perms
28+
run: |
29+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
30+
sudo udevadm control --reload-rules
31+
sudo udevadm trigger --name-match=kvm
32+
33+
- name: Gradle cache
34+
uses: gradle/actions/setup-gradle@v3
35+
36+
- name: AVD cache
37+
uses: actions/cache@v4
38+
id: avd-cache
39+
with:
40+
path: |
41+
~/.android/avd/*
42+
~/.android/adb*
43+
key: avd-36
44+
45+
- name: create AVD and generate snapshot for caching
46+
if: steps.avd-cache.outputs.cache-hit != 'true'
47+
uses: reactivecircus/android-emulator-runner@v2
48+
with:
49+
api-level: 36
50+
target: google_apis
51+
arch: x86_64
52+
profile: Nexus 6
53+
force-avd-creation: false
54+
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
55+
disable-animations: false
56+
script: echo "Generated AVD snapshot for caching."
57+
58+
- name: run tests
59+
uses: reactivecircus/android-emulator-runner@v2
60+
with:
61+
api-level: 36
62+
target: google_apis
63+
arch: x86_64
64+
profile: Nexus 6
65+
force-avd-creation: false
66+
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
67+
disable-animations: true
68+
script: ./gradlew clean build connectedCheck
69+
70+
- name: Check Android ELF Alignment
71+
uses: Dermochelys/check-android-elf-alignment@v2

README.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# UTC Clock
2+
3+
A simple, offline-only Android application that displays UTC (Coordinated Universal Time) with support for phones, tablets and Android TVs.
4+
5+
## Overview
6+
7+
- This is a single-module Android application built with Kotlin that provides an immersive fullscreen UTC time display.
8+
- It features a hybrid UI approach combining traditional Android Views with Jetpack Compose components.
9+
- In the future, when [Jetpack Navigation 3](https://developer.android.com/guide/navigation/navigation-3) is out of alpha it will allow removal of Fragment use and a pure Compose architecture.
10+
11+
## Features
12+
13+
- **Universal Time Display**: Clean, easy-to-read UTC clock
14+
- **Multi-Platform Support**:
15+
- Android TV (leanback) optimized interface
16+
- Mobile devices with touchscreen support
17+
- **Immersive Experience**: Edge-to-edge fullscreen display with system UI hiding
18+
- **Modern Architecture**: Built with latest Android development best practices
19+
- **Hybrid UI**: Combines ViewBinding and Jetpack Compose for optimal flexibility
20+
21+
## Requirements
22+
23+
- **Minimum SDK**: Android 5.0 (API 21)
24+
- **Target SDK**: Android 15+ (API 36)
25+
- **Java/Kotlin**: JVM toolchain version 24
26+
27+
## Building the Project
28+
29+
### Prerequisites
30+
31+
- Android Studio or IntelliJ IDEA with Android plugin
32+
- JDK 24
33+
- Android SDK with API level 36
34+
- NDK 29.0.14206865
35+
36+
### Build Commands
37+
38+
```bash
39+
# Build the project
40+
./gradlew build
41+
42+
# Build debug APK
43+
./gradlew assembleDebug
44+
45+
# Build release APK (with R8 minification)
46+
./gradlew assembleRelease
47+
48+
# Install debug build to connected device
49+
./gradlew installDebug
50+
```
51+
52+
## Development
53+
54+
### Architecture
55+
56+
The app uses modern Android architecture components:
57+
58+
- **Navigation**: Android Navigation Component with single Activity pattern
59+
- **Dependency Injection**: Hilt (Dagger-based DI)
60+
- **UI**: Hybrid approach with ViewBinding + Jetpack Compose
61+
- **Coroutines**: Kotlin Coroutines for asynchronous operations
62+
- **Data Layer**: Repository pattern for data management
63+
64+
### Project Structure
65+
66+
```
67+
app/src/main/java/com/dermochelys/utcclock/
68+
├── Activity.kt # Main activity
69+
├── landing/ # Entry point fragment
70+
├── view/
71+
│ ├── clock/ # Main UTC clock display
72+
│ ├── disclaimer/ # Legal disclaimer view
73+
│ ├── fontlicense/ # Font licensing dialog
74+
│ └── common/ # Shared UI utilities
75+
├── repository/ # Data layer
76+
└── di/ # Dependency injection modules
77+
```
78+
79+
### Code Quality
80+
81+
```bash
82+
# Run lint analysis
83+
./gradlew lint
84+
85+
# Lint vital checks for release
86+
./gradlew lintVitalRelease
87+
```
88+
89+
### Dependency Management
90+
91+
```bash
92+
# Check for dependency updates
93+
./gradlew dependencyUpdates
94+
```
95+
96+
Dependencies are managed via `gradle/libs.versions.toml` using Gradle version catalogs.
97+
98+
## Testing
99+
100+
### Running Tests
101+
102+
```bash
103+
# Run all tests
104+
./gradlew test
105+
106+
# Run unit tests only
107+
./gradlew testDebugUnitTest
108+
109+
# Run instrumented tests (requires emulator/device)
110+
./gradlew connectedAndroidTest
111+
```
112+
113+
### Testing Strategy
114+
115+
- **Unit Tests**: Fast, isolated tests using MockK for mocking
116+
- **Instrumentation Tests**: Integration tests with Hilt for full dependency injection testing
117+
118+
## Technical Details
119+
120+
- **Compile/Target SDK**: 36 (Android 15+)
121+
- **Compose Compiler**: 1.5.15
122+
- **Hilt Version**: 2.57.2
123+
- **Kotlin Symbol Processing (KSP)**: 2.2.20-2.0.3
124+
- **Build Features**:
125+
- R8 minification and resource shrinking enabled for release builds
126+
- Java compiler warnings treated as errors
127+
- Lint warnings treated as errors (with specific exceptions)
128+
129+
## License
130+
131+
- [GPL 3.0](gpl-3.0.md)

app/build.gradle.kts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ android {
1818
applicationId = "com.dermochelys.utcclock"
1919
minSdk = 21
2020
targetSdk = 36
21-
versionCode = 32
22-
versionName = "1.9.0+32"
21+
22+
// For this app and its simple needs, no fancier version code scheme is needed.
23+
// Just monotonically increase and follow SemVer for the versionName.
24+
versionCode = 36
25+
versionName = "1.9.1+36"
2326

2427
// 2025-07-15 Specify NDK version as workaround: https://issuetracker.google.com/issues/237187538
25-
ndkVersion = "28.2.13676358"
28+
ndkVersion = "29.0.14206865"
2629

2730
testInstrumentationRunner = "com.dermochelys.utcclock.HiltTestRunner"
2831
}
@@ -68,12 +71,6 @@ android {
6871
kotlinCompilerExtensionVersion = "1.5.15"
6972
}
7073

71-
externalNativeBuild {
72-
cmake {
73-
version = "4.0.2"
74-
}
75-
}
76-
7774
testOptions {
7875
unitTests.isReturnDefaultValues = true
7976
}
@@ -101,9 +98,8 @@ android {
10198
}
10299

103100
dependencies {
104-
implementation(project(":shared"))
105-
106101
implementation(platform(libs.androidx.compose.bom))
102+
androidTestImplementation(platform(libs.androidx.compose.bom))
107103

108104
implementation(libs.androidx.activity.compose)
109105
implementation(libs.androidx.appcompat)
@@ -141,6 +137,8 @@ dependencies {
141137
androidTestImplementation(libs.androidx.espresso.core)
142138
androidTestImplementation(libs.kotlinx.coroutines.test)
143139
androidTestImplementation(libs.hilt.android.testing)
140+
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
141+
debugImplementation(libs.androidx.compose.ui.test.manifest)
144142
kspAndroidTest(libs.hilt.android.compiler)
145143
}
146144

app/src/androidTest/java/com/dermochelys/utcclock/HiltTestRunner.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import android.content.Context
55
import androidx.test.runner.AndroidJUnitRunner
66
import dagger.hilt.android.testing.HiltTestApplication
77

8+
/** Used in build.gradle.kts */
9+
@Suppress("UNUSED")
810
class HiltTestRunner : AndroidJUnitRunner() {
911
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
1012
return super.newApplication(cl, HiltTestApplication::class.java.name, context)

app/src/androidTest/java/com/dermochelys/utcclock/di/TestCoroutineModule.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,24 @@ package com.dermochelys.utcclock.di
22

33
import dagger.Module
44
import dagger.Provides
5-
import dagger.hilt.components.SingletonComponent
5+
import dagger.hilt.android.components.ViewModelComponent
6+
import dagger.hilt.android.scopes.ViewModelScoped
67
import dagger.hilt.testing.TestInstallIn
78
import kotlinx.coroutines.CoroutineDispatcher
89
import kotlinx.coroutines.CoroutineScope
910
import kotlinx.coroutines.ExperimentalCoroutinesApi
1011
import kotlinx.coroutines.SupervisorJob
1112
import kotlinx.coroutines.test.TestScope
12-
import javax.inject.Singleton
1313

1414
@OptIn(ExperimentalCoroutinesApi::class)
1515
@Module
1616
@TestInstallIn(
17-
components = [SingletonComponent::class],
17+
components = [ViewModelComponent::class],
1818
replaces = [CoroutineModule::class]
1919
)
20-
object TestCoroutineModule {
20+
object TestViewModelCoroutineModule {
2121
@Provides
22-
@Singleton
22+
@ViewModelScoped
2323
fun provideCoroutineScope(coroutineDispatcher: CoroutineDispatcher): CoroutineScope {
2424
return TestScope(SupervisorJob() + coroutineDispatcher)
2525
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.dermochelys.utcclock.di
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.preferences.core.Preferences
6+
import androidx.datastore.preferences.preferencesDataStore
7+
import dagger.Module
8+
import dagger.Provides
9+
import dagger.hilt.android.qualifiers.ApplicationContext
10+
import dagger.hilt.components.SingletonComponent
11+
import dagger.hilt.testing.TestInstallIn
12+
import kotlinx.coroutines.CoroutineDispatcher
13+
import kotlinx.coroutines.SupervisorJob
14+
import kotlinx.coroutines.test.TestScope
15+
import javax.inject.Singleton
16+
17+
@Module
18+
@TestInstallIn(
19+
components = [SingletonComponent::class],
20+
replaces = [DataStoreModule::class]
21+
)
22+
object TestDataStoreModule {
23+
// Since components are recreated during tests, we need to manage the singleton on our own
24+
private var dataStoreProvider: DataStoreProvider? = null
25+
26+
@Provides
27+
@Singleton
28+
fun provideDataStore(@ApplicationContext context: Context,
29+
coroutineDispatcher: CoroutineDispatcher): DataStore<Preferences> {
30+
val provider = dataStoreProvider ?: let {
31+
DataStoreProvider(context, coroutineDispatcher).also { dataStoreProvider = it }
32+
}
33+
34+
return provider.datastore()
35+
}
36+
}
37+
38+
class DataStoreProvider(private val context: Context,
39+
coroutineDispatcher: CoroutineDispatcher) {
40+
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "disclaimer", scope = TestScope(
41+
SupervisorJob() + coroutineDispatcher))
42+
43+
fun datastore() = context.dataStore
44+
}

app/src/androidTest/java/com/dermochelys/utcclock/di/TestRepositoryModule.kt

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
package com.dermochelys.utcclock.di
22

3-
import android.content.Context
43
import androidx.datastore.core.DataStore
54
import androidx.datastore.preferences.core.Preferences
6-
import androidx.datastore.preferences.preferencesDataStore
75
import com.dermochelys.utcclock.repository.DisclaimerRepository
86
import com.dermochelys.utcclock.repository.ZonedDateRepository
97
import com.dermochelys.utcclock.repository.internal.DisclaimerRepositoryImpl
108
import com.dermochelys.utcclock.repository.internal.ZonedDateRepositoryImpl
119
import dagger.Module
1210
import dagger.Provides
13-
import dagger.hilt.android.qualifiers.ApplicationContext
1411
import dagger.hilt.components.SingletonComponent
1512
import dagger.hilt.testing.TestInstallIn
1613
import kotlinx.coroutines.CoroutineDispatcher
17-
import kotlinx.coroutines.CoroutineScope
1814
import kotlinx.coroutines.ExperimentalCoroutinesApi
1915
import javax.inject.Singleton
2016

@@ -27,23 +23,9 @@ import javax.inject.Singleton
2723
object TestRepositoryModule {
2824
@Provides
2925
@Singleton
30-
fun provideTestDataStore(@ApplicationContext context: Context,
31-
coroutineScope: CoroutineScope): DataStore<Preferences> {
32-
return object {
33-
val Context.testDataStore: DataStore<Preferences> by preferencesDataStore(
34-
name = "test_disclaimer",
35-
scope = coroutineScope,
36-
)
37-
38-
fun testDataStore(context: Context): DataStore<Preferences> = context.testDataStore
39-
}.testDataStore(context)
40-
}
41-
42-
@Provides
43-
@Singleton
44-
fun provideDisclaimerRepository(testDataStore: DataStore<Preferences>,
26+
fun provideDisclaimerRepository(dataStore: DataStore<Preferences>,
4527
coroutineDispatcher: CoroutineDispatcher): DisclaimerRepository {
46-
return DisclaimerRepositoryImpl(testDataStore, coroutineDispatcher)
28+
return DisclaimerRepositoryImpl(dataStore, coroutineDispatcher)
4729
}
4830

4931
@Provides

shared/src/androidTest/java/com/dermochelys/utcclock/DisclaimerRepositoryImplFunctionalTests.kt renamed to app/src/androidTest/java/com/dermochelys/utcclock/repository/DisclaimerRepositoryImplFunctionalTests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.dermochelys.utcclock
1+
package com.dermochelys.utcclock.repository
22

33
import android.content.Context
44
import androidx.datastore.core.DataStore

0 commit comments

Comments
 (0)