Skip to content

Commit

Permalink
- Feat: Add list security
Browse files Browse the repository at this point in the history
Adds the ability to require biometric authentication to view a custom list. This feature includes UI updates to add and remove authentication for a
 list, as well as a prompt for authentication when accessing a secured list.
  • Loading branch information
jacobrein committed Oct 3, 2024
1 parent 5daeee2 commit 7055786
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 16 deletions.
2 changes: 2 additions & 0 deletions UIViews/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ dependencies {
implementation("com.vanniktech:blurhash:0.4.0-SNAPSHOT")
ksp(libs.roomCompiler)

implementation("androidx.biometric:biometric:1.4.0-alpha02")

implementation(projects.gemini)

//TODO: Use this to check recomposition count on every screen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColor
import androidx.compose.animation.animateColorAsState
Expand Down Expand Up @@ -108,6 +109,7 @@ import com.programmersbox.uiviews.utils.M3CoverCard
import com.programmersbox.uiviews.utils.PreviewTheme
import com.programmersbox.uiviews.utils.Screen
import com.programmersbox.uiviews.utils.adaptiveGridCell
import com.programmersbox.uiviews.utils.biometricPrompting
import com.programmersbox.uiviews.utils.components.CoilGradientImage
import com.programmersbox.uiviews.utils.components.DynamicSearchBar
import com.programmersbox.uiviews.utils.components.ListBottomScreen
Expand All @@ -117,6 +119,7 @@ import com.programmersbox.uiviews.utils.dispatchIo
import com.programmersbox.uiviews.utils.launchCatching
import com.programmersbox.uiviews.utils.loadItem
import com.programmersbox.uiviews.utils.navigateToDetails
import com.programmersbox.uiviews.utils.rememberBiometricPrompt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
Expand All @@ -126,6 +129,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.koinInject
import java.util.UUID

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
Expand All @@ -144,7 +148,21 @@ fun OtakuCustomListScreen(
onSearchBarActiveChange: (Boolean) -> Unit,
navigateBack: () -> Unit,
isHorizontal: Boolean = false,
addSecurityItem: (UUID) -> Unit,
removeSecurityItem: (UUID) -> Unit,
hasAuthentication: Boolean = false,
) {
val biometricPrompt = rememberBiometricPrompt(
onAuthenticationSucceeded = {
customItem?.item?.uuid?.let { addSecurityItem(it) }
},
)

val removeBiometricPrompt = rememberBiometricPrompt(
onAuthenticationSucceeded = {
customItem?.item?.uuid?.let { removeSecurityItem(it) }
},
)
val navController = LocalNavController.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
Expand Down Expand Up @@ -201,6 +219,70 @@ fun OtakuCustomListScreen(
)
}

var showSecurityDialog by remember { mutableStateOf(false) }

if (showSecurityDialog) {
AlertDialog(
onDismissRequest = { showSecurityDialog = false },
title = { Text("Require Authentication to View this list?") },
text = { Text("This will require phone authentication to view this list") },
confirmButton = {
TextButton(
onClick = {
showSecurityDialog = false
biometricPrompting(
context,
biometricPrompt
).authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Add Authentication for ${customItem?.item?.name}")
.setSubtitle("Enter Authentication to add")
.setNegativeButtonText("Never Mind")
.build()
)
}
) { Text(stringResource(id = R.string.confirm)) }
},
dismissButton = {
TextButton(
onClick = { showSecurityDialog = false }
) { Text(stringResource(id = R.string.cancel)) }
}
)
}

var showRemoveSecurityDialog by remember { mutableStateOf(false) }

if (showRemoveSecurityDialog) {
AlertDialog(
onDismissRequest = { showRemoveSecurityDialog = false },
title = { Text("Remove Authentication?") },
text = { Text("This will remove the phone authentication to view this list") },
confirmButton = {
TextButton(
onClick = {
showRemoveSecurityDialog = false
biometricPrompting(
context,
removeBiometricPrompt
).authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Remove Authentication for ${customItem?.item?.name}")
.setSubtitle("Enter Authentication to remove")
.setNegativeButtonText("Never Mind")
.build()
)
}
) { Text(stringResource(id = R.string.confirm)) }
},
dismissButton = {
TextButton(
onClick = { showRemoveSecurityDialog = false }
) { Text(stringResource(id = R.string.cancel)) }
}
)
}

var showAdd by remember { mutableStateOf(false) }

if (showAdd) {
Expand Down Expand Up @@ -357,6 +439,24 @@ fun OtakuCustomListScreen(
}
)

if (hasAuthentication) {
DropdownMenuItem(
text = { Text("Remove Authentication") },
onClick = {
showRemoveSecurityDialog = true
showMenu = false
},
)
} else {
DropdownMenuItem(
text = { Text("Require Authentication") },
onClick = {
showSecurityDialog = true
showMenu = false
},
)
}

