diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab8a1156..e394cc93 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,11 +78,13 @@ dependencies { implementation(libs.google.accompanist.systemuicontroller) implementation(libs.kotlinx.coroutines.android) implementation(libs.librepass.client) - implementation(libs.medzik.android.components) - implementation(libs.medzik.android.crypto) - implementation(libs.medzik.android.utils) implementation(libs.process.phoenix) + // local modules + implementation(project(":components")) + implementation(project(":crypto")) + implementation(project(":utils")) + // for splash screen with material3 and dynamic color implementation(libs.google.material) diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt index 4dc2815f..5f8a2111 100644 --- a/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt @@ -3,7 +3,7 @@ package dev.medzik.librepass.android.ui.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -102,7 +102,7 @@ fun TopBarTwoColorPreview() { fun TopBarBackIcon(navController: NavController) { IconButton(onClick = { navController.popBackStack() }) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = Icons.Default.ArrowBack, contentDescription = null ) } diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt index 4685ea99..f4da1bfc 100644 --- a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt @@ -1,7 +1,7 @@ package dev.medzik.librepass.android.ui.screens.settings import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Logout import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext @@ -20,21 +20,22 @@ fun SettingsAccountScreen(navController: NavController) { val repository = context.getRepository() - fun logout() = runBlocking { - val credentials = repository.credentials.get()!! + fun logout() = + runBlocking { + val credentials = repository.credentials.get()!! - repository.credentials.drop() - repository.cipher.drop(credentials.userId) + repository.credentials.drop() + repository.cipher.drop(credentials.userId) - navController.navigate( - screen = Screen.Welcome, - disableBack = true - ) - } + navController.navigate( + screen = Screen.Welcome, + disableBack = true + ) + } PreferenceEntry( title = stringResource(R.string.Settings_Logout), - icon = { Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) }, + icon = { Icon(Icons.Default.Logout, contentDescription = null) }, onClick = { logout() }, ) } diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt index 5a2d9881..8a7b003c 100644 --- a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt @@ -7,10 +7,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.FloatingActionButton @@ -55,22 +55,25 @@ import java.util.UUID @Composable fun CipherViewScreen(navController: NavController) { - val cipherId = navController.getString(Argument.CipherId) - ?: return + val cipherId = + navController.getString(Argument.CipherId) + ?: return val context = LocalContext.current - val userSecrets = context.getUserSecrets() - ?: return + val userSecrets = + context.getUserSecrets() + ?: return val cipher = context.getRepository().cipher.get(UUID.fromString(cipherId))!!.encryptedCipher - val cipherData = try { - Cipher(cipher, userSecrets.secretKey).loginData!! - } catch (e: Exception) { - // handle decryption error - DecryptionError(navController, e) - return - } + val cipherData = + try { + Cipher(cipher, userSecrets.secretKey).loginData!! + } catch (e: Exception) { + // handle decryption error + DecryptionError(navController, e) + return + } Scaffold( topBar = { @@ -94,10 +97,11 @@ fun CipherViewScreen(navController: NavController) { } ) { innerPadding -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) ) { item { CipherField( @@ -155,9 +159,10 @@ fun CipherViewScreen(navController: NavController) { Text( text = parser.format(passwords[i].lastUsed), style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.6f - ) + color = + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.6f + ) ) Text( @@ -290,7 +295,7 @@ fun CipherField( } }) { Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, + imageVector = Icons.Default.OpenInNew, contentDescription = null ) } @@ -309,7 +314,10 @@ fun CipherField( } @Composable -fun DecryptionError(navController: NavController, e: Exception) { +fun DecryptionError( + navController: NavController, + e: Exception +) { Scaffold( topBar = { TopBar( diff --git a/components/.gitignore b/components/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/components/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/components/build.gradle.kts b/components/build.gradle.kts new file mode 100644 index 00000000..9e2565f6 --- /dev/null +++ b/components/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "dev.medzik.android.components" + compileSdk = libs.versions.android.sdk.compile.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.sdk.min.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + buildConfig = false + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } +} + +dependencies { + compileOnly(libs.androidx.material3) + compileOnly(libs.androidx.navigation.compose) + + debugImplementation(libs.androidx.material3) + debugImplementation(libs.androidx.navigation.compose) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.ui.test.junit4) + + testImplementation(libs.androidx.junit) +// testImplementation(libs.androidx.compose.runtime) + + debugImplementation(libs.androidx.ui.test.manifest) + + // for preview support + debugImplementation(libs.androidx.ui.tooling) + implementation(libs.androidx.ui.tooling.preview) +} diff --git a/components/src/androidTest/java/dev/medzik/android/components/NavigationTests.kt b/components/src/androidTest/java/dev/medzik/android/components/NavigationTests.kt new file mode 100644 index 00000000..2c9aaae9 --- /dev/null +++ b/components/src/androidTest/java/dev/medzik/android/components/NavigationTests.kt @@ -0,0 +1,77 @@ +package dev.medzik.android.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import org.junit.Rule +import org.junit.Test + +enum class Argument : NavArgument { + ID, + Name +} + +enum class Screen(override val args: Array? = null) : NavScreen { + Home, + Example(arrayOf(Argument.ID, Argument.Name)) +} + +class NavigationTests { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testNavigation() { + composeTestRule.setContent { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = Screen.Home.getRoute()) { + composable(Screen.Home.getRoute()) { + Column { + Text("Home Screen") + + Button(onClick = { + navController.navigate( + screen = Screen.Example, + args = + arrayOf( + Argument.ID to "test id", + Argument.Name to "test name" + ) + ) + }) { + Text("Click me to go to Example screen") + } + } + } + + composable(Screen.Example.getRoute()) { + val id = navController.getString(Argument.ID) + val name = navController.getString(Argument.Name) + + Column { + Text("Example Screen") + Text("ID: $id") + Text("Name: $name") + } + } + } + } + + composeTestRule.onNodeWithText("Home Screen").assertExists() + + // go to example screen + composeTestRule.onNodeWithText("Click me to go to Example screen").performClick() + composeTestRule.onNodeWithText("Example Screen").assertExists() + + // check arguments + composeTestRule.onNodeWithText("ID: test id").assertExists() + composeTestRule.onNodeWithText("Name: test name").assertExists() + } +} diff --git a/components/src/androidTest/java/dev/medzik/android/components/RememberTests.kt b/components/src/androidTest/java/dev/medzik/android/components/RememberTests.kt new file mode 100644 index 00000000..566c9670 --- /dev/null +++ b/components/src/androidTest/java/dev/medzik/android/components/RememberTests.kt @@ -0,0 +1,80 @@ +package dev.medzik.android.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test + +class RememberTests { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testRememberMutable() { + composeTestRule.setContent { + var clicked by rememberMutable(0) + + Column { + Text(text = "Clicked: $clicked") + + Button(onClick = { clicked++ }) { + Text(text = "Click me") + } + } + } + + // click the button two times + repeat(2) { + composeTestRule.onNodeWithText("Click me").performClick() + } + + // check if the text changed + composeTestRule.onNodeWithText("Clicked: 2").assertExists() + } + + @Test + fun testRememberMutableString() { + composeTestRule.setContent { + var value by rememberMutableString() + + Column { + Text(text = "Current Value: $value") + + Button(onClick = { value = "test" }) { + Text(text = "Click me") + } + } + } + + // click button + composeTestRule.onNodeWithText("Click me").performClick() + // check if the text changed + composeTestRule.onNodeWithText("Current Value: test").assertExists() + } + + @Test + fun testRememberMutableBoolean() { + composeTestRule.setContent { + var value by rememberMutableBoolean() + + Column { + Text(text = "Current Value: $value") + + Button(onClick = { value = true }) { + Text(text = "Click me") + } + } + } + + // click button + composeTestRule.onNodeWithText("Click me").performClick() + // check if the text changed + composeTestRule.onNodeWithText("Current Value: true").assertExists() + } +} diff --git a/components/src/main/java/dev/medzik/android/components/Dialog.kt b/components/src/main/java/dev/medzik/android/components/Dialog.kt new file mode 100644 index 00000000..33c8f380 --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/Dialog.kt @@ -0,0 +1,172 @@ +package dev.medzik.android.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun rememberDialogState() = remember { DialogState() } + +/** A visibility controller for a dialog */ +class DialogState { + var isVisible by mutableStateOf(false) + + /** Show the dialog. */ + fun show() { + isVisible = true + } + + /** Hide the dialog. */ + fun hide() { + isVisible = false + } +} + +/** + * A composable function for displaying a basic dialog. + * @param state the state that controls visibility of the dialog + * @param content the content of the dialog to display + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseDialog( + state: DialogState, + content: @Composable () -> Unit +) { + if (state.isVisible) { + AlertDialog(onDismissRequest = { state.hide() }) { + Surface( + shape = AlertDialogDefaults.shape, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Box( + modifier = Modifier.padding(vertical = 24.dp) + ) { + content() + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +fun BaseDialogPreview() { + val state = rememberDialogState() + state.show() + + Surface { + BaseDialog(state) { + Column { + Text( + text = "Example Dialog", + fontWeight = FontWeight.Black, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 8.dp) + ) + + Box( + modifier = + Modifier + .padding(horizontal = 24.dp) + ) { + Text("Some text") + } + } + } + } +} + +/** + * A composable function for displaying a picker dialog with a list of items. + * @param state the state that controls visibility of the dialog + * @param title the title to display at the top of the dialog + * @param items the list of items to display in the picker + * @param onSelected a callback function invoked when the item is selected + * @param content composable lambda that defines the visual representation of each item in the picker + */ +@Composable +fun PickerDialog( + state: DialogState, + title: String, + items: List, + onSelected: (T) -> Unit, + content: @Composable (T) -> Unit +) { + BaseDialog(state) { + Column { + Text( + text = title, + fontWeight = FontWeight.Black, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 8.dp) + ) + + items.forEach { item -> + Box( + modifier = + Modifier + .clickable { + onSelected(item) + state.hide() + } + ) { + Row( + modifier = + Modifier + .padding(horizontal = 24.dp) + ) { + content(item) + } + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +fun PickerDialogPreview() { + val state = rememberDialogState() + state.show() + + val items = listOf("First", "Second", "Third") + + Surface { + PickerDialog( + state, + title = "Example Picker Dialog", + items, + onSelected = {} + ) { + Text( + modifier = + Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(), + text = it + ) + } + } +} diff --git a/components/src/main/java/dev/medzik/android/components/ElevationTokens.kt b/components/src/main/java/dev/medzik/android/components/ElevationTokens.kt new file mode 100644 index 00000000..7b51b69f --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/ElevationTokens.kt @@ -0,0 +1,14 @@ +package dev.medzik.android.components + +import androidx.compose.ui.unit.dp + +/** This object defines elevation tokens provide predefined elevation values for UI elements. */ +@Suppress("unused") +object ElevationTokens { + val Level0 = 0.dp + val Level1 = 1.dp + val Level2 = 3.dp + val Level3 = 6.dp + val Level4 = 8.dp + val Level5 = 12.dp +} diff --git a/components/src/main/java/dev/medzik/android/components/Loading.kt b/components/src/main/java/dev/medzik/android/components/Loading.kt new file mode 100644 index 00000000..fa79e9cc --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/Loading.kt @@ -0,0 +1,165 @@ +package dev.medzik.android.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Only the dot that is used in [LoadingIndicator]. + * @param color The color of the dot. + * @param modifier The modifier to apply to the dot. + * @see LoadingIndicator + */ +@Composable +fun LoadingDot( + color: Color, + modifier: Modifier = Modifier +) { + Box( + modifier = + modifier + .clip(CircleShape) + .background(color) + ) +} + +/** + * A loading indicator that animates three dots in a row. + * @param animating Whether the indicator should be animating. + * @param modifier The modifier to apply to the indicator. + * @param color The color of the indicator. + * @param indicatorSpacing The spacing between the dots. + * @see LoadingDot + */ +@Composable +fun LoadingIndicator( + animating: Boolean, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + indicatorSpacing: Dp = 4.dp +) { + val animatedValues = + List(3) { index -> + var animatedValue by remember(animating) { mutableFloatStateOf(0f) } + + LaunchedEffect(animating) { + if (animating) { + animate( + initialValue = 8 / 2f, + targetValue = -8 / 2f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 300), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(300 / 3 * index) + ) + ) { value, _ -> animatedValue = value } + } + } + + animatedValue + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + animatedValues.forEach { animatedValue -> + LoadingDot( + modifier = + Modifier + .padding(horizontal = indicatorSpacing) + .width(8.dp) + .aspectRatio(1f) + .offset(y = animatedValue.dp), + color = color + ) + } + } +} + +@Preview +@Composable +fun LoadingIndicatorPreview() { + LoadingIndicator(animating = true) +} + +/** + * A button that shows the loading indicator, e.g., when clicked while waiting for an API response. + * @param onClick called when this button is clicked + * @param modifier the [Modifier] to be applied to this button + * @param loading if true, a loading animation will be shown + * @param enabled controls the enabled state of this button + */ +@Composable +fun LoadingButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + loading: Boolean = false, + enabled: Boolean = true, + content: @Composable () -> Unit +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled && !loading + ) { + if (loading) { + LoadingIndicator(animating = true) + } else { + content() + } + } +} + +@Preview +@Composable +fun LoadingButtonPreview() { + Surface { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoadingButton( + loading = false, + onClick = {}, + ) { + Text("Loading - false") + } + + LoadingButton( + loading = true, + onClick = {}, + ) { + Text("Loading - true") + } + } + } +} diff --git a/components/src/main/java/dev/medzik/android/components/Navigation.kt b/components/src/main/java/dev/medzik/android/components/Navigation.kt new file mode 100644 index 00000000..418a31ba --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/Navigation.kt @@ -0,0 +1,78 @@ +package dev.medzik.android.components + +import androidx.navigation.NavController +import androidx.navigation.NavOptionsBuilder + +/** A navigation argument interface that represents an argument for a navigation screens. */ +interface NavArgument { + val name: String +} + +/** + * A navigation screen interface that represents a destination within a navigation graph. + * @property args An optional array of [NavArgument] objects representing the arguments required for this screen. + */ +interface NavScreen { + val name: String + val args: Array? + + /** + * Returns the route destination for the screen without filling arguments. + * @return The route destination as a string. + */ + fun getRoute(): String { + return if (args != null) { + "${name.lowercase()}/${args!!.joinToString("/") { "{${it.name.lowercase()}}" }}" + } else { + name.lowercase() // if no arguments, return route without arguments + } + } + + /** + * Returns the route destination for the screen with filled arguments. + * @param args Pairs of [NavArgument] and their corresponding argument values. + * @return The route destination as a string with filled arguments. + * @throws IllegalArgumentException if the number of provided arguments does not match the expected count. + */ + fun fill(vararg args: Pair): String { + if (args.size != (this.args?.size ?: 0)) + throw IllegalArgumentException("Invalid number of arguments. Expected ${this.args?.size}, got ${args.size}") + + var route = getRoute() + for (arg in args) + route = route.replace("{${arg.first.name.lowercase()}}", arg.second) + + return route + } +} + +/** Gets the value of argument from navigation controller */ +fun NavController.getString(argument: NavArgument): String? { + return currentBackStackEntry?.arguments?.getString(argument.name.lowercase()) +} + +/** + * Navigates to the specified [NavScreen] with the provided [NavArgument] values. + * @param screen the target screen to navigate to + * @param args a list of argument pairs in the form of [NavArgument] and [String] values + * @param disableBack determines whether to disable the "back" navigation from this screen + * @param builderOptions a function that allows customization of custom navigation options + */ +fun NavController.navigate( + screen: NavScreen, + args: Array>? = null, + disableBack: Boolean = false, + builderOptions: (NavOptionsBuilder.() -> Unit)? = null +) { + val route = if (args != null) screen.fill(*args) else screen.fill() + + navigate( + route = route, + builder = { + // Disable back navigation + if (disableBack) popUpTo(graph.startDestinationId) { inclusive = true } + + if (builderOptions != null) builderOptions() + } + ) +} diff --git a/components/src/main/java/dev/medzik/android/components/Preference.kt b/components/src/main/java/dev/medzik/android/components/Preference.kt new file mode 100644 index 00000000..09dbf1f1 --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/Preference.kt @@ -0,0 +1,232 @@ +package dev.medzik.android.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** A composable function with text with secondary text color. */ +@Composable +fun SecondaryText( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier + ) +} + +/** + * A composable function to display a preference group title. + * + * @param title The title to display for the preference group. + * @param modifier The modifier for customizing the appearance and behavior of the preference group title. + */ +@Composable +fun PreferenceGroupTitle( + title: String, + modifier: Modifier = Modifier +) { + SecondaryText( + text = title, + modifier = modifier.padding(vertical = 8.dp, horizontal = 16.dp) + ) +} + +/** + * A composable function to display a preference entry. + * + * @param modifier The modifier for customizing the appearance and behavior of the preference entry. + * @param title The title of the preference entry. + * @param description An optional description of the preference entry. + * @param content An optional composable content to display within the preference entry. + * @param icon An optional icon to display with the preference entry. + * @param trailingContent An optional composable content to display at the end of the preference entry. + * @param onClick The callback to execute when the preference entry is clicked. + * @param isEnabled A flag indicating whether the preference entry is enabled or disabled. + */ +@Composable +fun PreferenceEntry( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + content: (@Composable () -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + onClick: () -> Unit, + isEnabled: Boolean = true, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .clickable( + enabled = isEnabled, + onClick = onClick + ) + .alpha(if (isEnabled) 1f else 0.5f) + .padding(vertical = 16.dp, horizontal = 16.dp) + ) { + if (icon != null) { + Box( + modifier = Modifier.padding(horizontal = 4.dp) + ) { + icon() + } + + Spacer(Modifier.width(12.dp)) + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + + content?.invoke() + } + + if (trailingContent != null) { + Spacer(Modifier.width(12.dp)) + + trailingContent() + } + } +} + +/** + * A composable function to display a switcher preference. + * + * @param modifier The modifier for customizing the appearance and behavior of the switcher preference. + * @param title The title of the switcher preference. + * @param description An optional description of the switcher preference. + * @param icon An optional icon to display with the switcher preference. + * @param checked The current state of the switch (checked or unchecked). + * @param onCheckedChange The callback to execute when the switch state changes. + * @param isEnabled A flag indicating whether the switcher preference is enabled or disabled. + */ +@Composable +fun SwitcherPreference( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + icon: (@Composable () -> Unit)? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true +) { + PreferenceEntry( + modifier = modifier, + title = title, + description = description, + icon = icon, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + }, + onClick = { onCheckedChange(!checked) }, + isEnabled = isEnabled + ) +} + +/** + * A composable function to display a property preference. + * + * @param modifier The modifier for customizing the appearance and behavior of the property preference. + * @param title The title of the property preference. + * @param description An optional description of the property preference. + * @param icon An optional icon to display with the property preference. + * @param currentValue The current value of the property. + * @param onClick The callback to execute when the property preference is clicked. + * @param isEnabled A flag indicating whether the property preference is enabled or disabled. + */ +@Composable +fun PropertyPreference( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + icon: (@Composable () -> Unit)? = null, + currentValue: String, + onClick: () -> Unit, + isEnabled: Boolean = true +) { + PreferenceEntry( + modifier = modifier, + title = title, + description = description, + icon = icon, + trailingContent = { + Text( + text = currentValue, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 16.dp) + ) + }, + onClick = { onClick() }, + isEnabled = isEnabled + ) +} + +@Preview +@Composable +fun PreferencesPreview() { + Surface { + Column { + PreferenceGroupTitle( + title = "Group title" + ) + + SwitcherPreference( + title = "First Switcher title", + checked = true, + onCheckedChange = {} + ) + + SwitcherPreference( + title = "Second Switcher title", + description = "Second Switcher description", + checked = false, + onCheckedChange = {} + ) + + PropertyPreference( + title = "Property title", + currentValue = "Value", + onClick = {} + ) + } + } +} diff --git a/components/src/main/java/dev/medzik/android/components/Remember.kt b/components/src/main/java/dev/medzik/android/components/Remember.kt new file mode 100644 index 00000000..e46282d4 --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/Remember.kt @@ -0,0 +1,14 @@ +package dev.medzik.android.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +@Composable +fun rememberMutable(value: T) = remember { mutableStateOf(value) } + +@Composable +fun rememberMutableString(value: String = "") = rememberMutable(value) + +@Composable +fun rememberMutableBoolean(value: Boolean = false) = rememberMutable(value) diff --git a/components/src/main/java/dev/medzik/android/components/Sheet.kt b/components/src/main/java/dev/medzik/android/components/Sheet.kt new file mode 100644 index 00000000..ec6e0b7b --- /dev/null +++ b/components/src/main/java/dev/medzik/android/components/Sheet.kt @@ -0,0 +1,123 @@ +@file:Suppress("unused") + +package dev.medzik.android.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rememberBottomSheetState(): BottomSheetState { + val sheetState = SheetState(skipPartiallyExpanded = true) // , density = LocalDensity.current) + return remember { BottomSheetState(sheetState) } +} + +/** A visibility controller for a bottom sheet */ +@OptIn(ExperimentalMaterial3Api::class) +class BottomSheetState constructor( + internal val sheetState: SheetState +) { + internal var expanded by mutableStateOf(false) + + /** Show the bottom sheet. */ + suspend fun hide() { + sheetState.hide() + expanded = false + } + + /** Show the bottom sheet. */ + fun show() { + expanded = true + } +} + +/** + * A composable function for displaying a basic bottom sheet. + * @param state the state that controls visibility of the bottom sheet + * @param navigationBarPadding determines whether the bottom sheet should have navigation bar padding + * @param content the content of the bottom sheet to display + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseBottomSheet( + state: BottomSheetState, + navigationBarPadding: Boolean = false, + content: @Composable (ColumnScope.() -> Unit) +) { + val scope = rememberCoroutineScope() + + if (state.expanded) { + ModalBottomSheet( + tonalElevation = ElevationTokens.Level2, + onDismissRequest = { + scope.launch { state.hide() } + }, + sheetState = state.sheetState + ) { + content() + + if (navigationBarPadding) { + Spacer( + modifier = Modifier.navigationBarsPadding() + ) + } + } + } +} + +/** + * A composable function for displaying a picker bottom sheet with a list of items. + * @param state the state that controls visibility of the bottom sheet + * @param items the list of items to display in the bottom sheet + * @param onSelected a callback function invoked when the item is selected + * @param navigationBarPadding determines whether the bottom sheet should have navigation bar padding + * @param content composable lambda that defines the visual representation of each item in the picker + */ +@Composable +fun PickerBottomSheet( + state: BottomSheetState, + items: List, + onSelected: (T) -> Unit, + navigationBarPadding: Boolean = false, + content: @Composable (T) -> Unit +) { + val scope = rememberCoroutineScope() + + BaseBottomSheet( + state, + navigationBarPadding + ) { + Column { + items.forEach { item -> + Box( + modifier = + Modifier + .clickable { + scope.launch { state.hide() } + onSelected(item) + } + .padding(horizontal = 24.dp), + ) { + content(item) + } + } + } + } +} diff --git a/components/src/test/java/dev/medzik/android/components/NavigationTests.kt b/components/src/test/java/dev/medzik/android/components/NavigationTests.kt new file mode 100644 index 00000000..fc0416c9 --- /dev/null +++ b/components/src/test/java/dev/medzik/android/components/NavigationTests.kt @@ -0,0 +1,34 @@ +package dev.medzik.android.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +enum class Argument : NavArgument { + Test, + Name +} + +enum class Screen(override val args: Array? = null) : NavScreen { + Home, + Example(arrayOf(Argument.Test, Argument.Name)) +} + +class NavigationTests { + @Test + fun testGetRoute() { + assertEquals("home", Screen.Home.getRoute()) + assertEquals("example/{test}/{name}", Screen.Example.getRoute()) + } + + @Test + fun testFillRoute() { + assertEquals("home", Screen.Home.fill()) + + val filledExampleScreen = + Screen.Example.fill( + Argument.Test to "test", + Argument.Name to "example_name" + ) + assertEquals("example/test/example_name", filledExampleScreen) + } +} diff --git a/crypto/.gitignore b/crypto/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/crypto/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/crypto/build.gradle.kts b/crypto/build.gradle.kts new file mode 100644 index 00000000..efcff9b1 --- /dev/null +++ b/crypto/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "dev.medzik.android.crypto" + compileSdk = libs.versions.android.sdk.compile.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.sdk.min.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + buildConfig = false + } +} + +dependencies { + implementation(libs.androidx.datastore.preferences) + implementation(libs.medzik.libcrypto) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/crypto/src/androidTest/java/dev/medzik/android/crypto/DataStoreTests.kt b/crypto/src/androidTest/java/dev/medzik/android/crypto/DataStoreTests.kt new file mode 100644 index 00000000..454da4f9 --- /dev/null +++ b/crypto/src/androidTest/java/dev/medzik/android/crypto/DataStoreTests.kt @@ -0,0 +1,58 @@ +package dev.medzik.android.crypto + +import android.content.Context +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.medzik.android.crypto.DataStore.delete +import dev.medzik.android.crypto.DataStore.deleteEncrypted +import dev.medzik.android.crypto.DataStore.read +import dev.medzik.android.crypto.DataStore.readEncrypted +import dev.medzik.android.crypto.DataStore.write +import dev.medzik.android.crypto.DataStore.writeEncrypted +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +private val Context.dataStore by preferencesDataStore(name = "crypto_datastore_tests") + +@RunWith(AndroidJUnit4::class) +class DataStoreTests { + enum class KeyAlias : KeyStoreAlias { + TEST_DATASTORE_ENCRYPTED + } + + private val testKey = stringPreferencesKey("test_key") + + @Test + fun testDataStore() = + runBlocking { + val value = "Hello World!" + + val context = InstrumentationRegistry.getInstrumentation().context + + context.dataStore.write(testKey, value) + assertEquals(value, context.dataStore.read(testKey)) + + context.dataStore.delete(testKey) + assertEquals(null, context.dataStore.read(testKey)) + } + + private val preferenceKeyEnc = "test_enc_key" + + @Test + fun testEncryptedDataStore() = + runBlocking { + val value = "Hello World!" + + val context = InstrumentationRegistry.getInstrumentation().context + + context.dataStore.writeEncrypted(KeyAlias.TEST_DATASTORE_ENCRYPTED, preferenceKeyEnc, value.toByteArray()) + assertEquals(value, String(context.dataStore.readEncrypted(KeyAlias.TEST_DATASTORE_ENCRYPTED, preferenceKeyEnc)!!)) + + context.dataStore.deleteEncrypted(preferenceKeyEnc) + assertEquals(null, context.dataStore.readEncrypted(KeyAlias.TEST_DATASTORE_ENCRYPTED, preferenceKeyEnc)) + } +} diff --git a/crypto/src/androidTest/java/dev/medzik/android/crypto/KeyStoreTests.kt b/crypto/src/androidTest/java/dev/medzik/android/crypto/KeyStoreTests.kt new file mode 100644 index 00000000..d706b985 --- /dev/null +++ b/crypto/src/androidTest/java/dev/medzik/android/crypto/KeyStoreTests.kt @@ -0,0 +1,29 @@ +package dev.medzik.android.crypto + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.medzik.libcrypto.Hex +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KeyStoreTests { + enum class KeyAlias : KeyStoreAlias { + TEST_KEY + } + + @Test + fun testEncryptDecrypt() { + val clearText = "Hello World!" + + // encrypt + val cipherEnc = KeyStore.initForEncryption(KeyAlias.TEST_KEY, false) + val encryptedData = KeyStore.encrypt(cipherEnc, clearText.toByteArray()) + + // decrypt + val cipherDec = KeyStore.initForDecryption(Hex.decode(encryptedData.initializationVector), KeyAlias.TEST_KEY, false) + val decryptedBytes = KeyStore.decrypt(cipherDec, encryptedData.cipherText) + + assertEquals(clearText, String(decryptedBytes)) + } +} diff --git a/crypto/src/main/java/dev/medzik/android/crypto/DataStore.kt b/crypto/src/main/java/dev/medzik/android/crypto/DataStore.kt new file mode 100644 index 00000000..28376372 --- /dev/null +++ b/crypto/src/main/java/dev/medzik/android/crypto/DataStore.kt @@ -0,0 +1,100 @@ +package dev.medzik.android.crypto + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.medzik.libcrypto.Hex +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** Android DataStore utility class for storing data */ +object DataStore { + /** + * Reads the key from KeyStore. + * @param preferenceKey key to read from KeyStore + * @return the value of the key + */ + suspend inline fun DataStore.read(preferenceKey: Preferences.Key): T? { + return data.map { it[preferenceKey] }.first() + } + + /** + * Writes the key to KeyStore. + * @param preferenceKey key to write to KeyStore + * @param value the value to write + */ + suspend inline fun DataStore.write( + preferenceKey: Preferences.Key, + value: T + ) { + edit { it[preferenceKey] = value } + } + + /** + * Deletes the key from KeyStore. + * @param preferenceKey key to delete from KeyStore + */ + suspend inline fun DataStore.delete(preferenceKey: Preferences.Key) { + edit { it.remove(preferenceKey) } + } + + /** + * Reads the encrypted key from KeyStore. + * @param keyStoreAlias secret key alias in Android KeyStore to decrypt the value + * @param preferenceKey key to read from KeyStore + * @return the decrypted value of the key + */ + suspend fun DataStore.readEncrypted( + keyStoreAlias: KeyStoreAlias, + preferenceKey: String + ): ByteArray? { + val cipherTextStore = stringPreferencesKey("$preferenceKey/encrypted") + + // read cipher text from datastore + val cipherTextWithIV = read(cipherTextStore) ?: return null + + // initialization vector length in hex string + val ivLength = 12 * 2 + + // extract IV and Cipher Text from hex string + val iv = cipherTextWithIV.substring(0, ivLength) + val cipherText = cipherTextWithIV.substring(ivLength) + + // decrypt cipher text + val cipher = KeyStore.initForDecryption(Hex.decode(iv), keyStoreAlias, false) + return KeyStore.decrypt(cipher, cipherText) + } + + /** + * Writes the encrypted key from KeyStore. + * @param keyStoreAlias secret key alias in Android KeyStore to encrypt the value + * @param preferenceKey key to write KeyStore + * @param value value to encrypt and write + * @return the value of the key + */ + suspend fun DataStore.writeEncrypted( + keyStoreAlias: KeyStoreAlias, + preferenceKey: String, + value: ByteArray + ) { + val cipherTextStore = stringPreferencesKey("$preferenceKey/encrypted") + + // encrypt value + val cipher = KeyStore.initForEncryption(keyStoreAlias, false) + val cipherData = KeyStore.encrypt(cipher, value) + + // write encrypted value to datastore + val cipherText = cipherData.initializationVector + cipherData.cipherText + write(cipherTextStore, cipherText) + } + + /** + * Deletes the encrypted key from KeyStore. + * @param preferenceKey key to delete from KeyStore + */ + suspend fun DataStore.deleteEncrypted(preferenceKey: String) { + val cipherTextStore = stringPreferencesKey("$preferenceKey/encrypted") + delete(cipherTextStore) + } +} diff --git a/crypto/src/main/java/dev/medzik/android/crypto/KeyStore.kt b/crypto/src/main/java/dev/medzik/android/crypto/KeyStore.kt new file mode 100644 index 00000000..17b6227a --- /dev/null +++ b/crypto/src/main/java/dev/medzik/android/crypto/KeyStore.kt @@ -0,0 +1,133 @@ +package dev.medzik.android.crypto + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import dev.medzik.libcrypto.Hex +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** A keystore alias interface that represents an alias for keystore operations. */ +interface KeyStoreAlias { + val name: String +} + +/** Android KeyStore utility class for encrypting and decrypting data. */ +object KeyStore { + private const val AES_MODE = "AES/GCM/NoPadding" + + /** + * Initializes a new Cipher for encryption. + * @param alias secret key alias in Android KeyStore + * @param deviceAuthentication whether to require user authentication to the secret key (e.g., using a biometric fingerprint) + * @return initialized Cipher for encryption + */ + fun initForEncryption( + alias: KeyStoreAlias, + deviceAuthentication: Boolean + ): Cipher { + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, getOrGenerateSecretKey(alias.name, deviceAuthentication)) + return cipher + } + + /** + * Initializes a new Cipher for decryption. + * @param alias secret key alias in Android KeyStore + * @param deviceAuthentication whether to require user authentication to the secret key (e.g., using a biometric fingerprint) + * @return initialized Cipher for decryption + */ + fun initForDecryption( + initializationVector: ByteArray, + alias: KeyStoreAlias, + deviceAuthentication: Boolean + ): Cipher { + val cipher = Cipher.getInstance(AES_MODE) + cipher.init( + Cipher.DECRYPT_MODE, + getOrGenerateSecretKey(alias.name, deviceAuthentication), + GCMParameterSpec(128, initializationVector) + ) + return cipher + } + + /** + * Encrypts the given clear bytes with specified cipher. + * @param cipher cipher to use for encryption + * @param clearBytes clear bytes to encrypt + * @return cipher text and initialization vector + */ + fun encrypt( + cipher: Cipher, + clearBytes: ByteArray + ): CipherText { + return CipherText( + cipherText = Hex.encode(cipher.doFinal(clearBytes)), + initializationVector = Hex.encode(cipher.iv) + ) + } + + /** + * Decrypts the given cipher text with specified cipher. + * @param cipher cipher to use for decryption + * @param cipherText cipher text to decrypt + * @return clear bytes (decrypted data) + */ + @Throws(Exception::class) + fun decrypt( + cipher: Cipher, + cipherText: String + ): ByteArray { + return cipher.doFinal(Hex.decode(cipherText)) + } + + /** Gets the secret key if it exists, otherwise generates a new secret key. */ + private fun getOrGenerateSecretKey( + alias: String, + deviceAuthentication: Boolean + ): SecretKey { + return if (secretKeyExists(alias)) { + getKeyStore().getKey(alias, null) as SecretKey + } else { + generateSecretKey(alias, deviceAuthentication) + } + } + + /** Checks if the given alias of a secret key exists. */ + private fun secretKeyExists(alias: String): Boolean { + return getKeyStore().containsAlias(alias) + } + + /** Generates a new secret key */ + private fun generateSecretKey( + alias: String, + requireAuthentication: Boolean + ): SecretKey { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + + val keyGenParameterSpec = + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setUserAuthenticationRequired(requireAuthentication) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + + keyGenerator.init(keyGenParameterSpec) + return keyGenerator.generateKey() + } + + /** Returns the Android KeyStore */ + private fun getKeyStore(): KeyStore { + return KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + + data class CipherText( + val cipherText: String, + val initializationVector: String + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7caefc5..423217e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,19 +9,21 @@ androidx-biometric = "1.2.0-alpha05" androidx-compose-compiler = "1.5.3" androidx-core = "1.12.0" androidx-datastore = "1.1.0-alpha05" -androidx-lifecycle-runtime-compose = "2.7.0-alpha02" -androidx-material-icons-extended = "1.6.0-alpha07" -androidx-material3 = "1.2.0-alpha09" +androidx-espresso-core = "3.5.1" +androidx-junit = "1.1.5" +androidx-lifecycle-runtime-compose = "2.7.0-alpha03" +androidx-material-icons-extended = "1.5.4" +androidx-material3 = "1.1.2" androidx-navigation-compose = "2.7.4" -androidx-room = "2.5.2" +androidx-room = "2.6.0" +androidx-ui = "1.5.4" coil-compose = "2.4.0" commons-codec = "1.16.0" google-accompanist = "0.32.0" google-material = "1.10.0" -jakewharton-process-phoenix = "2.1.2" kotlinx-coroutines-android = "1.7.3" librepass-client = "0.1.0-SNAPSHOT-10" -medzik-android-utils = "1.2.0" +medzik-libcrypto = "1.1.0" process-phoenix = "2.1.2" # Plugins android-application = "8.1.2" @@ -29,23 +31,26 @@ google-ksp = "1.9.10-1.0.13" kotlin-android = "1.9.10" [libraries] -androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidx-annotation" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "androidx-biometric" } -androidx-compose-compiler = { group = "androidx.compose.compiler", name = "compiler", version.ref = "androidx-compose-compiler" } -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } +androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "androidx-compose-compiler" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso-core" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-material-icons-extended" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-material3" } +androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation-compose" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } -androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-ui" } +androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics", version.ref = "androidx-ui" } +androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-ui" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-ui" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-ui" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-ui" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" } google-accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "google-accompanist" } @@ -54,10 +59,8 @@ google-accompanist-systemuicontroller = { module = "com.google.accompanist:accom google-material = { module = "com.google.android.material:material", version.ref = "google-material" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } librepass-client = { module = "dev.medzik.librepass:client", version.ref = "librepass-client" } -medzik-android-components = { module = "dev.medzik.android:components", version.ref = "medzik-android-utils" } -medzik-android-crypto = { module = "dev.medzik.android:crypto", version.ref = "medzik-android-utils" } -medzik-android-utils = { module = "dev.medzik.android:utils", version.ref = "medzik-android-utils" } -process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "jakewharton-process-phoenix" } +medzik-libcrypto = { module = "dev.medzik:libcrypto", version.ref = "medzik-libcrypto" } +process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "process-phoenix" } [plugins] android-application = { id = "com.android.application", version.ref = "android-application" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7b31a023..fe015cd2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,3 +16,7 @@ dependencyResolutionManagement { rootProject.name = "LibrePass" include(":app") + +include(":components") +include(":crypto") +include(":utils") diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/utils/build.gradle.kts b/utils/build.gradle.kts new file mode 100644 index 00000000..56947aab --- /dev/null +++ b/utils/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "dev.medzik.android.utils" + compileSdk = libs.versions.android.sdk.compile.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.sdk.min.get().toInt() + +// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + buildConfig = false + } +} + +dependencies { + compileOnly(libs.androidx.annotation) + compileOnly(libs.kotlinx.coroutines.android) + + debugImplementation(libs.androidx.annotation) + debugImplementation(libs.kotlinx.coroutines.android) + +// androidTestImplementation(libs.androidx.junit) +// androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/utils/src/main/java/dev/medzik/android/utils/Coroutines.kt b/utils/src/main/java/dev/medzik/android/utils/Coroutines.kt new file mode 100644 index 00000000..4432bff5 --- /dev/null +++ b/utils/src/main/java/dev/medzik/android/utils/Coroutines.kt @@ -0,0 +1,11 @@ +package dev.medzik.android.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch + +/** + * Runs the provided code block on the main user interface (UI) thread using Kotlin Coroutines. + * @param block The code block containing operations to be executed on the UI thread. + */ +fun runOnUiThread(block: suspend () -> Unit) = MainScope().launch(Dispatchers.Main) { block() } diff --git a/utils/src/main/java/dev/medzik/android/utils/Toast.kt b/utils/src/main/java/dev/medzik/android/utils/Toast.kt new file mode 100644 index 00000000..3985a697 --- /dev/null +++ b/utils/src/main/java/dev/medzik/android/utils/Toast.kt @@ -0,0 +1,20 @@ +@file:Suppress("UNUSED") + +package dev.medzik.android.utils + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes + +/** Shows the toast dialog. */ +fun Context.showToast(text: String) = + runOnUiThread { + Toast.makeText(this, text, Toast.LENGTH_LONG).show() + } + +/** Shows the toast dialog. */ +fun Context.showToast( + @StringRes resId: Int +) = runOnUiThread { + Toast.makeText(this, resId, Toast.LENGTH_LONG).show() +}