Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ app/android/note_room_key_store.jks

### iOS
# Generated files
/iosApp/iosApp.xcworkspace/xcuserdata/
/iosApp/fastlane/report.xml
/app/iosApp/iosApp.xcworkspace/xcuserdata/
/app/iosApp/fastlane/report.xml

# Built application files
/iosApp/iosApp.app.dSYM.zip
/iosApp/iosApp.ipa
/app/iosApp/iosApp.app.dSYM.zip
/app/iosApp/iosApp.ipa

# Code signing files
/app/iosApp/fastlane/28F5CB4337.json
Expand Down
56 changes: 55 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ NoteDelight is a **Kotlin Multiplatform** note-taking application with database

### Supported Platforms

- ✅ Android (minSdk 24)
- ✅ Android (minSdk 23)
- ✅ iOS (14.0+)
- ✅ Desktop (Windows, macOS, Linux)
- ✅ Web (WebAssembly, experimental)
Expand Down Expand Up @@ -89,6 +89,60 @@ kotlin.code.style=official
- One blank line between functions
- Two blank lines between top-level declarations
- No blank lines at start/end of blocks
- Inside an `expect`/`interface` body with several method signatures, separate them with blank lines so the
declaration list reads as members rather than a wall of text:
```kotlin
expect class BiometricInteractor {

suspend fun canAuthenticate(): Boolean

fun hasStoredPassword(): Boolean
// ...
}
```

#### Call Sites & Lambdas
- **Use named arguments** when calling a function with three or more parameters, or whenever the call site
would otherwise need a same-typed positional argument list. This is especially important for
cross-platform interactors and view-model actions:
```kotlin
biometricInteractor.encryptAndStorePassword(
password = password,
title = title,
subtitle = subtitle,
negativeButton = negativeButton,
)
```
- **Annotate non-trivial local types** so the reader does not have to follow inference through several
generics or platform calls (`val res: DecryptedPasswordResult = ...`, `val plain: ByteArray = ...`).
- **Order `when` branches by the success path first**, error/`else` branches afterwards — this matches the
way ViewModels read top-to-bottom in the project. Prefer `when (result) { is Success -> ...; else -> ... }`
over an inverted `if (!success) ... else ...` ladder.
- **Collapse trivial `viewModelScope.launch` blocks to a single line** when their body is one statement
(e.g. `private fun cancel() = viewModelScope.launch { router.popBackStack() }`).
- **Compose state edits**: prefer a single `mutableStateFlow.update { it.copy(...) }` that sets every field
affected by an event over multiple chained `update` calls. It keeps the resulting state atomic and
makes the visible transition obvious.

#### Composables, strings, and event arguments
- **Do not pipe localized strings through actions or screen wrappers as empty placeholders.** If a screen
needs a `stringResource` to dispatch an action, read it directly at the call site:
```kotlin
// ❌ Avoid
onClick = { onAction(SignInAction.OnBiometricClick("", "", "")) }
// ...wrapper that overwrites the empty strings before forwarding to the ViewModel.

// ✅ Prefer
val title = stringResource(Res.string.biometric_prompt_title)
val subtitle = stringResource(Res.string.biometric_prompt_subtitle)
val negative = stringResource(Res.string.biometric_prompt_negative_button)
onClick = { onAction(SignInAction.OnBiometricClick(title, subtitle, negative)) }
```
When the strings are needed inside a stateless `…Body` composable that also has its own preview, expose
them as defaulted parameters (`title: String = stringResource(Res.string.…)`) instead of forwarding the
action with empty strings and then re-resolving them in the stateful wrapper.
- **Prefer method references for forwarding callbacks** (`onAction = signInViewModel::onAction`) when the
wrapper performs no transformation.

### Code Organization

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Supported platforms:
| database | ✅ | ✅ | ✅ | ✅ |
| encryption | ✅ | ✅ | ✅ | ✅ |
| backup | ✅ | ✅ | ✅ | ✅ |
| biometric | ✅ | ✅ | | |

Interested in contributing new features or fixes? Check out [CONTRIBUTING.md](/CONTRIBUTING.md).

Expand Down
2 changes: 2 additions & 0 deletions app/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.USE_BIOMETRIC" />

<application
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
Expand Down
Binary file not shown.

This file was deleted.

2 changes: 2 additions & 0 deletions app/iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSFaceIDUsageDescription</key>
<string>Used to unlock your encrypted notes.</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
Expand Down
2 changes: 2 additions & 0 deletions core/presentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ All ViewModels are **100% shared** across platforms with no platform-specific co

