Skip to content

Commit e86e45d

Browse files
committed
feat: NoiseGuard v1.1.0
1 parent 73fa9e4 commit e86e45d

12 files changed

Lines changed: 448 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches: [ main, develop ]
66
pull_request:
7-
branches: [ main ]
7+
branches: [ main, develop ]
88

99
jobs:
1010
test:
@@ -83,4 +83,53 @@ jobs:
8383
with:
8484
name: NoiseGuard-debug
8585
path: app/build/outputs/apk/debug/app-debug.apk
86+
retention-days: 7
87+
88+
instrumented-tests:
89+
name: Instrumented Tests
90+
runs-on: ubuntu-latest
91+
needs: test
92+
93+
steps:
94+
- name: Checkout
95+
uses: actions/checkout@v4
96+
97+
- name: Set up JDK 17
98+
uses: actions/setup-java@v4
99+
with:
100+
java-version: '17'
101+
distribution: 'temurin'
102+
103+
- name: Cache Gradle
104+
uses: actions/cache@v4
105+
with:
106+
path: |
107+
~/.gradle/caches
108+
~/.gradle/wrapper
109+
key: gradle-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
110+
restore-keys: gradle-
111+
112+
- name: Make gradlew executable
113+
run: chmod +x gradlew
114+
115+
- name: Enable KVM
116+
run: |
117+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
118+
sudo udevadm control --reload-rules
119+
sudo udevadm trigger --name-match=kvm
120+
121+
- name: Run instrumented tests
122+
uses: reactivecircus/android-emulator-runner@v2
123+
with:
124+
api-level: 34
125+
arch: x86_64
126+
profile: pixel_6
127+
script: ./gradlew connectedDebugAndroidTest
128+
129+
- name: Upload instrumented test report
130+
uses: actions/upload-artifact@v4
131+
if: always()
132+
with:
133+
name: instrumented-test-report
134+
path: app/build/reports/androidTests/connected/
86135
retention-days: 7

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
55

66
## [Unreleased]
77

8+
## [1.1.0] - 2026-05-25
9+
10+
### Added
11+
- Hilt 2.56.2 DI: `@Singleton` repository, `@HiltViewModel` on all ViewModels, `AppModule`
12+
- 13 instrumented tests: `NoiseLevelDaoTest` (9) + `NavigationTest` (4)
13+
- GitHub Actions CI/CD with green badge — unit tests on every push
14+
- `HiltTestRunner`, `TestAppModule` (in-memory Room), `TestUtils` for test infrastructure
15+
16+
### Fixed
17+
- Multiple-ViewModel buffer bug: three independent `NoiseRepositoryImpl` instances
18+
caused clearing history while monitoring to silently fail
19+
- `ActivityInvoker` ClassNotFoundException on instrumented test teardown
20+
21+
## [1.0.2] - 2026-05-24
22+
23+
### Fixed
24+
- Permission flow: app now requests microphone and notification permissions
25+
on first launch via system dialog (previously showed blocked screen immediately)
26+
- Microphone unavailable toast appeared incorrectly when tapping STOP MONITORING
27+
- Version number in Settings was hardcoded as 1.0.0 (now uses BuildConfig.VERSION_NAME)
28+
- Notifications toggle showed ON even when Android notification permission was OFF
29+
30+
## [1.0.1] - 2026-05-23
31+
32+
### Fixed
33+
- License updated from CC BY-NC to BUSL-1.1
34+
- README: Hilt added to tech stack, Accompanist version corrected, Android Studio
35+
version updated to Meerkat (2025.1.1)
36+
- ARCHITECTURE.md: removed incorrect "No Hilt" section, replaced with actual DI setup
37+
- ADRs renumbered (clean sequence), ADR-004 documents Hilt decision
38+
- Calibration offset corrected to 80 dB throughout docs
39+
840
## [1.0.0] - 2026-05-19
941

1042
### Core

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@ It's a portfolio app built to demonstrate modern Android development - Compose,
5858

5959
---
6060

61+
## Testing
62+
63+
82 tests total — 69 unit and 13 instrumented.
64+
65+
Unit tests run on the JVM, no Android runtime needed. They cover noise
66+
classification, dB calculation, batch buffer logic, period filtering,
67+
and settings validation.
68+
69+
Instrumented tests run on a physical device (the emulator mic returns
70+
~20 dB regardless of environment, which makes audio tests meaningless):
71+
- `NoiseLevelDaoTest` — 9 tests against an in-memory Room database
72+
- `NavigationTest` — 4 tests verifying the bottom nav flow
73+
74+
Infrastructure: `HiltTestRunner` swaps the app component for a test
75+
component; `TestAppModule` provides the in-memory DB; `TestUtils` keeps
76+
timestamps deterministic.
77+
78+
---
79+
6180
## Tech Stack
6281

6382
| Library | Version | Purpose |

