Skip to content

Commit ea23d10

Browse files
committed
add biometric support
1 parent 0d6c68d commit ea23d10

11 files changed

Lines changed: 319 additions & 67 deletions

File tree

.idea/workspace.xml

Lines changed: 10 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ dependencies {
104104
implementation 'com.google.code.gson:gson:2.10.1'
105105

106106
implementation 'androidx.core:core-splashscreen:1.0.1'
107+
implementation 'androidx.biometric:biometric:1.1.0'
107108

108109
// Testing
109110
testImplementation 'junit:junit:4.13.2'

app/src/main/java/com/opennotes/feature_node/data/repository/DataStoreRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class DataStoreRepository @Inject constructor(
2424
// New theme settings
2525
private val THEME_MODE = stringPreferencesKey("theme_mode")
2626
private val BLACK_THEME = booleanPreferencesKey("black_theme")
27+
private val BIOMETRIC_LOCK = booleanPreferencesKey("biometric_lock")
2728

2829
// Legacy settings for backward compatibility
2930
private val DARK_THEME = booleanPreferencesKey("dark_theme")
@@ -38,6 +39,7 @@ class DataStoreRepository @Inject constructor(
3839
dataStore.edit { prefs ->
3940
prefs[THEME_MODE] = settings.themeMode.name
4041
prefs[BLACK_THEME] = settings.blackTheme
42+
prefs[BIOMETRIC_LOCK] = settings.biometricLock
4143

4244
// Also update legacy fields for compatibility
4345
when (settings.themeMode) {
@@ -92,6 +94,7 @@ class DataStoreRepository @Inject constructor(
9294
Settings(
9395
themeMode = themeMode,
9496
blackTheme = prefs[BLACK_THEME] ?: defaultSettings.blackTheme,
97+
biometricLock = prefs[BIOMETRIC_LOCK] ?: defaultSettings.biometricLock,
9598
// Legacy fields for compatibility
9699
darkTheme = prefs[DARK_THEME] ?: defaultSettings.darkTheme,
97100
systemTheme = prefs[AUTOMATIC_THEME] ?: defaultSettings.systemTheme,
@@ -134,6 +137,7 @@ class DataStoreRepository @Inject constructor(
134137
return Settings(
135138
themeMode = themeMode,
136139
blackTheme = prefs[BLACK_THEME] ?: defaultSettings.blackTheme,
140+
biometricLock = prefs[BIOMETRIC_LOCK] ?: defaultSettings.biometricLock,
137141
// Legacy fields for compatibility
138142
darkTheme = prefs[DARK_THEME] ?: defaultSettings.darkTheme,
139143
systemTheme = prefs[AUTOMATIC_THEME] ?: defaultSettings.systemTheme,
Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.opennotes.feature_node.data.repository
22

33
import android.app.Application
4+
import android.content.ContentValues
45
import android.net.Uri
6+
import android.os.Build
57
import android.os.Environment
6-
import androidx.core.content.FileProvider
8+
import android.provider.MediaStore
79
import kotlinx.coroutines.Dispatchers
810
import kotlinx.coroutines.withContext
911
import java.io.BufferedReader
@@ -14,6 +16,7 @@ import java.io.InputStreamReader
1416
interface FileHandler{
1517
suspend fun readTextFromUri(uri: Uri):String
1618
suspend fun saveToFile(filename:String,content:String):Uri?
19+
suspend fun writeTextToUri(uri: Uri, content: String): Boolean
1720

1821

1922
}
@@ -29,25 +32,58 @@ class AndroidFileHandler(private val application: Application):FileHandler{
2932

3033
override suspend fun saveToFile(filename:String,content:String):Uri?=withContext(Dispatchers.IO)
3134
{
32-
val notesDir = application.getExternalFilesDir(null)
35+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
36+
val resolver = application.contentResolver
3337

38+
val values = ContentValues().apply {
39+
put(MediaStore.Downloads.DISPLAY_NAME, filename)
40+
put(MediaStore.Downloads.MIME_TYPE, "application/json")
41+
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
42+
put(MediaStore.Downloads.IS_PENDING, 1)
43+
}
3444

35-
if(notesDir==null){
36-
return@withContext null
37-
}
45+
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
46+
val itemUri = resolver.insert(collection, values) ?: return@withContext null
3847

48+
try {
49+
resolver.openOutputStream(itemUri)?.use { outputStream ->
50+
outputStream.write(content.toByteArray())
51+
} ?: return@withContext null
3952

53+
values.clear()
54+
values.put(MediaStore.Downloads.IS_PENDING, 0)
55+
resolver.update(itemUri, values, null, null)
56+
57+
return@withContext itemUri
58+
} catch (_: Exception) {
59+
resolver.delete(itemUri, null, null)
60+
return@withContext null
61+
}
62+
}
4063

41-
if(!notesDir.exists() && !notesDir.mkdirs()){
64+
@Suppress("DEPRECATION")
65+
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
66+
if (!downloadsDir.exists() && !downloadsDir.mkdirs()) {
4267
return@withContext null
4368
}
4469

45-
val file=File(notesDir,filename)
46-
file.writeText(content)
47-
return@withContext FileProvider.getUriForFile(
48-
application,
49-
"${application.packageName}.fileprovider",
50-
file
51-
)
70+
val file = File(downloadsDir, filename)
71+
return@withContext try {
72+
file.writeText(content)
73+
Uri.fromFile(file)
74+
} catch (_: Exception) {
75+
null
76+
}
77+
}
78+
79+
override suspend fun writeTextToUri(uri: Uri, content: String): Boolean = withContext(Dispatchers.IO) {
80+
return@withContext try {
81+
application.contentResolver.openOutputStream(uri)?.use { outputStream ->
82+
outputStream.write(content.toByteArray())
83+
} ?: return@withContext false
84+
true
85+
} catch (_: Exception) {
86+
false
87+
}
5288
}
5389
}

app/src/main/java/com/opennotes/feature_node/domain/use_case/ExportUseCases.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
package com.opennotes.feature_node.domain.use_case
22

3-
import android.util.Log
3+
import android.net.Uri
44
import com.opennotes.feature_node.data.repository.FileHandler
5-
import com.opennotes.feature_node.data.repository.GsonJsonHandler
65
import com.opennotes.feature_node.data.repository.JsonHandler
76

87
import com.opennotes.feature_node.domain.repository.NoteRepository
@@ -15,7 +14,7 @@ class ExportUseCases(
1514
private val fileHandler: FileHandler,
1615
private val jsonHandler: JsonHandler
1716
) {
18-
suspend operator fun invoke(): ExportResult {
17+
suspend operator fun invoke(targetUri: Uri): ExportResult {
1918
return try {
2019
val allNotes = repository.getAllNotes()
2120
.filter{it.isNotEmpty()}
@@ -26,14 +25,13 @@ class ExportUseCases(
2625
val notesJson = jsonHandler.toJson(allNotes)
2726

2827

29-
val fileUri=fileHandler.saveToFile ("notes_backup.json", notesJson)
28+
val isSaved = fileHandler.writeTextToUri(targetUri, notesJson)
3029

31-
32-
if(fileUri==null){
33-
return ExportResult.Error("Could not save the file to a URI")
30+
if(!isSaved){
31+
return ExportResult.Error("Could not save the file to the selected location")
3432
}
3533

36-
ExportResult.Success(fileUri)
34+
ExportResult.Success(targetUri)
3735
} catch (e: Exception) {
3836
ExportResult.Error("Failed to export notes: ${e.message}")
3937
}

app/src/main/java/com/opennotes/feature_node/presentation/MainActivity.kt

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.opennotes.feature_node.presentation
22

33
import android.os.Bundle
4-
import androidx.activity.ComponentActivity
54
import androidx.activity.compose.setContent
65
import androidx.activity.viewModels
6+
import androidx.biometric.BiometricPrompt
77
import androidx.compose.animation.EnterTransition
88
import androidx.compose.animation.ExitTransition
9+
import androidx.compose.foundation.isSystemInDarkTheme
910
import androidx.compose.material3.MaterialTheme
1011
import androidx.compose.material3.Surface
1112
import androidx.compose.runtime.collectAsState
@@ -17,27 +18,39 @@ import androidx.navigation.compose.composable
1718
import androidx.navigation.compose.rememberNavController
1819
import androidx.navigation.navArgument
1920
import com.opennotes.feature_node.presentation.add_edit_note.AddEditNoteScreen
21+
import com.opennotes.ui.theme.NoteColorPalette
22+
import androidx.compose.ui.graphics.toArgb
23+
import androidx.core.content.ContextCompat
24+
import androidx.fragment.app.FragmentActivity
25+
import androidx.lifecycle.lifecycleScope
2026
import com.opennotes.feature_node.presentation.notes.NotesScreen
2127
import com.opennotes.feature_node.presentation.settings.AboutScreen
2228
import com.opennotes.feature_node.presentation.settings.SettingsScreen
2329
import com.opennotes.feature_node.presentation.settings.SettingsViewModel
30+
import com.opennotes.feature_node.presentation.settings.ThemeMode
2431
import com.opennotes.feature_node.presentation.util.Screen
2532
import com.opennotes.ui.theme.OpenNotesTheme
2633
import dagger.hilt.android.AndroidEntryPoint
34+
import kotlinx.coroutines.flow.filter
35+
import kotlinx.coroutines.flow.first
36+
import kotlinx.coroutines.launch
2737

2838
@AndroidEntryPoint
29-
class MainActivity : ComponentActivity() {
39+
class MainActivity : FragmentActivity() {
3040

3141
private val settingsViewModel: SettingsViewModel by viewModels()
42+
private var hasShownBiometric = false
3243

3344
override fun onCreate(savedInstanceState: Bundle?) {
3445
val splashScreen = installSplashScreen()
3546
super.onCreate(savedInstanceState)
36-
3747
splashScreen.setKeepOnScreenCondition {
3848
!settingsViewModel.isLoaded.value
49+
3950
}
4051

52+
53+
4154
setContent {
4255
val currentSettings by settingsViewModel.settings.collectAsState()
4356

@@ -70,10 +83,25 @@ class MainActivity : ComponentActivity() {
7083
}
7184
)
7285
) { backStackEntry ->
73-
val color = backStackEntry.arguments?.getInt("noteColor") ?: -1
86+
val color = backStackEntry.arguments?.getInt("noteColor")
87+
?.takeIf { it != -1 }
88+
val isDarkTheme = when (currentSettings.themeMode) {
89+
ThemeMode.SYSTEM ->
90+
isSystemInDarkTheme()
91+
ThemeMode.LIGHT -> false
92+
ThemeMode.DARK -> true
93+
}
94+
95+
val resolvedColor = color ?: if (isDarkTheme) {
96+
NoteColorPalette.Dark.first().toArgb()
97+
} else {
98+
NoteColorPalette.Light.first().toArgb()
99+
}
100+
74101
AddEditNoteScreen(
75102
navController = navController,
76-
noteColor = color
103+
noteColor = resolvedColor,
104+
isDarkTheme = isDarkTheme
77105
)
78106
}
79107
composable(route = Screen.SettingsScreen.route) {
@@ -92,5 +120,38 @@ class MainActivity : ComponentActivity() {
92120
}
93121
}
94122
}
123+
124+
125+
}
126+
127+
override fun onWindowFocusChanged(hasFocus: Boolean) {
128+
super.onWindowFocusChanged(hasFocus)
129+
if (hasFocus && !hasShownBiometric) {
130+
hasShownBiometric = true
131+
handleBiometricLock()
132+
}
133+
}
134+
135+
private fun handleBiometricLock() {
136+
lifecycleScope.launch {
137+
settingsViewModel.isLoaded.filter { it }.first()
138+
if (!settingsViewModel.settings.value.biometricLock) return@launch
139+
140+
val promptInfo = BiometricPrompt.PromptInfo.Builder()
141+
.setTitle("Unlock OpenNotes")
142+
.setSubtitle("Confirm your fingerprint to access your notes")
143+
.setNegativeButtonText("Cancel")
144+
.build()
145+
146+
BiometricPrompt(
147+
this@MainActivity,
148+
ContextCompat.getMainExecutor(this@MainActivity),
149+
object : BiometricPrompt.AuthenticationCallback() {
150+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = Unit
151+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) = finish()
152+
override fun onAuthenticationFailed() = Unit
153+
}
154+
).authenticate(promptInfo)
155+
}
95156
}
96157
}

app/src/main/java/com/opennotes/feature_node/presentation/add_edit_note/AddEditNoteScreen.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import androidx.compose.foundation.background
77
import androidx.compose.foundation.border
88
import androidx.compose.foundation.clickable
99
import androidx.compose.foundation.interaction.MutableInteractionSource
10-
import androidx.compose.foundation.isSystemInDarkTheme
10+
1111
import androidx.compose.foundation.layout.*
1212
import androidx.compose.foundation.rememberScrollState
1313
import androidx.compose.foundation.shape.CircleShape
@@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
4040
fun AddEditNoteScreen(
4141
navController: NavController,
4242
noteColor: Int?,
43+
isDarkTheme: Boolean,
4344
viewModel: AddEditNoteViewModel = hiltViewModel()
4445
) {
4546
val titleState = viewModel.noteTitle.value
@@ -48,9 +49,6 @@ fun AddEditNoteScreen(
4849

4950
var isPreviewMode by remember { mutableStateOf(false) }
5051

51-
val fallbackColorInt = MaterialTheme.colorScheme.surface.toArgb()
52-
53-
5452
val resolvedColorInt = remember(noteColor, viewModel.noteColor.value) {
5553
noteColor ?: viewModel.noteColor.value
5654
}
@@ -70,7 +68,7 @@ fun AddEditNoteScreen(
7068
Color.Black
7169
}
7270

73-
val noteColors = if (isSystemInDarkTheme()) {
71+
val noteColors = if (isDarkTheme) {
7472
NoteColorPalette.Dark
7573
} else {
7674
NoteColorPalette.Light
@@ -150,7 +148,7 @@ fun AddEditNoteScreen(
150148
horizontalArrangement = Arrangement.SpaceBetween
151149
) {
152150
noteColors.forEach { color ->
153-
val colorInt = remember(color) { color.toArgb() } // Cache conversion
151+
val colorInt = remember(color) { color.toArgb() }
154152
Box(
155153
modifier = Modifier
156154
.size(50.dp)

app/src/main/java/com/opennotes/feature_node/presentation/add_edit_note/AddEditNoteViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ private var currentNoteId:Int ? = null
6969
text=note.content,
7070
isHintVisible=false
7171
)
72-
_noteColor.value = note.color
72+
_noteColor.intValue = note.color
7373
}
7474
}
7575
}
@@ -119,7 +119,7 @@ private var currentNoteId:Int ? = null
119119
}
120120
}
121121
is AddEditNoteEvent.changeColor -> {
122-
_noteColor.value= event.color
122+
_noteColor.intValue= event.color
123123
}
124124
is AddEditNoteEvent.changeContentFocus -> {
125125
_noteContent.value= _noteContent.value.copy(

app/src/main/java/com/opennotes/feature_node/presentation/settings/Settings.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ enum class ThemeMode {
99
data class Settings(
1010
val themeMode: ThemeMode = ThemeMode.SYSTEM,
1111
val blackTheme: Boolean = false,
12+
val biometricLock: Boolean = false,
1213
// Legacy fields - keeping for migration compatibility
1314
@Deprecated("Use themeMode instead") val darkTheme: Boolean = false,
1415
@Deprecated("Use themeMode instead") val systemTheme: Boolean = true,

0 commit comments

Comments
 (0)