Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@file:OptIn(ExperimentalTestApi::class)

package com.softartdev.notedelight.ui

import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import com.softartdev.notedelight.MainActivity
import com.softartdev.notedelight.di.biometricTestModule
import com.softartdev.notedelight.interactor.TestBiometricInteractor
import com.softartdev.notedelight.reflect
import com.softartdev.notedelight.ui.cases.BiometricSettingsTestCase
import leakcanary.DetectLeaksAfterTestSuccess
import leakcanary.TestDescriptionHolder
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.core.context.loadKoinModules
import org.koin.mp.KoinPlatformTools

@FlakyTest
@RunWith(AndroidJUnit4::class)
class BiometricSettingsTest {

private val testBiometricInteractor: TestBiometricInteractor
get() = KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class)

private val composeTestRule = customAndroidComposeRule<MainActivity>(
beforeActivityLaunched = {
loadKoinModules(biometricTestModule)
testBiometricInteractor.reset(canAuthenticateResult = true)
}
)

@get:Rule
val rules: RuleChain = RuleChain.outerRule(TestDescriptionHolder)
.around(DetectLeaksAfterTestSuccess())
.around(composeTestRule)

private val composeUiTest: ComposeUiTest = reflect(composeTestRule)

@After
fun tearDown() = testBiometricInteractor.reset()

@Test
fun biometricSettingsTest() = BiometricSettingsTestCase(
composeUiTest = composeUiTest,
closeSoftKeyboard = Espresso::closeSoftKeyboard,
).invoke()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
@file:OptIn(ExperimentalTestApi::class)

package com.softartdev.notedelight.ui

import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import com.softartdev.notedelight.DbTestEncryptor
import com.softartdev.notedelight.MainActivity
import com.softartdev.notedelight.di.biometricTestModule
import com.softartdev.notedelight.interactor.TestBiometricInteractor
import com.softartdev.notedelight.reflect
import com.softartdev.notedelight.ui.cases.BiometricSignInTestCase
import leakcanary.DetectLeaksAfterTestSuccess
import leakcanary.TestDescriptionHolder
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.koin.core.context.loadKoinModules
import org.koin.mp.KoinPlatformTools

@FlakyTest
@RunWith(AndroidJUnit4::class)
class BiometricSignInTest {

private val testBiometricInteractor: TestBiometricInteractor
get() = KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class)

private val composeTestRule = customAndroidComposeRule<MainActivity>(
beforeActivityLaunched = {
loadKoinModules(biometricTestModule)
testBiometricInteractor.reset(
canAuthenticateResult = true,
storedPassword = DbTestEncryptor.PASSWORD,
)
DbTestEncryptor()
}
)

@get:Rule
val rules: RuleChain = RuleChain.outerRule(TestDescriptionHolder)
.around(DetectLeaksAfterTestSuccess())
.around(composeTestRule)

private val composeUiTest: ComposeUiTest = reflect(composeTestRule)

@After
fun tearDown() = testBiometricInteractor.reset()

@Test
fun biometricSignInTest() = BiometricSignInTestCase(composeUiTest = composeUiTest).invoke()
}
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 All @@ -26,10 +27,10 @@ import com.softartdev.notedelight.presentation.settings.SettingsCategoriesAction
import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel
import com.softartdev.notedelight.presentation.settings.SettingsViewModel
import com.softartdev.notedelight.repository.SafeRepo
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
import com.softartdev.notedelight.usecase.note.CreateNoteUseCase
import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase
import com.softartdev.notedelight.usecase.note.SaveNoteUseCase
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase
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
Loading
Loading