diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a86d691..54226b0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -66,7 +66,7 @@ android { } dependencies { - + implementation("androidx.appcompat:appcompat:1.7.0") implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -108,7 +108,8 @@ dependencies { implementation ("androidx.glance:glance-appwidget:1.0.0") implementation ("androidx.glance:glance-material3:1.0.0") implementation ("androidx.lifecycle:lifecycle-livedata-ktx:2.8.6") - + implementation("androidx.security:security-crypto:1.1.0-alpha04") + implementation("androidx.biometric:biometric:1.4.0-alpha02") } kapt{ diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/MainActivity.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/MainActivity.kt index aafde13..780692b 100644 --- a/android/app/src/main/java/rocks/poopjournal/fucksgiven/MainActivity.kt +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/MainActivity.kt @@ -1,29 +1,31 @@ package rocks.poopjournal.fucksgiven -import android.app.LocaleConfig -import android.app.LocaleManager import android.os.Build -import android.os.Build.VERSION.SDK_INT import android.os.Bundle -import android.os.LocaleList import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.collectAsState -import androidx.core.os.LocaleListCompat +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint +import rocks.poopjournal.fucksgiven.data.getPasswordProtectionEnabled +import rocks.poopjournal.fucksgiven.presentation.component.BiometricPromptManager import rocks.poopjournal.fucksgiven.presentation.navigation.NavGraph +import rocks.poopjournal.fucksgiven.presentation.screens.PasswordPromptScreen import rocks.poopjournal.fucksgiven.presentation.ui.theme.FucksGivenTheme import rocks.poopjournal.fucksgiven.presentation.ui.utils.AppTheme import rocks.poopjournal.fucksgiven.presentation.ui.utils.ThemeSetting import javax.inject.Inject @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { @Inject lateinit var themeSetting: ThemeSetting @@ -39,7 +41,15 @@ class MainActivity : ComponentActivity() { AppTheme.DARK -> true } FucksGivenTheme(darkTheme = useDarkColors) { - NavGraph(navController = rememberNavController(), themeSetting = themeSetting, context = this) + var isAuthenticated by remember { mutableStateOf(false) } + val isPasswordProtectionEnabled = getPasswordProtectionEnabled(context = this) + if (isAuthenticated || !isPasswordProtectionEnabled) { + NavGraph(navController = rememberNavController(), themeSetting = themeSetting, context = this) + } else { + PasswordPromptScreen(context = this) { + isAuthenticated = true + } + } } } } diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/data/SecureStorage.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/data/SecureStorage.kt new file mode 100644 index 0000000..a9a97f6 --- /dev/null +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/data/SecureStorage.kt @@ -0,0 +1,49 @@ +package rocks.poopjournal.fucksgiven.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys + +fun getEncryptedSharedPreferences(context: Context): SharedPreferences { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + + return EncryptedSharedPreferences.create( + "secure_prefs", + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} + +fun savePassword(context: Context, password: String) { + val sharedPreferences = getEncryptedSharedPreferences(context) + val editor = sharedPreferences.edit() + editor.putString("user_password", password) + editor.apply() +} + +fun getPassword(context: Context): String? { + val sharedPreferences = getEncryptedSharedPreferences(context) + return sharedPreferences.getString("user_password", null) +} + +fun clearStoredPassword(context: Context) { + val sharedPreferences = getEncryptedSharedPreferences(context) + val editor = sharedPreferences.edit() + editor.remove("user_password") + editor.apply() +} + +fun setPasswordProtectionEnabled(context: Context, enabled: Boolean) { + val sharedPreferences = getEncryptedSharedPreferences(context) + val editor = sharedPreferences.edit() + editor.putBoolean("password_protection_enabled", enabled) + editor.apply() +} + +fun getPasswordProtectionEnabled(context: Context): Boolean { + val sharedPreferences = getEncryptedSharedPreferences(context) + return sharedPreferences.getBoolean("password_protection_enabled", false) +} diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/component/BiometricPromptManager.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/component/BiometricPromptManager.kt new file mode 100644 index 0000000..fa51cae --- /dev/null +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/component/BiometricPromptManager.kt @@ -0,0 +1,83 @@ +package rocks.poopjournal.fucksgiven.presentation.component + +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.PromptInfo +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +class BiometricPromptManager( + private val activity: AppCompatActivity +) { + private val resultChannel = Channel() + val promptResults = resultChannel.receiveAsFlow() + + fun showBiometricPrompt( + title: String, + description: String + ) { + val manager = BiometricManager.from(activity) + val authenticators = if(Build.VERSION.SDK_INT >= 30) { + BIOMETRIC_STRONG or DEVICE_CREDENTIAL + } else BIOMETRIC_STRONG + + val promptInfo = PromptInfo.Builder() + .setTitle(title) + .setDescription(description) + .setAllowedAuthenticators(authenticators) + + if(Build.VERSION.SDK_INT < 30) { + promptInfo.setNegativeButtonText("Cancel") + } + + when(manager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { + resultChannel.trySend(BiometricResult.HardwareUnavailable) + return + } + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> { + resultChannel.trySend(BiometricResult.FeatureUnavailable) + return + } + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { + resultChannel.trySend(BiometricResult.AuthenticationNotSet) + return + } + else -> Unit + } + + val prompt = BiometricPrompt( + activity, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + resultChannel.trySend(BiometricResult.AuthenticationError(errString.toString())) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + resultChannel.trySend(BiometricResult.AuthenticationSuccess) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + resultChannel.trySend(BiometricResult.AuthenticationFailed) + } + } + ) + prompt.authenticate(promptInfo.build()) + } + + sealed interface BiometricResult { + data object HardwareUnavailable: BiometricResult + data object FeatureUnavailable: BiometricResult + data class AuthenticationError(val error: String): BiometricResult + data object AuthenticationFailed: BiometricResult + data object AuthenticationSuccess: BiometricResult + data object AuthenticationNotSet: BiometricResult + } +} \ No newline at end of file diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/AppRoutes.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/AppRoutes.kt index 05784fc..284d8ed 100644 --- a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/AppRoutes.kt +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/AppRoutes.kt @@ -3,4 +3,5 @@ package rocks.poopjournal.fucksgiven.presentation.navigation const val HOME_SCREEN = "HomeScreen" const val SETTINGS_SCREEN = "SettingsScreen" const val STATS_SCREEN = "StatsScreen" -const val ABOUT_SCREEN = "AboutScreen" \ No newline at end of file +const val ABOUT_SCREEN = "AboutScreen" +const val PASSWORD_PROMPT_SCREEN = "PasswordPromptScreen" \ No newline at end of file diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/NavGraph.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/NavGraph.kt index af93db2..a2b5a47 100644 --- a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/NavGraph.kt +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/navigation/NavGraph.kt @@ -11,6 +11,7 @@ import androidx.navigation.compose.composable import rocks.poopjournal.fucksgiven.presentation.screens.AboutScreen import rocks.poopjournal.fucksgiven.presentation.viewmodel.HomeViewModel import rocks.poopjournal.fucksgiven.presentation.screens.HomeScreen +import rocks.poopjournal.fucksgiven.presentation.screens.PasswordPromptScreen import rocks.poopjournal.fucksgiven.presentation.screens.SettingScreen import rocks.poopjournal.fucksgiven.presentation.screens.StatsScreen import rocks.poopjournal.fucksgiven.presentation.ui.utils.ThemeSetting @@ -19,7 +20,7 @@ import rocks.poopjournal.fucksgiven.presentation.viewmodel.StatsViewModel @RequiresApi(Build.VERSION_CODES.P) @Composable -fun NavGraph(navController: NavHostController,themeSetting: ThemeSetting,context: Context){ +fun NavGraph(navController: NavHostController,themeSetting: ThemeSetting, context: Context){ val viewModel : HomeViewModel = hiltViewModel() val statsViewModel : StatsViewModel = hiltViewModel() val settingsViewModel : SettingsViewModel = hiltViewModel() @@ -37,8 +38,12 @@ fun NavGraph(navController: NavHostController,themeSetting: ThemeSetting,context } composable(route = SETTINGS_SCREEN){ - SettingScreen(navController = navController, viewModel = settingsViewModel) + SettingScreen(navController = navController, viewModel = settingsViewModel, context = context) + } + composable(route = PASSWORD_PROMPT_SCREEN){ + PasswordPromptScreen(context, onAuthenticated = { + navController.navigate(HOME_SCREEN) + }) } - } } \ No newline at end of file diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/PasswordPromptScreen.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/PasswordPromptScreen.kt new file mode 100644 index 0000000..0d31c99 --- /dev/null +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/PasswordPromptScreen.kt @@ -0,0 +1,153 @@ +package rocks.poopjournal.fucksgiven.presentation.screens + +import rocks.poopjournal.fucksgiven.data.getPassword +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import rocks.poopjournal.fucksgiven.R +import rocks.poopjournal.fucksgiven.presentation.component.BiometricPromptManager + +@Composable +fun PasswordPromptScreen(context: Context, onAuthenticated: () -> Unit) { + var enteredPassword by remember { mutableStateOf("") } + val storedPassword = getPassword(context) + + val promptManager = remember { BiometricPromptManager(context as AppCompatActivity) } + + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val biometricResult by promptManager.promptResults.collectAsState(initial = null) + val enrollLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { + println("Activity result: $it") + } + ) + + LaunchedEffect(biometricResult) { + if (biometricResult is BiometricPromptManager.BiometricResult.AuthenticationNotSet) { + if (Build.VERSION.SDK_INT >= 30) { + val enrollIntent = Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply { + putExtra( + Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, + BIOMETRIC_STRONG or DEVICE_CREDENTIAL + ) + } + enrollLauncher.launch(enrollIntent) + } + } else if (biometricResult is BiometricPromptManager.BiometricResult.AuthenticationSuccess) { + onAuthenticated() // Call onAuthenticated if biometric authentication succeeds + } + } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.fillMaxSize(0.2f)) + + Image( + painter = painterResource(R.drawable.fucks), + contentDescription = "Logo", + modifier = Modifier.size(200.dp) + .align(Alignment.CenterHorizontally) + .padding(vertical = 24.dp) + ) + + OutlinedTextField( + value = enteredPassword, + onValueChange = { enteredPassword = it }, + label = { Text("Enter Password") }, + modifier = Modifier.padding(vertical = 16.dp), + visualTransformation = PasswordVisualTransformation() + ) + + OutlinedButton(onClick = { + if (storedPassword == enteredPassword) { + onAuthenticated() + } else { + Toast.makeText(context, "Password is not correct", Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.fillMaxWidth(0.3f) + ) { + Text(text = "Login") + } + Spacer(modifier = Modifier.fillMaxSize(0.4f)) + OutlinedButton(onClick = { + promptManager.showBiometricPrompt( + title = "Biometric Authentication", + description = "Authenticate using your Fingerprint" + ) + } + ) { + Text(text = "Authenticate with Fingerprint") + } + + biometricResult?.let { result -> + Text( + text = when (result) { + is BiometricPromptManager.BiometricResult.AuthenticationError -> { + result.error + } + BiometricPromptManager.BiometricResult.AuthenticationFailed -> { + "Authentication failed" + } + BiometricPromptManager.BiometricResult.AuthenticationNotSet -> { + "Authentication not set" + } + BiometricPromptManager.BiometricResult.AuthenticationSuccess -> { + "Authentication success" + } + BiometricPromptManager.BiometricResult.FeatureUnavailable -> { + "Feature unavailable" + } + BiometricPromptManager.BiometricResult.HardwareUnavailable -> { + "Hardware unavailable" + } + }, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} diff --git a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/SettingsScreen.kt b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/SettingsScreen.kt index 1845219..4538a71 100644 --- a/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/SettingsScreen.kt +++ b/android/app/src/main/java/rocks/poopjournal/fucksgiven/presentation/screens/SettingsScreen.kt @@ -1,7 +1,10 @@ package rocks.poopjournal.fucksgiven.presentation.screens +import android.content.Context +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,7 +23,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -31,13 +37,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import rocks.poopjournal.fucksgiven.R +import rocks.poopjournal.fucksgiven.data.getPasswordProtectionEnabled +import rocks.poopjournal.fucksgiven.data.savePassword +import rocks.poopjournal.fucksgiven.data.setPasswordProtectionEnabled import rocks.poopjournal.fucksgiven.presentation.component.ThemeContent import rocks.poopjournal.fucksgiven.presentation.navigation.ABOUT_SCREEN import rocks.poopjournal.fucksgiven.presentation.ui.utils.ThemeSetting @@ -45,9 +56,12 @@ import rocks.poopjournal.fucksgiven.presentation.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingScreen(navController: NavHostController, viewModel: SettingsViewModel) { +fun SettingScreen(navController: NavHostController, viewModel: SettingsViewModel, context: Context) { var showDialog by remember { mutableStateOf(false) } val toastMessage = stringResource(id = R.string.backup_success) + var isPasswordProtectionEnabled by remember { mutableStateOf(getPasswordProtectionEnabled(context)) } + var showPasswordDialog by remember { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -105,6 +119,62 @@ fun SettingScreen(navController: NavHostController, viewModel: SettingsViewModel } } } + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background(MaterialTheme.colorScheme.secondary), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.security), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 11.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 11.dp) + ) { + Text(text = stringResource(R.string.enable_app_protection)) + Switch( + modifier = Modifier.padding(start = 4.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.primary, + uncheckedTrackColor = Color.White, + ), + checked = isPasswordProtectionEnabled, + onCheckedChange = { enabled -> + isPasswordProtectionEnabled = enabled + + if (enabled) { + showPasswordDialog = true + } else{ + isPasswordProtectionEnabled = false + setPasswordProtectionEnabled(context, false) + } + } + ) + } + } + if (showPasswordDialog) { + SetPasswordScreen( + context = context, + onPasswordSet = { + showPasswordDialog = false + }, + onDismissRequest = { + showPasswordDialog = false + isPasswordProtectionEnabled = false + setPasswordProtectionEnabled(context, false) + } + ) + } + Column { Row( modifier = Modifier @@ -221,3 +291,47 @@ fun ThemeSelectionDialog( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SetPasswordScreen(context: Context, onPasswordSet: () -> Unit, onDismissRequest: () -> Unit) { + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + AlertDialog(onDismissRequest = onDismissRequest){ + Column( + modifier = Modifier.clipToBounds(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextField( + value = password, + onValueChange = { password = it }, + label = { Text("Enter Password") }, + modifier = Modifier.padding(16.dp), + visualTransformation = PasswordVisualTransformation() + ) + TextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = { Text("Confirm Password") }, + modifier = Modifier.padding(16.dp), + visualTransformation = PasswordVisualTransformation() + ) + Button( + onClick = { + if (password == confirmPassword && password.isNotBlank()) { + setPasswordProtectionEnabled(context, true) + savePassword(context, password) + onPasswordSet() + } else { + password = "" + confirmPassword = "" + Toast.makeText(context, "Password didn't match", Toast.LENGTH_SHORT ).show() + } + }) { + Text(text = "Set Password", color = Color.White) + } + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0f5d397..8972f57 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -11,6 +11,8 @@ Settings General Appearance + Enable App Protection + Security Data Backup Database backup successfully diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 2201671..f8d74b0 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,7 +1,7 @@ -