Skip to content

Commit 9ff71fa

Browse files
committed
refactor: improve biometric authentication and unify sign-in state
- Refactor `BiometricInteractor` on Android to use `Application.ActivityLifecycleCallbacks` for automatic activity tracking, eliminating the manual `BiometricActivityHolder`. - Consolidate `SignInViewModel` UI state into a single `SignInResult` data class, replacing separate flows for result and visibility with atomic state updates. - Update `SignInScreen` and related tests to utilize the unified state model. - Downgrade `androidx.biometric` to `1.1.0` for stability and set `minSdk` to 23 in `CONTRIBUTING.md`. - Add `USE_FINGERPRINT` permission to `AndroidManifest.xml` and remove manual `configChanges` handling for `MainActivity`. - Expand `CONTRIBUTING.md` with detailed coding standards for named arguments, state management, and Composable patterns. - Switch iOS project configuration to use automatic code signing. - Ensure `BiometricInteractor` operations are explicitly dispatched to the main thread when interacting with UI components.
1 parent babf494 commit 9ff71fa

14 files changed

Lines changed: 241 additions & 163 deletions

File tree

CONTRIBUTING.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ NoteDelight is a **Kotlin Multiplatform** note-taking application with database
2727

2828
### Supported Platforms
2929

30-
- ✅ Android (minSdk 24)
30+
- ✅ Android (minSdk 23)
3131
- ✅ iOS (14.0+)
3232
- ✅ Desktop (Windows, macOS, Linux)
3333
- ✅ Web (WebAssembly, experimental)
@@ -89,6 +89,60 @@ kotlin.code.style=official
8989
- One blank line between functions
9090
- Two blank lines between top-level declarations
9191
- No blank lines at start/end of blocks
92+
- Inside an `expect`/`interface` body with several method signatures, separate them with blank lines so the
93+
declaration list reads as members rather than a wall of text:
94+
```kotlin
95+
expect class BiometricInteractor {
96+
97+
suspend fun canAuthenticate(): Boolean
98+
99+
fun hasStoredPassword(): Boolean
100+
// ...
101+
}
102+
```
103+
104+
#### Call Sites & Lambdas
105+
- **Use named arguments** when calling a function with three or more parameters, or whenever the call site
106+
would otherwise need a same-typed positional argument list. This is especially important for
107+
cross-platform interactors and view-model actions:
108+
```kotlin
109+
biometricInteractor.encryptAndStorePassword(
110+
password = password,
111+
title = title,
112+
subtitle = subtitle,
113+
negativeButton = negativeButton,
114+
)
115+
```
116+
- **Annotate non-trivial local types** so the reader does not have to follow inference through several
117+
generics or platform calls (`val res: DecryptedPasswordResult = ...`, `val plain: ByteArray = ...`).
118+
- **Order `when` branches by the success path first**, error/`else` branches afterwards — this matches the
119+
way ViewModels read top-to-bottom in the project. Prefer `when (result) { is Success -> ...; else -> ... }`
120+
over an inverted `if (!success) ... else ...` ladder.
121+
- **Collapse trivial `viewModelScope.launch` blocks to a single line** when their body is one statement
122+
(e.g. `private fun cancel() = viewModelScope.launch { router.popBackStack() }`).
123+
- **Compose state edits**: prefer a single `mutableStateFlow.update { it.copy(...) }` that sets every field
124+
affected by an event over multiple chained `update` calls. It keeps the resulting state atomic and
125+
makes the visible transition obvious.
126+
127+
#### Composables, strings, and event arguments
128+
- **Do not pipe localized strings through actions or screen wrappers as empty placeholders.** If a screen
129+
needs a `stringResource` to dispatch an action, read it directly at the call site:
130+
```kotlin
131+
// ❌ Avoid
132+
onClick = { onAction(SignInAction.OnBiometricClick("", "", "")) }
133+
// ...wrapper that overwrites the empty strings before forwarding to the ViewModel.
134+
135+
// ✅ Prefer
136+
val title = stringResource(Res.string.biometric_prompt_title)
137+
val subtitle = stringResource(Res.string.biometric_prompt_subtitle)
138+
val negative = stringResource(Res.string.biometric_prompt_negative_button)
139+
onClick = { onAction(SignInAction.OnBiometricClick(title, subtitle, negative)) }
140+
```
141+
When the strings are needed inside a stateless `…Body` composable that also has its own preview, expose
142+
them as defaulted parameters (`title: String = stringResource(Res.string.…)`) instead of forwarding the
143+
action with empty strings and then re-resolving them in the stateful wrapper.
144+
- **Prefer method references for forwarding callbacks** (`onAction = signInViewModel::onAction`) when the
145+
wrapper performs no transformation.
92146