app/build.gradle.kts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ android {
1414
applicationId = "com.marcodomingues.noiseguard"
1515
minSdk = 24
1616
targetSdk = 36
17-
versionCode = 2
18-
versionName = "1.0.2"
19-
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17+
versionCode = 3
18+
versionName = "1.1.0"
19+
testInstrumentationRunner = "com.marcodomingues.noiseguard.HiltTestRunner"
2020
}
2121

2222
buildTypes {
@@ -98,10 +98,17 @@ dependencies {
9898
testImplementation(libs.mockk)
9999

100100
// Instrumented tests
101+
androidTestImplementation(libs.androidx.test.runner)
102+
androidTestImplementation(libs.androidx.test.rules)
101103
androidTestImplementation(libs.androidx.junit)
102104
androidTestImplementation(libs.androidx.espresso.core)
103105
androidTestImplementation(platform(libs.androidx.compose.bom))
104106
androidTestImplementation(libs.androidx.ui.test.junit4)
107+
androidTestImplementation(libs.androidx.room.testing)
108+
androidTestImplementation(libs.hilt.android.testing)
109+
androidTestImplementation(libs.kotlinx.coroutines.test)
110+
androidTestImplementation(libs.androidx.test.core)
111+
kspAndroidTest(libs.hilt.compiler)
105112

106113
// Debug
107114
debugImplementation(libs.androidx.ui.tooling)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.marcodomingues.noiseguard
2+
3+
import android.app.Application
4+
import android.content.Context
5+
import androidx.test.runner.AndroidJUnitRunner
6+
import dagger.hilt.android.testing.HiltTestApplication
7+
8+
class HiltTestRunner : AndroidJUnitRunner() {
9+
override fun newApplication(
10+
cl: ClassLoader?,
11+
className: String?,
12+
context: Context?
13+
): Application {
14+
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
15+
}
16+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.marcodomingues.noiseguard.data.local
2+
3+
import androidx.test.core.app.ApplicationProvider
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import androidx.test.filters.SmallTest
6+
import com.marcodomingues.noiseguard.utils.BASE_TIMESTAMP
7+
import com.marcodomingues.noiseguard.utils.createInMemoryDatabase
8+
import com.marcodomingues.noiseguard.utils.createTestReading
9+
import com.marcodomingues.noiseguard.utils.hoursAgo
10+
import kotlinx.coroutines.flow.first
11+
import kotlinx.coroutines.test.runTest
12+
import org.junit.After
13+
import org.junit.Assert.assertEquals
14+
import org.junit.Assert.assertNull
15+
import org.junit.Before
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
19+
@RunWith(AndroidJUnit4::class)
20+
@SmallTest
21+
class NoiseLevelDaoTest {
22+
23+
private lateinit var db: NoiseGuardDatabase
24+
private lateinit var dao: NoiseLevelDao
25+
26+
@Before
27+
fun setup() {
28+
db = createInMemoryDatabase(ApplicationProvider.getApplicationContext())
29+
dao = db.noiseLevelDao()
30+
}
31+
32+
@After
33+
fun teardown() {
34+
db.close()
35+
}
36+
37+
@Test
38+
fun insertAll_and_getReadingsSince_returnsInsertedReadings() = runTest {
39+
dao.insertAll(listOf(
40+
createTestReading(60.0, hoursAgo(2)),
41+
createTestReading(70.0, hoursAgo(1)),
42+
createTestReading(80.0, BASE_TIMESTAMP)
43+
))
44+
45+
val result = dao.getReadingsSince(hoursAgo(3)).first()
46+
47+
assertEquals(3, result.size)
48+
// Query returns DESC order: most recent first
49+
assertEquals(80.0, result[0].decibels, 0.01)
50+
assertEquals(70.0, result[1].decibels, 0.01)
51+
assertEquals(60.0, result[2].decibels, 0.01)
52+
}
53+
54+
@Test
55+
fun getReadingsSince_filtersOldReadings() = runTest {
56+
dao.insertAll(listOf(
57+
createTestReading(55.0, hoursAgo(2)), // outside window
58+
createTestReading(75.0, hoursAgo(0)) // inside window (= BASE_TIMESTAMP)
59+
))
60+
61+
val result = dao.getReadingsSince(hoursAgo(1)).first()
62+
63+
assertEquals(1, result.size)
64+
assertEquals(75.0, result[0].decibels, 0.01)
65+
}
66+
67+
@Test
68+
fun getAverageSince_calculatesCorrectly() = runTest {
69+
dao.insertAll(listOf(
70+
createTestReading(40.0, hoursAgo(0)),
71+
createTestReading(60.0, hoursAgo(0)),
72+
createTestReading(80.0, hoursAgo(0))
73+
))
74+
75+
val avg = dao.getAverageSince(hoursAgo(1))
76+
77+
assertEquals(60.0, avg!!, 0.01)
78+
}
79+
80+
@Test
81+
fun getPeakSince_returnsMaximum() = runTest {
82+
dao.insertAll(listOf(
83+
createTestReading(35.0, hoursAgo(0)),
84+
createTestReading(78.0, hoursAgo(0)),
85+
createTestReading(52.0, hoursAgo(0))
86+
))
87+
88+
val peak = dao.getPeakSince(hoursAgo(1))
89+
90+
assertEquals(78.0, peak!!, 0.01)
91+
}
92+
93+
@Test
94+
fun deleteOlderThan_removesOnlyOldEntries() = runTest {
95+
dao.insertAll(listOf(
96+
createTestReading(60.0, hoursAgo(3)),
97+
createTestReading(65.0, hoursAgo(4)),
98+
createTestReading(70.0, BASE_TIMESTAMP),
99+
createTestReading(75.0, BASE_TIMESTAMP - 1_800_000L) // 30 min ago
100+
))
101+
102+
val deleted = dao.deleteOlderThan(hoursAgo(1))
103+
104+
assertEquals(2, deleted)
105+
val remaining = dao.getReadingsSince(0L).first()
106+
assertEquals(2, remaining.size)
107+
}
108+
109+
@Test
110+
fun deleteAll_clearsDatabase() = runTest {
111+
dao.insertAll(listOf(
112+
createTestReading(55.0, hoursAgo(1)),
113+
createTestReading(60.0, hoursAgo(2)),
114+
createTestReading(65.0, hoursAgo(3)),
115+
createTestReading(70.0, hoursAgo(4)),
116+
createTestReading(75.0, hoursAgo(5))
117+
))
118+
119+
dao.deleteAll()
120+
121+
val remaining = dao.getReadingsSince(0L).first()
122+
assertEquals(0, remaining.size)
123+
}
124+
125+
@Test
126+
fun emptyDatabase_returnsNullForAverage() = runTest {
127+
val avg = dao.getAverageSince(0L)
128+
assertNull(avg)
129+
}
130+
131+
@Test
132+
fun emptyDatabase_returnsNullForPeak() = runTest {
133+
val peak = dao.getPeakSince(0L)
134+
assertNull(peak)
135+
}
136+
137+
@Test
138+
fun flowEmitsNewValue_whenDataChanges() = runTest {
139+
val flow = dao.getReadingsSince(0L)
140+
141+
val before = flow.first()
142+
assertEquals(0, before.size)
143+
144+
dao.insertAll(listOf(createTestReading(65.0, BASE_TIMESTAMP)))
145+
146+
val after = flow.first()
147+
assertEquals(1, after.size)
148+
assertEquals(65.0, after[0].decibels, 0.01)
149+
}
150+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.marcodomingues.noiseguard.di
2+
3+
import android.content.Context
4+
import androidx.room.Room
5+
import com.marcodomingues.noiseguard.data.audio.AudioAnalyzer
6+
import com.marcodomingues.noiseguard.data.local.NoiseLevelDao
7+
import com.marcodomingues.noiseguard.data.local.NoiseGuardDatabase
8+
import com.marcodomingues.noiseguard.data.notification.NotificationHelper
9+
import com.marcodomingues.noiseguard.data.preferences.UserPreferences
10+
import com.marcodomingues.noiseguard.data.repository.NoiseRepositoryImpl
11+
import com.marcodomingues.noiseguard.domain.repository.NoiseRepository
12+
import dagger.Module
13+
import dagger.Provides
14+
import dagger.hilt.android.qualifiers.ApplicationContext
15+
import dagger.hilt.components.SingletonComponent
16+
import dagger.hilt.testing.TestInstallIn
17+
import javax.inject.Singleton
18+
19+
@Module
20+
@TestInstallIn(
21+
components = [SingletonComponent::class],
22+
replaces = [AppModule::class]
23+
)
24+
object TestAppModule {
25+
26+
@Provides
27+
@Singleton
28+
fun provideInMemoryDatabase(@ApplicationContext context: Context): NoiseGuardDatabase =
29+
Room.inMemoryDatabaseBuilder(context, NoiseGuardDatabase::class.java)
30+
.allowMainThreadQueries()
31+
.build()
32+
33+
@Provides
34+
@Singleton
35+
fun provideNoiseLevelDao(database: NoiseGuardDatabase): NoiseLevelDao =
36+
database.noiseLevelDao()
37+
38+
@Provides
39+
@Singleton
40+
fun provideNoiseRepository(dao: NoiseLevelDao): NoiseRepository =
41+
NoiseRepositoryImpl(dao)
42+
43+
@Provides
44+
@Singleton
45+
fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences =
46+
UserPreferences(context)
47+
48+
@Provides
49+
@Singleton
50+
fun provideNotificationHelper(@ApplicationContext context: Context): NotificationHelper =
51+
NotificationHelper(context)
52+
53+
@Provides
54+
fun provideAudioAnalyzer(): AudioAnalyzer = AudioAnalyzer()
55+
}

0 commit comments

Comments
 (0)