DropdownMenuItem(
text = { Text(stringResource(R.string.remove_items)) },
onClick = {
Expand Down Expand Up @@ -744,7 +844,9 @@ private fun CustomListScreenPreview() {
searchQuery = viewModel.searchQuery,
setQuery = viewModel::setQuery,
searchBarActive = viewModel.searchBarActive,
onSearchBarActiveChange = { viewModel.searchBarActive = it }
onSearchBarActiveChange = { viewModel.searchBarActive = it },
addSecurityItem = {},
removeSecurityItem = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package com.programmersbox.uiviews.lists

import androidx.activity.compose.BackHandler
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
Expand Down Expand Up @@ -38,16 +39,23 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.lifecycle.viewmodel.compose.viewModel
import com.programmersbox.favoritesdatabase.ListDao
import com.programmersbox.favoritesdatabase.SecurityItem
import com.programmersbox.uiviews.utils.LocalCustomListDao
import com.programmersbox.uiviews.utils.LocalSettingsHandling
import com.programmersbox.uiviews.utils.biometricPrompting
import com.programmersbox.uiviews.utils.findActivity
import com.programmersbox.uiviews.utils.rememberBiometricPrompt
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
Expand All @@ -56,6 +64,9 @@ fun OtakuListScreen(
viewModel: OtakuListViewModel = viewModel { OtakuListViewModel(listDao) },
isHorizontal: Boolean = false,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val securityItems = viewModel.securityItems
val showListDetail by LocalSettingsHandling.current.rememberShowListDetail()

val windowSize = with(LocalDensity.current) {
Expand Down Expand Up @@ -98,6 +109,13 @@ fun OtakuListScreen(
viewModel.customItem = null
state.navigateBack()
},
addSecurityItem = {
scope.launch { listDao.addSecurityItem(SecurityItem(it)) }
},
removeSecurityItem = {
scope.launch { listDao.removeSecurityItem(SecurityItem(it)) }
},
hasAuthentication = securityItems.any { s -> s.uuid == targetState.item.uuid }
)
BackHandler {
viewModel.customItem = null
Expand All @@ -110,6 +128,18 @@ fun OtakuListScreen(
}
}

val navigate = {
if (showListDetail)
state.navigateTo(ListDetailPaneScaffoldRole.Detail)
else
state.navigateTo(ListDetailPaneScaffoldRole.Extra)
}

val biometricPrompt = rememberBiometricPrompt(
onAuthenticationSucceeded = { navigate() },
onAuthenticationFailed = { viewModel.customItem = null }
)

ListDetailPaneScaffold(
directive = state.scaffoldDirective,
value = state.scaffoldValue,
Expand All @@ -120,10 +150,20 @@ fun OtakuListScreen(
customLists = viewModel.customLists,
navigateDetail = {
viewModel.customItem = it
if (showListDetail)
state.navigateTo(ListDetailPaneScaffoldRole.Detail)
else
state.navigateTo(ListDetailPaneScaffoldRole.Extra)
if (securityItems.any { s -> s.uuid == it.item.uuid }) {
biometricPrompting(
context.findActivity(),
biometricPrompt
).authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Authentication required")
.setSubtitle("In order to view ${it.item.name}, please authenticate")
.setNegativeButtonText("Never Mind")
.build()
)
} else {
navigate()
}
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.programmersbox.favoritesdatabase.CustomList
import com.programmersbox.favoritesdatabase.CustomListInfo
import com.programmersbox.favoritesdatabase.ListDao
import com.programmersbox.favoritesdatabase.SecurityItem
import com.programmersbox.gsonutils.toJson
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
Expand Down Expand Up @@ -52,13 +53,23 @@ class OtakuListViewModel(
.toList()
}

val securityItems = mutableStateListOf<SecurityItem>()

init {
listDao.getAllLists()
.onEach {
customLists.clear()
customLists.addAll(it)
}
.launchIn(viewModelScope)

listDao.getSecurityItems()
.onEach {
println(it)
securityItems.clear()
securityItems.addAll(it)
}
.launchIn(viewModelScope)
}

fun removeItem(item: CustomListInfo) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.programmersbox.uiviews.utils

import android.content.Context
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState

fun biometricPrompting(
context: Context,
authenticationCallback: BiometricPrompt.AuthenticationCallback,
) = BiometricPrompt(
context.findActivity(),
context.mainExecutor,
authenticationCallback
)

@Composable
fun rememberBiometricPrompt(
onAuthenticationSucceeded: () -> Unit,
onAuthenticationFailed: () -> Unit = {},
): BiometricPrompt.AuthenticationCallback {
val succeed by rememberUpdatedState(onAuthenticationSucceeded)
val failed by rememberUpdatedState(onAuthenticationFailed)
return remember(succeed, failed) {
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
failed()
}

override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
super.onAuthenticationSucceeded(result)
println("Authentication succeeded $result")
succeed()
}

override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
println("Authentication failed")
failed()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ import kotlinx.coroutines.flow.Flow
import java.util.UUID

@Database(
entities = [CustomListItem::class, CustomListInfo::class],
version = 2,
autoMigrations = [AutoMigration(from = 1, to = 2)]
entities = [CustomListItem::class, CustomListInfo::class, SecurityItem::class],
version = 3,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3)
]
)
abstract class ListDatabase : RoomDatabase() {

Expand Down Expand Up @@ -107,6 +110,15 @@ interface ListDao {
true
}
}

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addSecurityItem(item: SecurityItem)

@Delete
suspend fun removeSecurityItem(item: SecurityItem)

@Query("SELECT * FROM SecurityItem")
fun getSecurityItems(): Flow<List<SecurityItem>>
}

data class CustomList(
Expand Down Expand Up @@ -146,5 +158,11 @@ data class CustomListInfo(
@ColumnInfo(name = "imageUrl")
val imageUrl: String,
@ColumnInfo(name = "sources")
val source: String
val source: String,
)

@Entity(tableName = "SecurityItem")
data class SecurityItem(
@PrimaryKey
val uuid: UUID,
)
Loading

0 comments on commit 7055786

Please sign in to comment.