Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
.externalNativeBuild
/apk/
/app/release/
.kotlin/
23 changes: 23 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.detekt)
}

detekt {
toolVersion = libs.versions.detekt.get()
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
buildUponDefaultConfig = true
allRules = false
ignoreFailures = false
basePath = rootProject.projectDir
}

tasks.withType(dev.detekt.gradle.Detekt).configureEach {
exclude("**/build/**")
exclude("**/generated/**")
exclude("**/ksp/**")

reports {
checkstyle.required.set(true)
html.required.set(true)
markdown.required.set(true)
sarif.required.set(true)
}
}

def Properties versionProps = new Properties()
Expand Down
64 changes: 38 additions & 26 deletions app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,48 @@ class ConverterEngine @Inject constructor() {

suspend fun convert(route: AppRoute, value: String, fromInput: Boolean, mode: Int): String =
when (route) {
UnicodeRoute -> if (fromInput) {
value.map { "\\u${it.code.toString(16).padStart(4, '0')}" }.joinToString("")
} else Decoder.decodeUnicode(value)

is Base64Route -> {
val flags = base64Flags[mode.coerceIn(base64Flags.indices)]
if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64(
value,
flags
)
}

is HexRoute -> if (mode == 1) {
if (fromInput) Decoder.intToHex(value)
else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X"))
} else if (fromInput) {
Decoder.toHexString(value)
} else Decoder.decodeHexString(value.removePrefix("0x"))

is TextToolRoute -> when (route.tool) {
TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString()
TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString()
TextTool.XML -> Decoder.unescapeXml(value)
TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong())
}

UnicodeRoute -> convertUnicode(value, fromInput)
is Base64Route -> convertBase64(value, fromInput, mode)
is HexRoute -> convertHex(value, fromInput, mode)
is TextToolRoute -> convertTextTool(value, route.tool)
else -> value
}

private suspend fun convertUnicode(value: String, fromInput: Boolean): String =
if (fromInput) {
value.map { "\\u${it.code.toString(HEX_RADIX).padStart(UNICODE_HEX_DIGITS, '0')}" }
.joinToString("")
} else {
Decoder.decodeUnicode(value)
}

private suspend fun convertBase64(value: String, fromInput: Boolean, mode: Int): String {
val flags = base64Flags[mode.coerceIn(base64Flags.indices)]
return if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64(value, flags)
}

private suspend fun convertHex(value: String, fromInput: Boolean, mode: Int): String =
if (mode == HEX_NUMBER_MODE) {
if (fromInput) Decoder.intToHex(value)
else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X"))
} else if (fromInput) {
Decoder.toHexString(value)
} else {
Decoder.decodeHexString(value.removePrefix("0x"))
}

private suspend fun convertTextTool(value: String, tool: TextTool): String = when (tool) {
TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString()
TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString()
TextTool.XML -> Decoder.unescapeXml(value)
TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong())
}