93147
### Code Organization
94148

app/android/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

44
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
5+
<uses-permission android:name="android.permission.USE_FINGERPRINT" android:maxSdkVersion="28" />
56

67
<application
78
android:name=".MainApplication"
@@ -11,7 +12,6 @@
1112
android:theme="@style/Theme.NoteDelight">
1213
<activity
1314
android:name=".MainActivity"
14-
android:configChanges="orientation|screenSize|keyboardHidden"
1515
android:exported="true"
1616
android:windowSoftInputMode="adjustResize">
1717
<intent-filter>

app/android/src/main/java/com/softartdev/notedelight/MainActivity.kt

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,13 @@ package com.softartdev.notedelight
33
import android.os.Bundle
44
import androidx.activity.compose.setContent
55
import androidx.appcompat.app.AppCompatActivity
6-
import com.softartdev.notedelight.interactor.BiometricActivityHolder
7-
import org.koin.android.ext.android.inject
86

97
class MainActivity : AppCompatActivity() {
108

11-
private val biometricActivityHolder: BiometricActivityHolder by inject()
12-
139
override fun onCreate(savedInstanceState: Bundle?) {
1410
super.onCreate(savedInstanceState)
15-
biometricActivityHolder.attach(this)
1611
setContent {
1712
App()
1813
}
1914
}
20-
21-
override fun onDestroy() {
22-
biometricActivityHolder.detach()
23-
super.onDestroy()
24-
}
2515
}