### Core Dependencies
- `core:domain` - Domain models and use cases
- `feature:biometric:domain` - BiometricInteractor (used by SignInViewModel and SettingsViewModel)
- `feature:backup:domain` - Backup use cases
- `androidx-lifecycle-viewmodel` - ViewModel base class (multiplatform)
- `kotlinx-serialization-json` - Serialization support
- `kotlinx-coroutines` - Asynchronous programming
Expand Down
1 change: 1 addition & 0 deletions core/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ kotlin {
commonMain.dependencies {
implementation(projects.core.domain)
implementation(projects.feature.backup.domain)
implementation(projects.feature.biometric.domain)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.PrintLogWriter
import com.softartdev.notedelight.db.NoteDAO
import com.softartdev.notedelight.interactor.AdaptiveInteractor
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.LocaleInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.model.SettingsCategory
Expand Down Expand Up @@ -66,6 +67,7 @@ class AdaptiveInteractorTest {
private val mockDeleteNoteUseCase = Mockito.mock(DeleteNoteUseCase::class.java)
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
private val checkSqlCipherVersionUseCase = CheckSqlCipherVersionUseCase(mockSafeRepo)
private val revealFileListUseCase = RevealFileListUseCase()
Expand Down Expand Up @@ -118,6 +120,7 @@ class AdaptiveInteractorTest {
revealFileListUseCase = revealFileListUseCase,
localeInteractor = mockLocaleInteractor,
adaptiveInteractor = adaptiveInteractor,
biometricInteractor = mockBiometricInteractor,
coroutineDispatchers = coroutineDispatchers,
)
Mockito.`when`(mockNoteDAO.pagingDataFlow).thenReturn(flowOf(PagingData.empty()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import app.cash.turbine.test
import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.interactor.AdaptiveInteractor
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.LocaleInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.interactor.SnackbarMessage
Expand All @@ -21,8 +22,10 @@ import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
import com.softartdev.notedelight.usecase.settings.RevealFileListUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito
Expand All @@ -44,6 +47,7 @@ class SettingsViewModelTest {
private val mockRouter = Mockito.mock(Router::class.java)
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java)
private val adaptiveInteractor = AdaptiveInteractor()
private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler)
Expand All @@ -58,12 +62,23 @@ class SettingsViewModelTest {
revealFileListUseCase = RevealFileListUseCase(),
localeInteractor = mockLocaleInteractor,
adaptiveInteractor = adaptiveInteractor,
biometricInteractor = mockBiometricInteractor,
coroutineDispatchers = coroutineDispatchers,
)

@Before
fun stubBiometricDefaults() {
// Mockito returns null for unstubbed suspend methods; unboxing the null Boolean inside
// updateSwitches() would NPE and route to ErrorDialog, breaking unrelated tests.
runBlocking {
Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(false)
Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false)
}
}

@After
fun tearDown() = runTest {
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase)
Mockito.reset(mockSafeRepo, mockSnackbarInteractor, mockRouter, mockAppVersionUseCase, mockBiometricInteractor)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import app.cash.turbine.test
import co.touchlab.kermit.Logger
import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.PrintLogWriter
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.presentation.MainDispatcherRule
import com.softartdev.notedelight.presentation.settings.security.FieldLabel
import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
Expand All @@ -34,6 +36,7 @@ class ChangeViewModelTest {

private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java)
private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val mockRouter = Mockito.mock(Router::class.java)
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val coroutineDispatchers = CoroutineDispatchersStub(
Expand All @@ -42,18 +45,22 @@ class ChangeViewModelTest {
private val viewModel = ChangeViewModel(
checkPasswordUseCase = mockCheckPasswordUseCase,
changePasswordUseCase = mockChangePasswordUseCase,
biometricInteractor = mockBiometricInteractor,
snackbarInteractor = mockSnackbarInteractor,
router = mockRouter,
coroutineDispatchers = coroutineDispatchers
)

@Before
fun setUp() = Logger.setLogWriters(PrintLogWriter())
fun setUp() {
Logger.setLogWriters(PrintLogWriter())
runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) }
}

@After
fun tearDown() {
Logger.setLogWriters()
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter)
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import app.cash.turbine.test
import co.touchlab.kermit.Logger
import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.PrintLogWriter
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.presentation.MainDispatcherRule
import com.softartdev.notedelight.presentation.settings.security.FieldLabel
import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
Expand All @@ -32,25 +34,30 @@ class ConfirmViewModelTest {
val mainDispatcherRule = MainDispatcherRule()

private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val mockRouter = Mockito.mock(Router::class.java)
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val coroutineDispatchers = CoroutineDispatchersStub(
scheduler = mainDispatcherRule.testDispatcher.scheduler
)
private val viewModel = ConfirmViewModel(
changePasswordUseCase = mockChangePasswordUseCase,
biometricInteractor = mockBiometricInteractor,
snackbarInteractor = mockSnackbarInteractor,
router = mockRouter,
coroutineDispatchers = coroutineDispatchers
)

@Before
fun setUp() = Logger.setLogWriters(PrintLogWriter())
fun setUp() {
Logger.setLogWriters(PrintLogWriter())
runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) }
}

@After
fun tearDown() {
Logger.setLogWriters()
Mockito.reset(mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter)
Mockito.reset(mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import app.cash.turbine.test
import co.touchlab.kermit.Logger
import com.softartdev.notedelight.CoroutineDispatchersStub
import com.softartdev.notedelight.PrintLogWriter
import com.softartdev.notedelight.interactor.BiometricInteractor
import com.softartdev.notedelight.interactor.SnackbarInteractor
import com.softartdev.notedelight.navigation.Router
import com.softartdev.notedelight.presentation.MainDispatcherRule
import com.softartdev.notedelight.presentation.settings.security.FieldLabel
import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase
import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
Expand All @@ -34,6 +36,7 @@ class EnterViewModelTest {

private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java)
private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
private val mockBiometricInteractor = Mockito.mock(BiometricInteractor::class.java)
private val mockRouter = Mockito.mock(Router::class.java)
private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java)
private val coroutineDispatchers = CoroutineDispatchersStub(
Expand All @@ -42,18 +45,22 @@ class EnterViewModelTest {
private val viewModel = EnterViewModel(
checkPasswordUseCase = mockCheckPasswordUseCase,
changePasswordUseCase = mockChangePasswordUseCase,
biometricInteractor = mockBiometricInteractor,
snackbarInteractor = mockSnackbarInteractor,
router = mockRouter,
coroutineDispatchers = coroutineDispatchers
)

@Before
fun setUp() = Logger.setLogWriters(PrintLogWriter())
fun setUp() {
Logger.setLogWriters(PrintLogWriter())
runBlocking { Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(false) }
}

@After
fun tearDown() {
Logger.setLogWriters()
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter)
Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockSnackbarInteractor, mockRouter, mockBiometricInteractor)
}

@Test
Expand Down
Loading
Loading