private companion object {
const val HEX_RADIX = 16
const val HEX_NUMBER_MODE = 1
const val UNICODE_HEX_DIGITS = 4

val base64Flags = intArrayOf(
Base64.NO_WRAP, Base64.NO_CLOSE, Base64.NO_PADDING,
Base64.URL_SAFE, Base64.CRLF, Base64.DEFAULT
Expand Down
35 changes: 25 additions & 10 deletions app/src/main/java/ru/svolf/convertx/data/SettingsRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException

private const val DefaultFontSize = 16
private const val MinFontSize = 12
private const val MaxFontSize = 32
private const val DefaultBase64Mode = 0
private const val MaxBase64Mode = 5
private const val DefaultHexMode = 0
private const val MaxHexMode = 1

enum class ThemeMode { LIGHT, DARK }

data class SettingsState(
val theme: ThemeMode = ThemeMode.LIGHT,
val fontSize: Int = 16,
val fontSize: Int = DefaultFontSize,
val twiceBackToExit: Boolean = true,
val base64Mode: Int = 0,
val hexMode: Int = 0
val base64Mode: Int = DefaultBase64Mode,
val hexMode: Int = DefaultHexMode
)

class SettingsRepository(private val dataStore: DataStore<Preferences>) {
Expand All @@ -38,26 +46,33 @@ class SettingsRepository(private val dataStore: DataStore<Preferences>) {
}
.map { preferences ->
SettingsState(
theme = if (preferences[Keys.theme]?.toIntOrNull() == ThemeMode.DARK.ordinal) ThemeMode.DARK else ThemeMode.LIGHT,
theme = if (preferences[Keys.theme]?.toIntOrNull() == ThemeMode.DARK.ordinal) {
ThemeMode.DARK
} else {
ThemeMode.LIGHT
},
fontSize = (preferences[Keys.appFontSize] ?: preferences[Keys.oldFontSize]
?: 16).coerceIn(12, 32),
?: DefaultFontSize).coerceIn(MinFontSize, MaxFontSize),
twiceBackToExit = preferences[Keys.twiceBack] ?: true,
base64Mode = (preferences[Keys.base64Mode] ?: 0).coerceIn(0, 5),
hexMode = (preferences[Keys.hexMode] ?: 0).coerceIn(0, 1)
base64Mode = (preferences[Keys.base64Mode] ?: DefaultBase64Mode)
.coerceIn(DefaultBase64Mode, MaxBase64Mode),
hexMode = (preferences[Keys.hexMode] ?: DefaultHexMode)
.coerceIn(DefaultHexMode, MaxHexMode)
)
}

suspend fun setTheme(theme: ThemeMode) =
dataStore.edit { it[Keys.theme] = theme.ordinal.toString() }

suspend fun setFontSize(size: Int) =
dataStore.edit { it[Keys.appFontSize] = size.coerceIn(12, 32) }
dataStore.edit { it[Keys.appFontSize] = size.coerceIn(MinFontSize, MaxFontSize) }

suspend fun setTwiceBackToExit(enabled: Boolean) =
dataStore.edit { it[Keys.twiceBack] = enabled }

suspend fun setBase64Mode(mode: Int) =
dataStore.edit { it[Keys.base64Mode] = mode.coerceIn(0, 5) }
dataStore.edit { it[Keys.base64Mode] = mode.coerceIn(DefaultBase64Mode, MaxBase64Mode) }

suspend fun setHexMode(mode: Int) = dataStore.edit { it[Keys.hexMode] = mode.coerceIn(0, 1) }
suspend fun setHexMode(mode: Int) =
dataStore.edit { it[Keys.hexMode] = mode.coerceIn(DefaultHexMode, MaxHexMode) }
}
1 change: 1 addition & 0 deletions app/src/main/java/ru/svolf/convertx/di/AppComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface AppComponent {
fun paletteViewModelFactory(): PaletteViewModelFactory
fun settingsViewModelFactory(): SettingsViewModelFactory
fun regexViewModelFactory(): RegexViewModelFactory
fun textToolViewModelFactory(): ru.svolf.convertx.presentation.viewmodel.TextToolViewModel.Factory

@Component.Factory
interface Factory {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package ru.svolf.convertx.presentation.compose

import android.content.ClipData
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.AssistChip
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.svolf.convertx.R
import ru.svolf.convertx.presentation.navigation.AppRoute
import ru.svolf.convertx.presentation.navigation.Base64Route
import ru.svolf.convertx.presentation.navigation.HexRoute
import ru.svolf.convertx.presentation.navigation.TextTool
import ru.svolf.convertx.presentation.navigation.TextToolRoute
import ru.svolf.convertx.presentation.navigation.UnicodeRoute

internal enum class ConverterField { INPUT, OUTPUT }

@Composable
internal fun inputHint(route: AppRoute): String = when (route) {
UnicodeRoute, is Base64Route -> stringResource(R.string.hint_utf)
is HexRoute -> stringResource(R.string.hint_string)
is TextToolRoute -> when (route.tool) {
TextTool.ADLER32 -> stringResource(R.string.hint_adler32)
TextTool.CRC32 -> stringResource(R.string.hint_crc)
TextTool.XML -> stringResource(R.string.hint_encoded_xml)
TextTool.TIMESTAMP -> stringResource(R.string.hint_timestamp)
}

else -> stringResource(R.string.hint_string)
}

@Composable
internal fun outputHint(route: AppRoute): String = when (route) {
UnicodeRoute -> stringResource(R.string.hint_unicode)
is Base64Route -> stringResource(R.string.hint_base64)
is HexRoute -> stringResource(R.string.hint_hex)
else -> stringResource(R.string.copy2clipboard)
}

@Composable
internal fun ConverterControls(
route: AppRoute,
selectedMode: Int,
activeField: ConverterField,
onFieldToggle: () -> Unit,
onModeSelected: (Int) -> Unit
) {
Box(Modifier.fillMaxWidth().height(48.dp)) {
DirectionSwitcher(activeField, onFieldToggle, Modifier.align(Alignment.Center))
if (route is Base64Route || route is ru.svolf.convertx.presentation.navigation.HexRoute) {
ModeMenu(route, selectedMode, onModeSelected, Modifier.align(Alignment.CenterStart))
}
}
}

@Composable
private fun DirectionSwitcher(
activeField: ConverterField,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
val primary = MaterialTheme.colorScheme.primary
val muted = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
val downTint by animateColorAsState(
targetValue = if (activeField == ConverterField.INPUT) primary else muted,
animationSpec = tween(durationMillis = 220),
label = "downArrowTint"
)
val upTint by animateColorAsState(
targetValue = if (activeField == ConverterField.OUTPUT) primary else muted,
animationSpec = tween(durationMillis = 220),
label = "upArrowTint"
)
Column(
modifier = modifier.height(46.dp).clickable(onClick = onToggle),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(Modifier.width(2.dp).weight(1f).background(primary.copy(alpha = 0.65f)))
Row(horizontalArrangement = Arrangement.Center) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = downTint,
modifier = Modifier.size(20.dp)
)
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = null,
tint = upTint,
modifier = Modifier.size(20.dp)
)
}
Box(Modifier.width(2.dp).weight(1f).background(primary.copy(alpha = 0.65f)))
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModeMenu(
route: AppRoute,
selected: Int,
onSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val options = if (route is Base64Route) {
listOf("No wrap", "No close", "No padding", "URL safe", "CRLF", "Default")
} else {
listOf("String", "Integer")
}
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded, { expanded = !expanded }, modifier) {
AssistChip(
onClick = { expanded = true },
label = { Text(options.getOrElse(selected) { options.first() }) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor()
)
DropdownMenu(expanded, { expanded = false }) {
options.forEachIndexed { index, option ->
DropdownMenuItem(
text = { Text(option) },
onClick = { expanded = false; onSelected(index) },
leadingIcon = if (index == selected) {
{ Icon(Icons.Default.Check, null) }
} else {
null
}
)
}
}
}
}

@Composable
internal fun ClearButton(onClick: () -> Unit) {
TextButton(modifier = Modifier.height(48.dp), onClick = onClick) {
Icon(Icons.Default.Clear, stringResource(R.string.clear))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.clear))
}
}

@Composable
internal fun CopyButton(value: String, message: String, snackbar: SnackbarHostState) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
TextButton(
modifier = Modifier.height(48.dp),
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, value)))
snackbar.showSnackbar(message)
}
}
) {
Icon(Icons.Default.ContentCopy, stringResource(R.string.copy2clipboard))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.copy2clipboard))
}
}
Loading