chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+25
View File
@@ -0,0 +1,25 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
plugins {
alias(libs.plugins.image.toolbox.library)
alias(libs.plugins.image.toolbox.feature)
alias(libs.plugins.image.toolbox.hilt)
alias(libs.plugins.image.toolbox.compose)
}
android.namespace = "com.t8rin.imagetoolbox.feature.cipher"
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<manifest>
</manifest>
@@ -0,0 +1,237 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
@file:Suppress("PrivatePropertyName", "SpellCheckingInspection")
package com.t8rin.imagetoolbox.feature.cipher.data
import com.t8rin.imagetoolbox.core.data.saving.io.StringReadable
import com.t8rin.imagetoolbox.core.data.utils.computeBytesFromReadable
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.model.CipherType
import com.t8rin.imagetoolbox.core.domain.model.HashingType
import com.t8rin.imagetoolbox.feature.cipher.domain.CryptographyManager
import com.t8rin.imagetoolbox.feature.cipher.domain.WrongKeyException
import kotlinx.coroutines.withContext
import java.security.Key
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.PBEParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
internal class AndroidCryptographyManager @Inject constructor(
private val dispatchersHolder: DispatchersHolder
) : CryptographyManager, DispatchersHolder by dispatchersHolder {
private val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
private fun createKey(
password: String,
type: CipherType
): Key = if ("PBE" in type.name) {
SecretKeyFactory
.getInstance(type.cipher)
.generateSecret(
PBEKeySpec(password.toCharArray())
)
} else {
hashingType(type.name)
.computeBytesFromReadable(StringReadable(password))
.let { key ->
SecretKeySpec(key, type.cipher)
}
}
override fun generateRandomString(length: Int): String {
val sr = SecureRandom()
return buildString {
repeat(length) {
append(CHARS[sr.nextInt(CHARS.length)])
}
}
}
override suspend fun decrypt(
data: ByteArray,
key: String,
type: CipherType
): ByteArray = withContext(defaultDispatcher) {
val cipher = type.toCipher(
keySpec = createKey(
password = key,
type = type
),
isEncrypt = false
)
cipher.doOrThrow(data)
}
override suspend fun encrypt(
data: ByteArray,
key: String,
type: CipherType
): ByteArray = withContext(defaultDispatcher) {
val cipher = type.toCipher(
keySpec = createKey(
password = key,
type = type
),
isEncrypt = true
)
cipher.doOrThrow(data)
}
private fun Cipher.init(
keySpec: Key,
isEncrypt: Boolean,
type: CipherType
) {
val mode = if (isEncrypt) Cipher.ENCRYPT_MODE else Cipher.DECRYPT_MODE
try {
val encodedKey = keySpec.encoded
when {
"PBE" in type.name -> {
init(
mode,
keySpec,
PBEParameterSpec(encodedKey, encodedKey.size)
)
}
else -> {
init(
mode,
keySpec,
IvParameterSpec(encodedKey, 0, blockSize)
)
}
}
} catch (_: Throwable) {
runCatching {
init(
mode,
keySpec,
generateIV(ivSize(type.name))
)
}.getOrElse {
init(
mode,
keySpec
)
}
}
}
private fun CipherType.toCipher(
keySpec: Key,
isEncrypt: Boolean
): Cipher = Cipher.getInstance(cipher).apply {
init(
keySpec = keySpec,
isEncrypt = isEncrypt,
type = this@toCipher
)
}
private fun Cipher.doOrThrow(data: ByteArray): ByteArray {
return try {
doFinal(data)
} catch (e: Throwable) {
throw if (e.message?.contains("mac") == true && e.message?.contains("failed") == true) {
WrongKeyException()
} else e
}
}
private fun generateIV(size: Int): IvParameterSpec {
val iv = ByteArray(size)
SecureRandom().nextBytes(iv)
return IvParameterSpec(iv)
}
private fun ivSize(name: String): Int = when {
name == "AESRFC5649WRAP"
|| name == "AESWRAPPAD"
|| name == "AES_128/KWP/NoPadding"
|| name == "AES_192/KWP/NoPadding"
|| name == "AES_256/KWP/NoPadding"
|| name == "ARIAWRAPPAD" -> 4
name == "AESWRAP"
|| name == "AES_128/KW/NoPadding"
|| name == "AES_192/KW/NoPadding"
|| name == "CAMELLIAWRAP"
|| name == "AES_256/KW/NoPadding"
|| name == "ARIAWRAP"
|| name == "CHACHA"
|| "CBC" in name && name != "SEED/CBC"
|| name == "DESEDEWRAP"
|| name == "GRAINV1"
|| name == "SALSA20"
|| name.contains("wrap", true) -> 8
name == "AES/ECB/PKCS7PADDING"
|| name == "AES/GCM-SIV/NOPADDING"
|| name == "AES128/CCM"
|| name == "AES192/CCM"
|| name == "AES256/CCM"
|| "CCM" in name
|| "CHACHA" in name
|| name == "AES_256/GCM-SIV/NOPADDING"
|| name == "AES_256/GCM/NOPADDING"
|| name == "GRAIN128"
|| name == "AES_128/CBC/NOPADDING"
|| name == "AES_128/CBC/PKCS5PADDING"
|| name == "AES_128/GCM/NOPADDING" -> 12
name == "XSALSA20" -> 24
name == "ZUC-256" -> 25
"DSTU7624" in name && "512" in name -> 64
else -> 16
}
private val MD5List = listOf(
"SEED",
"NOEKEON",
"HC128",
"AES_128/CBC/NOPADDING",
"AES_128/CBC/PKCS5PADDING",
"AES_128/GCM/NOPADDING",
"DESEDE",
"GRAIN128",
"SM4",
"TEA",
"ZUC-128",
"AES_128/ECB/NOPADDING",
"AES_128/ECB/PKCS5PADDING",
"AES_128/GCM/NOPADDING"
)
private fun hashingType(name: String): HashingType = when {
MD5List.any { name.contains(it, true) } -> HashingType.MD5
else -> HashingType.SHA_256
}
}
@@ -0,0 +1,30 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.data
import com.t8rin.imagetoolbox.core.domain.saving.RandomStringGenerator
import com.t8rin.imagetoolbox.feature.cipher.domain.CryptographyManager
import javax.inject.Inject
internal class AndroidRandomStringGenerator @Inject constructor(
private val cryptographyManager: CryptographyManager
) : RandomStringGenerator {
override fun generate(length: Int): String = cryptographyManager.generateRandomString(length)
}
@@ -0,0 +1,47 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.di
import com.t8rin.imagetoolbox.core.domain.saving.RandomStringGenerator
import com.t8rin.imagetoolbox.feature.cipher.data.AndroidCryptographyManager
import com.t8rin.imagetoolbox.feature.cipher.data.AndroidRandomStringGenerator
import com.t8rin.imagetoolbox.feature.cipher.domain.CryptographyManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface CipherModule {
@Singleton
@Binds
fun cryptographyManager(
impl: AndroidCryptographyManager
): CryptographyManager
@Singleton
@Binds
fun provideRandomStringGenerator(
impl: AndroidRandomStringGenerator
): RandomStringGenerator
}
@@ -0,0 +1,40 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2024 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.domain
import com.t8rin.imagetoolbox.core.domain.model.CipherType
interface CryptographyManager {
fun generateRandomString(
length: Int
): String
suspend fun decrypt(
data: ByteArray,
key: String,
type: CipherType
): ByteArray
suspend fun encrypt(
data: ByteArray,
key: String,
type: CipherType
): ByteArray
}
@@ -0,0 +1,20 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2025 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.domain
internal class WrongKeyException : Throwable()
@@ -0,0 +1,196 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.presentation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.t8rin.imagetoolbox.core.domain.model.CipherType
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.FileOpen
import com.t8rin.imagetoolbox.core.resources.icons.KeyVertical
import com.t8rin.imagetoolbox.core.resources.icons.Lock
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.rememberFilePicker
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.widget.AdaptiveLayoutScreen
import com.t8rin.imagetoolbox.core.ui.widget.buttons.BottomButtonsBlock
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.ExitWithoutSavingDialog
import com.t8rin.imagetoolbox.core.ui.widget.dialogs.LoadingDialog
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedBadge
import com.t8rin.imagetoolbox.core.ui.widget.image.AutoFilePicker
import com.t8rin.imagetoolbox.core.ui.widget.image.FileNotPickedWidget
import com.t8rin.imagetoolbox.core.ui.widget.modifier.scaleOnTap
import com.t8rin.imagetoolbox.core.ui.widget.other.TopAppBarEmoji
import com.t8rin.imagetoolbox.core.ui.widget.text.marquee
import com.t8rin.imagetoolbox.feature.cipher.domain.WrongKeyException
import com.t8rin.imagetoolbox.feature.cipher.presentation.components.CipherControls
import com.t8rin.imagetoolbox.feature.cipher.presentation.components.CipherTipSheet
import com.t8rin.imagetoolbox.feature.cipher.presentation.screenLogic.CipherComponent
@Composable
fun CipherContent(
component: CipherComponent
) {
val showTip = component.showTip
var showExitDialog by rememberSaveable { mutableStateOf(false) }
val canGoBack = component.canGoBack
val key = component.key
val onBack = {
if (!canGoBack) showExitDialog = true
else component.onGoBack()
}
val filePicker = rememberFilePicker(onSuccess = component::setUri)
AutoFilePicker(
onAutoPick = filePicker::pickFile,
isPickedAlready = component.initialUri != null
)
val isPortrait by isPortraitOrientationAsState()
AdaptiveLayoutScreen(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.marquee()
) {
AnimatedContent(
targetState = component.uri to component.isEncrypt,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { (uri, isEncrypt) ->
Text(
if (uri == null) {
stringResource(R.string.cipher)
} else {
listOf(
stringResource(R.string.encryption),
stringResource(R.string.decryption)
)[if (isEncrypt) 0 else 1]
},
textAlign = TextAlign.Center
)
}
EnhancedBadge(
content = {
Text(
text = CipherType.entries.size.toString()
)
},
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary,
modifier = Modifier
.padding(horizontal = 2.dp)
.padding(bottom = 12.dp)
.scaleOnTap {
AppToastHost.showConfetti()
}
)
}
},
onGoBack = onBack,
shouldDisableBackHandler = canGoBack,
topAppBarPersistentActions = {
TopAppBarEmoji()
},
actions = {},
buttons = {
BottomButtonsBlock(
isNoData = component.uri == null,
onSecondaryButtonClick = filePicker::pickFile,
secondaryButtonIcon = Icons.Rounded.FileOpen,
secondaryButtonText = stringResource(R.string.pick_file),
onPrimaryButtonClick = {
component.startCryptography {
if (it is WrongKeyException) {
AppToastHost.showFailureToast(R.string.invalid_password_or_not_encrypted)
} else if (it != null) {
AppToastHost.showFailureToast(
throwable = it
)
}
}
},
primaryButtonIcon = if (component.isEncrypt) {
Icons.Rounded.Lock
} else {
Icons.Rounded.KeyVertical
},
primaryButtonText = if (isPortrait) {
if (component.isEncrypt) {
stringResource(R.string.encrypt)
} else {
stringResource(R.string.decrypt)
}
} else "",
isPrimaryButtonEnabled = key.isNotEmpty(),
actions = {}
)
},
canShowScreenData = component.uri != null,
noDataControls = {
FileNotPickedWidget(onPickFile = filePicker::pickFile)
},
controls = {
CipherControls(component)
},
imagePreview = {},
showImagePreviewAsStickyHeader = false,
placeImagePreview = false,
showActionsInTopAppBar = false,
addHorizontalCutoutPaddingIfNoPreview = component.uri != null,
)
ExitWithoutSavingDialog(
onExit = component.onGoBack,
onDismiss = { showExitDialog = false },
visible = showExitDialog
)
CipherTipSheet(
visible = showTip,
onDismiss = component::hideTip
)
LoadingDialog(
visible = component.isSaving,
onCancelLoading = component::cancelSaving
)
}
@@ -0,0 +1,344 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.imagetoolbox.core.domain.model.CipherType
import com.t8rin.imagetoolbox.core.domain.utils.toInt
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Cancel
import com.t8rin.imagetoolbox.core.resources.icons.CheckCircle
import com.t8rin.imagetoolbox.core.resources.icons.Download
import com.t8rin.imagetoolbox.core.resources.icons.File
import com.t8rin.imagetoolbox.core.resources.icons.HashTag
import com.t8rin.imagetoolbox.core.resources.icons.Help
import com.t8rin.imagetoolbox.core.resources.icons.Share
import com.t8rin.imagetoolbox.core.resources.icons.Shuffle
import com.t8rin.imagetoolbox.core.settings.presentation.provider.LocalSettingsState
import com.t8rin.imagetoolbox.core.ui.theme.Green
import com.t8rin.imagetoolbox.core.ui.theme.outlineVariant
import com.t8rin.imagetoolbox.core.ui.utils.content_pickers.rememberFileCreator
import com.t8rin.imagetoolbox.core.ui.utils.helper.ContextUtils.rememberFilename
import com.t8rin.imagetoolbox.core.ui.utils.helper.ImageUtils.rememberHumanFileSize
import com.t8rin.imagetoolbox.core.ui.utils.helper.isPortraitOrientationAsState
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.DataSelector
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButtonGroup
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedIconButton
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.preferences.PreferenceItem
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
import com.t8rin.imagetoolbox.feature.cipher.presentation.screenLogic.CipherComponent
import kotlin.random.Random
@Composable
internal fun CipherControls(component: CipherComponent) {
val settingsState = LocalSettingsState.current
val isPortrait by isPortraitOrientationAsState()
val saveLauncher = rememberFileCreator(
onSuccess = component::saveCryptographyTo
)
val filename = component.uri?.let {
rememberFilename(it)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (isPortrait) Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier
.container(ShapeDefaults.circle)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
val items = listOf(
stringResource(R.string.encryption),
stringResource(R.string.decryption)
)
EnhancedButtonGroup(
enabled = true,
itemCount = items.size,
selectedIndex = (!component.isEncrypt).toInt(),
onIndexChange = {
component.setIsEncrypt(it == 0)
},
itemContent = {
Text(
text = items[it],
fontSize = 12.sp
)
},
isScrollable = false,
modifier = Modifier.weight(1f)
)
EnhancedIconButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
onClick = component::showTip
) {
Icon(
imageVector = Icons.Outlined.Help,
contentDescription = "Info"
)
}
}
Spacer(Modifier.height(16.dp))
PreferenceItem(
modifier = Modifier,
title = filename ?: stringResource(R.string.something_went_wrong),
onClick = null,
titleFontStyle = LocalTextStyle.current.copy(
lineHeight = 16.sp,
fontSize = 15.sp
),
subtitle = component.uri?.let {
stringResource(
id = R.string.size,
rememberHumanFileSize(it)
)
},
startIcon = Icons.Rounded.File
)
Spacer(Modifier.height(16.dp))
RoundedTextField(
modifier = Modifier
.container(
shape = MaterialTheme.shapes.large,
resultPadding = 8.dp
),
value = component.key,
startIcon = {
EnhancedIconButton(
onClick = {
component.updateKey(component.generateRandomPassword())
},
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = stringResource(R.string.shuffle),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
},
endIcon = {
EnhancedIconButton(
onClick = { component.updateKey("") },
modifier = Modifier.padding(end = 4.dp)
) {
Icon(
imageVector = Icons.Outlined.Cancel,
contentDescription = stringResource(R.string.cancel),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
singleLine = false,
onValueChange = component::updateKey,
label = {
Text(stringResource(R.string.key))
}
)
AnimatedVisibility(visible = component.byteArray != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
.container(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme
.colorScheme
.surfaceContainerHighest,
resultPadding = 0.dp
)
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Rounded.CheckCircle,
contentDescription = null,
tint = Green,
modifier = Modifier
.size(36.dp)
.background(
color = MaterialTheme.colorScheme.surface,
shape = ShapeDefaults.circle
)
.border(
width = settingsState.borderWidth,
color = MaterialTheme.colorScheme.outlineVariant(),
shape = ShapeDefaults.circle
)
.padding(4.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
stringResource(R.string.file_proceed),
fontSize = 17.sp,
fontWeight = FontWeight.Medium
)
}
Text(
text = stringResource(R.string.store_file_desc),
fontSize = 13.sp,
color = LocalContentColor.current.copy(alpha = 0.7f),
lineHeight = 14.sp,
modifier = Modifier.padding(vertical = 16.dp)
)
var name by rememberSaveable(component.byteArray, filename) {
mutableStateOf(
if (component.isEncrypt) {
"enc-"
} else {
"dec-"
} + (filename ?: Random.nextInt())
)
}
RoundedTextField(
modifier = Modifier
.padding(top = 8.dp)
.container(
shape = MaterialTheme.shapes.large,
resultPadding = 8.dp
),
value = name,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
singleLine = false,
onValueChange = { name = it },
label = {
Text(stringResource(R.string.filename))
}
)
Row(
modifier = Modifier
.padding(top = 24.dp)
.fillMaxWidth()
) {
EnhancedButton(
onClick = {
saveLauncher.make(name)
},
modifier = Modifier
.padding(end = 8.dp)
.fillMaxWidth(0.5f)
.height(50.dp),
containerColor = MaterialTheme.colorScheme.secondaryContainer
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Rounded.Download,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
AutoSizeText(
text = stringResource(id = R.string.save),
maxLines = 1
)
}
}
EnhancedButton(
onClick = {
component.byteArray?.let {
component.shareFile(
it = it,
filename = name
)
}
},
modifier = Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.height(50.dp),
containerColor = MaterialTheme.colorScheme.secondaryContainer
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Rounded.Share,
contentDescription = stringResource(
R.string.share
)
)
Spacer(modifier = Modifier.width(8.dp))
AutoSizeText(
text = stringResource(id = R.string.share),
maxLines = 1
)
}
}
}
}
}
Spacer(Modifier.height(24.dp))
DataSelector(
modifier = Modifier,
value = component.cipherType,
containerColor = Color.Unspecified,
spanCount = 5,
selectedItemColor = MaterialTheme.colorScheme.secondary,
onValueChange = component::updateCipherType,
entries = CipherType.entries,
title = stringResource(R.string.algorithms),
titleIcon = Icons.Rounded.HashTag,
itemContentText = {
it.name
},
initialExpanded = true
)
}
}
@@ -0,0 +1,158 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.presentation.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.t8rin.imagetoolbox.core.resources.Icons
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.resources.icons.Extension
import com.t8rin.imagetoolbox.core.resources.icons.File
import com.t8rin.imagetoolbox.core.resources.icons.Functions
import com.t8rin.imagetoolbox.core.resources.icons.Interface
import com.t8rin.imagetoolbox.core.resources.icons.Security
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedButton
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedModalBottomSheet
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.enhancedVerticalScroll
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.modifier.container
import com.t8rin.imagetoolbox.core.ui.widget.text.AutoSizeText
import com.t8rin.imagetoolbox.core.ui.widget.text.TitleItem
@Composable
fun CipherTipSheet(
visible: Boolean,
onDismiss: () -> Unit
) {
EnhancedModalBottomSheet(
sheetContent = {
Box {
Column(
modifier = Modifier
.enhancedVerticalScroll(rememberScrollState())
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Column(
modifier = Modifier
.container(
shape = ShapeDefaults.top
)
.fillMaxWidth()
) {
TitleItem(
text = stringResource(R.string.features),
icon = Icons.Rounded.Functions
)
Text(
text = stringResource(R.string.features_sub),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
Column(
modifier = Modifier
.container(
shape = ShapeDefaults.center
)
.fillMaxWidth()
) {
TitleItem(
text = stringResource(R.string.implementation),
icon = Icons.Filled.Interface
)
Text(
text = stringResource(id = R.string.implementation_sub),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
Column(
modifier = Modifier
.container(
shape = ShapeDefaults.center
)
.fillMaxWidth()
) {
TitleItem(
text = stringResource(R.string.file_size),
icon = Icons.Outlined.File
)
Text(
text = stringResource(id = R.string.file_size_sub),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
Column(
modifier = Modifier
.container(
shape = ShapeDefaults.bottom
)
.fillMaxWidth()
) {
TitleItem(
text = stringResource(R.string.compatibility),
icon = Icons.Outlined.Extension
)
Text(
text = stringResource(id = R.string.compatibility_sub),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
fontSize = 14.sp,
lineHeight = 18.sp
)
}
}
}
},
visible = visible,
onDismiss = {
if (!it) onDismiss()
},
title = {
TitleItem(
text = stringResource(R.string.cipher),
icon = Icons.Rounded.Security
)
},
confirmButton = {
EnhancedButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
onClick = onDismiss
) {
AutoSizeText(stringResource(R.string.close))
}
}
)
}
@@ -0,0 +1,198 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.imagetoolbox.feature.cipher.presentation.screenLogic
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import com.arkivanov.decompose.ComponentContext
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ShareProvider
import com.t8rin.imagetoolbox.core.domain.model.CipherType
import com.t8rin.imagetoolbox.core.domain.saving.FileController
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.domain.utils.smartJob
import com.t8rin.imagetoolbox.core.domain.utils.update
import com.t8rin.imagetoolbox.core.ui.utils.BaseComponent
import com.t8rin.imagetoolbox.core.ui.utils.helper.AppToastHost
import com.t8rin.imagetoolbox.core.ui.utils.state.savable
import com.t8rin.imagetoolbox.core.ui.utils.state.update
import com.t8rin.imagetoolbox.feature.cipher.domain.CryptographyManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
class CipherComponent @AssistedInject internal constructor(
@Assisted componentContext: ComponentContext,
@Assisted val initialUri: Uri?,
@Assisted val onGoBack: () -> Unit,
private val cryptographyManager: CryptographyManager,
private val shareProvider: ShareProvider,
private val fileController: FileController,
dispatchersHolder: DispatchersHolder
) : BaseComponent(dispatchersHolder, componentContext) {
private val _cipherType = fileController.savable(
scope = componentScope,
initial = CipherType.entries.first()
)
val cipherType: CipherType by _cipherType
private val _showTip: MutableState<Boolean> = mutableStateOf(false)
val showTip by _showTip
private val _key: MutableState<String> = mutableStateOf("")
val key by _key
val canGoBack: Boolean
get() = uri == null || (key.isEmpty() && byteArray == null)
fun showTip() = _showTip.update { true }
fun hideTip() = _showTip.update { false }
init {
debounce {
initialUri?.let(::setUri)
}
}
fun updateKey(newKey: String) {
_key.update { newKey }
resetCalculatedData()
}
private val _uri = mutableStateOf<Uri?>(null)
val uri by _uri
private val _isEncrypt = mutableStateOf(true)
val isEncrypt by _isEncrypt
private val _byteArray = mutableStateOf<ByteArray?>(null)
val byteArray by _byteArray
private val _isSaving: MutableState<Boolean> = mutableStateOf(false)
val isSaving by _isSaving
fun setUri(newUri: Uri) {
_uri.value = newUri
resetCalculatedData()
}
private var savingJob: Job? by smartJob {
_isSaving.update { false }
}
fun startCryptography(
onComplete: (Throwable?) -> Unit
) {
savingJob = trackProgress {
_isSaving.value = true
val uri = uri
if (uri == null) {
onComplete(null)
return@trackProgress
}
runSuspendCatching {
_byteArray.update {
fileController.readBytes(uri.toString()).let { file ->
if (isEncrypt) {
cryptographyManager.encrypt(
data = file,
key = key,
type = cipherType
)
} else {
cryptographyManager.decrypt(
data = file,
key = key,
type = cipherType
)
}
}
}
}.exceptionOrNull().let(onComplete)
_isSaving.value = false
}
}
fun updateCipherType(type: CipherType) {
_cipherType.update { type }
resetCalculatedData()
}
fun setIsEncrypt(isEncrypt: Boolean) {
_isEncrypt.value = isEncrypt
resetCalculatedData()
}
fun resetCalculatedData() {
_byteArray.value = null
}
fun saveCryptographyTo(uri: Uri) {
savingJob = trackProgress {
_isSaving.value = true
byteArray?.let { byteArray ->
fileController.writeBytes(
uri = uri.toString(),
block = { it.writeBytes(byteArray) }
).also(::parseFileSaveResult).onSuccess(::registerSave)
}
_isSaving.value = false
}
}
fun generateRandomPassword(): String = cryptographyManager.generateRandomString(18)
fun shareFile(
it: ByteArray,
filename: String,
) {
savingJob = trackProgress {
_isSaving.value = true
shareProvider.shareByteArray(
byteArray = it,
filename = filename,
onComplete = {
_isSaving.value = false
AppToastHost.showConfetti()
}
)
}
}
fun cancelSaving() {
savingJob?.cancel()
savingJob = null
_isSaving.value = false
}
@AssistedFactory
fun interface Factory {
operator fun invoke(
componentContext: ComponentContext,
initialUri: Uri?,
onGoBack: () -> Unit,
): CipherComponent
}
}