app/iosApp/iosApp.xcodeproj/project.pbxproj

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -460,12 +460,10 @@
460460
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
461461
CLANG_ENABLE_MODULES = YES;
462462
CODE_SIGN_IDENTITY = "Apple Development";
463-
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
464-
CODE_SIGN_STYLE = Manual;
463+
CODE_SIGN_STYLE = Automatic;
465464
CURRENT_PROJECT_VERSION = 19;
466465
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
467-
DEVELOPMENT_TEAM = "";
468-
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = H7L7R3VNZ4;
466+
DEVELOPMENT_TEAM = H7L7R3VNZ4;
469467
ENABLE_PREVIEWS = YES;
470468
GENERATE_INFOPLIST_FILE = NO;
471469
INFOPLIST_FILE = iosApp/Info.plist;
@@ -475,7 +473,7 @@
475473
"$(inherited)",
476474
"@executable_path/Frameworks",
477475
);
478-
MARKETING_VERSION = 8.5.4;
476+
MARKETING_VERSION = 8.5.4;
479477
OTHER_LDFLAGS = (
480478
"$(inherited)",
481479
"-ObjC",
@@ -487,7 +485,6 @@
487485
PRODUCT_BUNDLE_IDENTIFIER = com.softartdev.notedelight;
488486
PRODUCT_NAME = "$(TARGET_NAME)";
489487
PROVISIONING_PROFILE_SPECIFIER = "";
490-
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = NoteDelight_Development_Profile;
491488
SWIFT_OBJC_BRIDGING_HEADER = "iosApp-Bridging-Header.h";
492489
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
493490
SWIFT_VERSION = 5.0;
@@ -517,7 +514,7 @@
517514
"$(inherited)",
518515
"@executable_path/Frameworks",
519516
);
520-
MARKETING_VERSION = 8.5.4;
517+
MARKETING_VERSION = 8.5.4;
521518
OTHER_LDFLAGS = (
522519
"$(inherited)",
523520
"-ObjC",
Binary file not shown.

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
2222
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
2323
import com.softartdev.notedelight.usecase.settings.RevealFileListUseCase
2424
import kotlinx.coroutines.ExperimentalCoroutinesApi
25+
import kotlinx.coroutines.runBlocking
2526
import kotlinx.coroutines.test.runTest
2627
import org.junit.After
28+
import org.junit.Before
2729
import org.junit.Rule
2830
import org.junit.Test
2931
import org.mockito.Mockito
@@ -64,6 +66,16 @@ class SettingsViewModelTest {
6466
coroutineDispatchers = coroutineDispatchers,
6567
)
6668

69+
@Before
70+
fun stubBiometricDefaults() {
71+
// Mockito returns null for unstubbed suspend methods; unboxing the null Boolean inside
72+
// updateSwitches() would NPE and route to ErrorDialog, breaking unrelated tests.
73+
runBlocking {
74+
Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(false)
75+
}
76+
Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false)
77+
}
78+
6779
@After
6880
fun tearDown() = runTest {
6981
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor)

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,15 @@ class SignInViewModelTest {
4949
@Test
5050
fun showSignInForm() = runTest {
5151
signInViewModel.stateFlow.test {
52-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
52+
assertEquals(SignInResult(), awaitItem())
5353
cancelAndIgnoreRemainingEvents()
5454
}
5555
}
5656

5757
@Test
5858
fun onSettingsClick() = runTest {
5959
signInViewModel.stateFlow.test {
60-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
60+
assertEquals(SignInResult(), awaitItem())
6161

6262
signInViewModel.onAction(SignInAction.OnSettingsClick)
6363
Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Settings)
@@ -69,7 +69,7 @@ class SignInViewModelTest {
6969
@Test
7070
fun navMain() = runTest {
7171
signInViewModel.stateFlow.test {
72-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
72+
assertEquals(SignInResult(), awaitItem())
7373

7474
val pass = StubEditable("pass")
7575
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true)
@@ -84,10 +84,10 @@ class SignInViewModelTest {
8484
@Test
8585
fun showEmptyPassError() = runTest {
8686
signInViewModel.stateFlow.test {
87-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
87+
assertEquals(SignInResult(), awaitItem())
8888

8989
signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable("")))
90-
assertEquals(SignInResult.ShowEmptyPassError, awaitItem())
90+
assertEquals(SignInResult.State.ShowEmptyPassError, awaitItem().state)
9191

9292
cancelAndIgnoreRemainingEvents()
9393
}
@@ -96,12 +96,12 @@ class SignInViewModelTest {
9696
@Test
9797
fun showIncorrectPassError() = runTest {
9898
signInViewModel.stateFlow.test {
99-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
99+
assertEquals(SignInResult(), awaitItem())
100100

101101
val pass = StubEditable("pass")
102102
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false)
103103
signInViewModel.onAction(SignInAction.OnSignInClick(pass))
104-
assertEquals(SignInResult.ShowIncorrectPassError, awaitItem())
104+
assertEquals(SignInResult.State.ShowIncorrectPassError, awaitItem().state)
105105

106106
cancelAndIgnoreRemainingEvents()
107107
}
@@ -110,7 +110,7 @@ class SignInViewModelTest {
110110
@Test
111111
fun showError() = runTest {
112112
signInViewModel.stateFlow.test {
113-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
113+
assertEquals(SignInResult(), awaitItem())
114114

115115
val throwable = Throwable()
116116
Mockito.`when`(mockCheckPasswordUseCase(anyObject())).thenThrow(throwable)
@@ -127,10 +127,10 @@ class SignInViewModelTest {
127127
fun refreshBiometricVisibleWhenAvailable() = runTest {
128128
Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true)
129129
Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true)
130-
signInViewModel.biometricVisibleFlow.test {
131-
assertFalse(awaitItem())
130+
signInViewModel.stateFlow.test {
131+
assertFalse(awaitItem().biometricVisible)
132132
signInViewModel.onAction(SignInAction.RefreshBiometric)
133-
assertTrue(awaitItem())
133+
assertTrue(awaitItem().biometricVisible)
134134
cancelAndIgnoreRemainingEvents()
135135
}
136136
}
@@ -142,7 +142,7 @@ class SignInViewModelTest {
142142
.thenReturn(DecryptedPasswordResult.Success(pass))
143143
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true)
144144
signInViewModel.stateFlow.test {
145-
assertEquals(SignInResult.ShowSignInForm, awaitItem())
145+
assertEquals(SignInResult(), awaitItem())
146146
signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c"))
147147
Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main)
148148
cancelAndIgnoreRemainingEvents()
@@ -153,11 +153,11 @@ class SignInViewModelTest {
153153
fun biometricSignInUnavailableClearsState() = runTest {
154154
Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject()))
155155
.thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable))
156-
signInViewModel.biometricVisibleFlow.test {
157-
assertFalse(awaitItem())
156+
signInViewModel.stateFlow.test {
157+
assertFalse(awaitItem().biometricVisible)
158158
signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c"))
159159
Mockito.verify(mockBiometricInteractor).clearStoredPassword()
160160
cancelAndIgnoreRemainingEvents()
161161
}
162162
}
163-
}
163+
}

core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricActivityHolder.kt

Lines changed: 0 additions & 19 deletions
This file was deleted.

0 commit comments

Comments
 (0)