apng tools wip

This commit is contained in:
T8RIN
2024-02-28 21:35:11 +03:00
parent 156ad12636
commit 932d9540f1
16 changed files with 1714 additions and 2 deletions

View File

@ -812,4 +812,11 @@
<string name="linear_tilt_shift">Linear Tilt Shift</string>
<string name="tags_to_remove">Tags To Remove</string>
<string name="enhanced_zoom_blur">Enhanced Zoom Blur</string>
<string name="apng_tools">APNG Tools</string>
<string name="apng_tools_sub">Convert images to APNG picture or extract frames from given APNG image</string>
<string name="apng_type_to_image">APNG to images</string>
<string name="apng_type_to_image_sub">Convert APNG file to batch of pictures</string>
<string name="apng_type_to_apng_sub">Convert batch of images to APNG file</string>
<string name="apng_type_to_apng">Images to APNG</string>
<string name="select_apng_image_to_start">Pick APNG image to start</string>
</resources>

View File

@ -42,6 +42,8 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import ru.tech.imageresizershrinker.core.resources.R
import ru.tech.imageresizershrinker.core.ui.icons.material.Apng
import ru.tech.imageresizershrinker.core.ui.icons.material.ApngBox
import ru.tech.imageresizershrinker.core.ui.icons.material.CropSmall
import ru.tech.imageresizershrinker.core.ui.icons.material.Encrypted
import ru.tech.imageresizershrinker.core.ui.icons.material.Exif
@ -367,6 +369,49 @@ sealed class Screen(
}
}
data class ApngTools(
val type: Type? = null
) : Screen(
id = 21,
icon = Icons.Rounded.ApngBox,
title = R.string.apng_tools,
subtitle = R.string.apng_tools_sub
) {
@Parcelize
sealed class Type(
@StringRes val title: Int,
@StringRes val subtitle: Int,
@IgnoredOnParcel val icon: ImageVector? = null
) : Parcelable {
data class ApngToImage(
val apngUri: Uri? = null
) : Type(
title = R.string.apng_type_to_image,
subtitle = R.string.apng_type_to_image_sub,
icon = Icons.Outlined.Collections
)
data class ImageToApng(
val imageUris: List<Uri>? = null
) : Type(
title = R.string.apng_type_to_apng,
subtitle = R.string.apng_type_to_apng_sub,
icon = Icons.Rounded.Apng
)
companion object {
val entries by lazy {
listOf(
ApngToImage(),
ImageToApng()
)
}
}
}
}
companion object {
val typedEntries by lazy {
listOf(
@ -402,8 +447,9 @@ sealed class Screen(
Compare(),
GifTools(),
ImagePreview(),
LoadNetImage(),
GeneratePalette(),
ApngTools(),
LoadNetImage(),
) to Triple(
R.string.tools,
Icons.Rounded.Toolbox,
@ -426,6 +472,7 @@ sealed class Screen(
RecognizeText(),
Watermarking(),
GifTools(),
ApngTools(),
ImagePreview(),
LoadNetImage(),
PickColorFromImage(),
@ -436,6 +483,6 @@ sealed class Screen(
LimitResize()
)
}
const val featuresCount = 27
const val featuresCount = 29
}
}

1
feature/apng-tools/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

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 = "ru.tech.imageresizershrinker.feature.apng_tools"

View File

@ -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>

View File

@ -0,0 +1,114 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.data
import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import oupson.apng.decoder.ApngDecoder
import oupson.apng.encoder.ApngEncoder
import ru.tech.imageresizershrinker.core.domain.image.ImageGetter
import ru.tech.imageresizershrinker.core.domain.image.ShareProvider
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
import ru.tech.imageresizershrinker.core.domain.model.ImageInfo
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
import ru.tech.imageresizershrinker.core.domain.model.Quality
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngConverter
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngParams
import java.io.ByteArrayOutputStream
import javax.inject.Inject
internal class AndroidApngConverter @Inject constructor(
private val imageGetter: ImageGetter<Bitmap, ExifInterface>,
private val imageShareProvider: ShareProvider<Bitmap>,
@ApplicationContext private val context: Context
) : ApngConverter {
override fun extractFramesFromApng(
apngUri: String,
imageFormat: ImageFormat,
quality: Quality
): Flow<String> = flow {
ApngDecoder(
context = context,
uri = apngUri.toUri()
).decodeAsync(currentCoroutineContext()) { frame ->
if (!currentCoroutineContext().isActive) {
currentCoroutineContext().cancel(null)
return@decodeAsync
}
imageShareProvider.cacheImage(
image = frame,
imageInfo = ImageInfo(
width = frame.width,
height = frame.height,
imageFormat = imageFormat,
quality = quality
),
name = "apng_image"
)?.let { emit(it) }
frame.recycle()
}
}
override suspend fun createApngFromImageUris(
imageUris: List<String>,
params: ApngParams,
onProgress: () -> Unit
): ByteArray = withContext(Dispatchers.IO) {
val out = ByteArrayOutputStream()
val size = params.size ?: imageGetter.getImage(data = imageUris[0])!!.run {
IntegerSize(width, height)
}
val encoder = ApngEncoder(
outputStream = out,
width = size.width,
height = size.height,
numberOfFrames = imageUris.size
).apply {
setRepetitionCount(params.repeatCount)
setCompressionLevel(params.quality.qualityValue)
}
imageUris.forEach { uri ->
imageGetter.getImage(
data = uri,
size = params.size
)?.let {
encoder.writeFrame(it, delay = params.delay.toFloat())
}
onProgress()
}
encoder.writeEnd()
out.toByteArray()
}
}

View File

@ -0,0 +1,38 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.tech.imageresizershrinker.feature.apng_tools.data.AndroidApngConverter
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngConverter
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface GifToolsModule {
@Binds
@Singleton
fun provideConverter(
converter: AndroidApngConverter
): ApngConverter
}

View File

@ -0,0 +1,38 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.domain
import kotlinx.coroutines.flow.Flow
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
import ru.tech.imageresizershrinker.core.domain.model.Quality
interface ApngConverter {
fun extractFramesFromApng(
apngUri: String,
imageFormat: ImageFormat,
quality: Quality
): Flow<String>
suspend fun createApngFromImageUris(
imageUris: List<String>,
params: ApngParams,
onProgress: () -> Unit
): ByteArray
}

View File

@ -0,0 +1,39 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.domain
sealed interface ApngFrames {
data object All : ApngFrames {
override fun getFramePositions(
frameCount: Int
): List<Int> = List(frameCount) { it + 1 }
}
data class ManualSelection(
val framePositions: List<Int>
) : ApngFrames {
override fun getFramePositions(
frameCount: Int
): List<Int> = framePositions.filter { it - 1 < frameCount }
}
fun getFramePositions(
frameCount: Int
): List<Int>
}

View File

@ -0,0 +1,39 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.domain
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
import ru.tech.imageresizershrinker.core.domain.model.Quality
data class ApngParams(
val size: IntegerSize?,
val repeatCount: Int,
val delay: Int,
val quality: Quality
) {
companion object {
val Default by lazy {
ApngParams(
size = null,
repeatCount = 1,
delay = 1000,
quality = Quality.Base(5)
)
}
}
}

View File

@ -0,0 +1,605 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.presentation
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderOff
import androidx.compose.material.icons.outlined.PhotoSizeSelectLarge
import androidx.compose.material.icons.outlined.RepeatOne
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.olshevski.navigation.reimagined.hilt.hiltViewModel
import kotlinx.coroutines.launch
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
import ru.tech.imageresizershrinker.core.domain.model.ImageInfo
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
import ru.tech.imageresizershrinker.core.resources.R
import ru.tech.imageresizershrinker.core.settings.presentation.LocalSettingsState
import ru.tech.imageresizershrinker.core.ui.icons.material.Apng
import ru.tech.imageresizershrinker.core.ui.utils.confetti.LocalConfettiController
import ru.tech.imageresizershrinker.core.ui.utils.helper.ContextUtils.getFileName
import ru.tech.imageresizershrinker.core.ui.utils.helper.Picker
import ru.tech.imageresizershrinker.core.ui.utils.helper.ReviewHandler
import ru.tech.imageresizershrinker.core.ui.utils.helper.failedToSaveImages
import ru.tech.imageresizershrinker.core.ui.utils.helper.localImagePickerMode
import ru.tech.imageresizershrinker.core.ui.utils.helper.rememberImagePicker
import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen
import ru.tech.imageresizershrinker.core.ui.widget.AdaptiveLayoutScreen
import ru.tech.imageresizershrinker.core.ui.widget.buttons.BottomButtonsBlock
import ru.tech.imageresizershrinker.core.ui.widget.buttons.EnhancedIconButton
import ru.tech.imageresizershrinker.core.ui.widget.controls.EnhancedSliderItem
import ru.tech.imageresizershrinker.core.ui.widget.controls.ImageFormatSelector
import ru.tech.imageresizershrinker.core.ui.widget.controls.ImageReorderCarousel
import ru.tech.imageresizershrinker.core.ui.widget.controls.QualityWidget
import ru.tech.imageresizershrinker.core.ui.widget.controls.ResizeImageField
import ru.tech.imageresizershrinker.core.ui.widget.dialogs.ExitWithoutSavingDialog
import ru.tech.imageresizershrinker.core.ui.widget.modifier.container
import ru.tech.imageresizershrinker.core.ui.widget.modifier.withModifier
import ru.tech.imageresizershrinker.core.ui.widget.other.Loading
import ru.tech.imageresizershrinker.core.ui.widget.other.LoadingDialog
import ru.tech.imageresizershrinker.core.ui.widget.other.LocalToastHostState
import ru.tech.imageresizershrinker.core.ui.widget.other.ToastDuration
import ru.tech.imageresizershrinker.core.ui.widget.other.TopAppBarEmoji
import ru.tech.imageresizershrinker.core.ui.widget.other.showError
import ru.tech.imageresizershrinker.core.ui.widget.preferences.PreferenceItem
import ru.tech.imageresizershrinker.core.ui.widget.preferences.PreferenceRowSwitch
import ru.tech.imageresizershrinker.core.ui.widget.text.TopAppBarTitle
import ru.tech.imageresizershrinker.core.ui.widget.utils.LocalWindowSizeClass
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngFrames
import ru.tech.imageresizershrinker.feature.apng_tools.presentation.components.ApngConvertedImagesPreview
import ru.tech.imageresizershrinker.feature.apng_tools.presentation.viewModel.ApngToolsViewModel
import kotlin.math.roundToInt
@Composable
fun ApngToolsScreen(
typeState: Screen.ApngTools.Type?,
onGoBack: () -> Unit,
viewModel: ApngToolsViewModel = hiltViewModel()
) {
val context = LocalContext.current as ComponentActivity
val toastHostState = LocalToastHostState.current
val scope = rememberCoroutineScope()
val confettiController = LocalConfettiController.current
val showConfetti: () -> Unit = {
scope.launch {
confettiController.showEmpty()
}
}
LaunchedEffect(typeState) {
typeState?.let { viewModel.setType(it) }
}
val pickImagesLauncher =
rememberImagePicker(
mode = localImagePickerMode(Picker.Multiple)
) { list ->
list.takeIf { it.isNotEmpty() }?.let(viewModel::setImageUris)
}
val pickSingleImageLauncher = rememberImagePicker(
mode = localImagePickerMode(Picker.Single)
) { list ->
list.takeIf { it.isNotEmpty() }?.firstOrNull()?.let {
if (it.isApng(context)) {
viewModel.setApngUri(it)
} else {
scope.launch {
toastHostState.showToast(
message = context.getString(R.string.select_apng_image_to_start),
icon = Icons.Rounded.Apng
)
}
}
}
}
val savePdfLauncher = rememberLauncherForActivityResult(
contract = CreateDocument(),
onResult = {
it?.let { uri ->
viewModel.saveApngTo(
outputStream = context.contentResolver.openOutputStream(uri, "rw")
) { t ->
if (t != null) {
scope.launch {
toastHostState.showError(context, t)
}
} else {
scope.launch {
confettiController.showEmpty()
}
scope.launch {
toastHostState.showToast(
context.getString(
R.string.saved_to_without_filename,
""
),
Icons.Rounded.Save
)
ReviewHandler.showReview(context)
}
}
}
}
}
)
var showExitDialog by rememberSaveable { mutableStateOf(false) }
val onBack = {
if (viewModel.type != null) showExitDialog = true
else onGoBack()
}
val isPortrait =
LocalConfiguration.current.orientation != Configuration.ORIENTATION_LANDSCAPE || LocalWindowSizeClass.current.widthSizeClass == WindowWidthSizeClass.Compact
AdaptiveLayoutScreen(
title = {
TopAppBarTitle(
title = when (viewModel.type) {
is Screen.ApngTools.Type.ApngToImage -> {
stringResource(R.string.apng_type_to_image)
}
is Screen.ApngTools.Type.ImageToApng -> {
stringResource(R.string.apng_type_to_apng)
}
null -> stringResource(R.string.apng_tools)
},
input = viewModel.type,
isLoading = viewModel.isLoading,
size = null
)
},
onGoBack = onBack,
topAppBarPersistentActions = {
if (viewModel.type == null) TopAppBarEmoji()
val pagesSize by remember(viewModel.apngFrames, viewModel.convertedImageUris) {
derivedStateOf {
viewModel.apngFrames.getFramePositions(viewModel.convertedImageUris.size).size
}
}
val isApngToImage = viewModel.type is Screen.ApngTools.Type.ApngToImage
AnimatedVisibility(
visible = isApngToImage && pagesSize != viewModel.convertedImageUris.size,
enter = fadeIn() + scaleIn() + expandHorizontally(),
exit = fadeOut() + scaleOut() + shrinkHorizontally()
) {
EnhancedIconButton(
containerColor = Color.Transparent,
contentColor = LocalContentColor.current,
enableAutoShadowAndBorder = false,
onClick = viewModel::selectAllConvertedImages
) {
Icon(Icons.Outlined.SelectAll, null)
}
}
AnimatedVisibility(
modifier = Modifier
.padding(8.dp)
.container(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp),
resultPadding = 0.dp
),
visible = isApngToImage && pagesSize != 0
) {
Row(
modifier = Modifier.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
pagesSize.takeIf { it != 0 }?.let {
Spacer(Modifier.width(8.dp))
Text(
text = it.toString(),
fontSize = 20.sp,
fontWeight = FontWeight.Medium
)
}
EnhancedIconButton(
containerColor = Color.Transparent,
contentColor = LocalContentColor.current,
enableAutoShadowAndBorder = false,
onClick = viewModel::clearConvertedImagesSelection
) {
Icon(Icons.Rounded.Close, null)
}
}
}
},
actions = {
EnhancedIconButton(
containerColor = Color.Transparent,
contentColor = LocalContentColor.current,
enableAutoShadowAndBorder = false,
onClick = {
viewModel.performSharing(showConfetti)
},
enabled = !viewModel.isLoading && viewModel.type != null
) {
Icon(Icons.Outlined.Share, null)
}
},
imagePreview = {
AnimatedContent(
targetState = viewModel.isLoading to viewModel.type
) { (loading, type) ->
Box(
contentAlignment = Alignment.Center,
modifier = if (loading) {
Modifier.padding(32.dp)
} else Modifier
) {
if (loading || type == null) {
Loading()
} else {
when (type) {
is Screen.ApngTools.Type.ApngToImage -> {
ApngConvertedImagesPreview(
imageUris = viewModel.convertedImageUris,
apngFrames = viewModel.apngFrames,
onApngFramesChange = viewModel::updateApngFrames,
isPortrait = isPortrait,
isLoadingApngImages = viewModel.isLoadingApngImages
)
}
is Screen.ApngTools.Type.ImageToApng -> Unit
}
}
}
}
},
placeImagePreview = viewModel.type is Screen.ApngTools.Type.ApngToImage,
showImagePreviewAsStickyHeader = false,
autoClearFocus = false,
controls = {
when (val type = viewModel.type) {
is Screen.ApngTools.Type.ApngToImage -> {
Spacer(modifier = Modifier.height(16.dp))
ImageFormatSelector(
value = viewModel.imageFormat,
onValueChange = viewModel::setImageFormat
)
Spacer(modifier = Modifier.height(8.dp))
QualityWidget(
imageFormat = viewModel.imageFormat,
enabled = true,
quality = viewModel.params.quality,
onQualityChange = viewModel::setQuality
)
Spacer(modifier = Modifier.height(16.dp))
}
is Screen.ApngTools.Type.ImageToApng -> {
val addImagesToPdfPicker = rememberImagePicker(
mode = localImagePickerMode(Picker.Multiple)
) { list ->
list.takeIf { it.isNotEmpty() }?.let(viewModel::addImageToUris)
}
Spacer(modifier = Modifier.height(16.dp))
ImageReorderCarousel(
images = type.imageUris,
onReorder = viewModel::reorderImageUris,
onNeedToAddImage = { addImagesToPdfPicker.pickImage() },
onNeedToRemoveImageAt = viewModel::removeImageAt
)
Spacer(modifier = Modifier.height(8.dp))
val size = viewModel.params.size ?: IntegerSize.Undefined
AnimatedVisibility(size.isDefined()) {
ResizeImageField(
imageInfo = ImageInfo(size.width, size.height),
originalSize = null,
onWidthChange = {
viewModel.updateParams(
viewModel.params.copy(
size = size.copy(width = it)
)
)
},
onHeightChange = {
viewModel.updateParams(
viewModel.params.copy(
size = size.copy(height = it)
)
)
}
)
}
Spacer(modifier = Modifier.height(8.dp))
PreferenceRowSwitch(
title = stringResource(id = R.string.use_size_of_first_frame),
subtitle = stringResource(id = R.string.use_size_of_first_frame_sub),
checked = viewModel.params.size == null,
onClick = viewModel::setUseOriginalSize,
startIcon = Icons.Outlined.PhotoSizeSelectLarge,
modifier = Modifier.fillMaxWidth(),
color = Color.Unspecified,
shape = RoundedCornerShape(24.dp)
)
Spacer(modifier = Modifier.height(8.dp))
QualityWidget(
imageFormat = ImageFormat.Jpeg,
enabled = true,
quality = viewModel.params.quality,
onQualityChange = viewModel::setQuality
)
Spacer(modifier = Modifier.height(8.dp))
EnhancedSliderItem(
value = viewModel.params.repeatCount,
icon = Icons.Outlined.RepeatOne,
title = stringResource(id = R.string.repeat_count),
valueRange = 1f..10f,
steps = 9,
internalStateTransformation = { it.roundToInt() },
onValueChange = {
viewModel.updateParams(
viewModel.params.copy(
repeatCount = it.roundToInt()
)
)
},
shape = RoundedCornerShape(24.dp)
)
Spacer(modifier = Modifier.height(8.dp))
EnhancedSliderItem(
value = viewModel.params.delay,
icon = Icons.Outlined.Timelapse,
title = stringResource(id = R.string.frame_delay),
valueRange = 1f..4000f,
internalStateTransformation = { it.roundToInt() },
onValueChange = {
viewModel.updateParams(
viewModel.params.copy(
delay = it.roundToInt()
)
)
},
shape = RoundedCornerShape(24.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
null -> Unit
}
},
contentPadding = animateDpAsState(
if (viewModel.type == null) 12.dp
else 20.dp
).value,
buttons = {
val settingsState = LocalSettingsState.current
BottomButtonsBlock(
targetState = (viewModel.type == null) to isPortrait,
onSecondaryButtonClick = {
if (viewModel.type !is Screen.ApngTools.Type.ApngToImage) {
pickImagesLauncher.pickImage()
} else pickSingleImageLauncher.pickImage()
},
isPrimaryButtonVisible = viewModel.canSave,
onPrimaryButtonClick = {
viewModel.saveBitmaps(
onApngSaveResult = { name ->
runCatching {
runCatching {
savePdfLauncher.launch("image/apng#$name.png")
}.onFailure {
scope.launch {
toastHostState.showToast(
message = context.getString(R.string.activate_files),
icon = Icons.Outlined.FolderOff,
duration = ToastDuration.Long
)
}
}
}.onFailure {
scope.launch {
toastHostState.showToast(
message = context.getString(R.string.activate_files),
icon = Icons.Outlined.FolderOff,
duration = ToastDuration.Long
)
}
}
},
onResult = { results, savingPath ->
context.failedToSaveImages(
scope = scope,
results = results,
toastHostState = toastHostState,
savingPathString = savingPath,
isOverwritten = settingsState.overwriteFiles,
showConfetti = showConfetti
)
}
)
},
actions = {
if (isPortrait) it()
},
showNullDataButtonAsContainer = true
)
},
noDataControls = {
val types = remember {
listOf(
Screen.ApngTools.Type.ImageToApng(),
Screen.ApngTools.Type.ApngToImage()
)
}
val preference1 = @Composable {
PreferenceItem(
title = stringResource(types[0].title),
subtitle = stringResource(types[0].subtitle),
startIcon = types[0].icon,
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
onClick = {
pickImagesLauncher.pickImage()
}
)
}
val preference2 = @Composable {
PreferenceItem(
title = stringResource(types[1].title),
subtitle = stringResource(types[1].subtitle),
startIcon = types[1].icon,
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
onClick = {
pickSingleImageLauncher.pickImage()
}
)
}
if (isPortrait) {
Column {
preference1()
Spacer(modifier = Modifier.height(8.dp))
preference2()
}
} else {
val direction = LocalLayoutDirection.current
Row(
modifier = Modifier.padding(
WindowInsets.displayCutout.asPaddingValues().let {
PaddingValues(
start = it.calculateStartPadding(direction),
end = it.calculateEndPadding(direction)
)
}
)
) {
preference1.withModifier(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(8.dp))
preference2.withModifier(modifier = Modifier.weight(1f))
}
}
},
isPortrait = isPortrait,
canShowScreenData = viewModel.type != null
)
if (viewModel.isSaving) {
if (viewModel.left != -1) {
LoadingDialog(
done = viewModel.done,
left = viewModel.left,
onCancelLoading = viewModel::cancelSaving
)
} else {
LoadingDialog(
onCancelLoading = viewModel::cancelSaving
)
}
}
ExitWithoutSavingDialog(
onExit = viewModel::clearAll,
onDismiss = { showExitDialog = false },
visible = showExitDialog
)
}
private fun Uri.isApng(context: Context): Boolean {
return context.getFileName(this).toString().endsWith(".png")
.or(context.contentResolver.getType(this)?.contains("png") == true)
.or(context.contentResolver.getType(this)?.contains("apng") == true)
}
private class CreateDocument : ActivityResultContracts.CreateDocument("*/*") {
override fun createIntent(
context: Context,
input: String
): Intent {
return super.createIntent(
context = context,
input = input.split("#")[0]
).putExtra(Intent.EXTRA_TITLE, input.split("#")[1])
}
}
private val ApngToolsViewModel.canSave: Boolean
get() = (apngFrames == ApngFrames.All)
.or(type is Screen.ApngTools.Type.ImageToApng)
.or((apngFrames as? ApngFrames.ManualSelection)?.framePositions?.isNotEmpty() == true)

View File

@ -0,0 +1,341 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.presentation.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.RectangleShape
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import ru.tech.imageresizershrinker.core.ui.widget.image.Picture
import ru.tech.imageresizershrinker.core.ui.widget.modifier.dragHandler
import ru.tech.imageresizershrinker.core.ui.widget.other.Loading
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngFrames
@Composable
fun ApngConvertedImagesPreview(
imageUris: List<String>,
apngFrames: ApngFrames,
onApngFramesChange: (ApngFrames) -> Unit,
isPortrait: Boolean,
isLoadingApngImages: Boolean,
spacing: Dp = 8.dp
) {
val state = rememberLazyGridState()
val autoScrollSpeed: MutableState<Float> = remember { mutableFloatStateOf(0f) }
LaunchedEffect(autoScrollSpeed.value) {
if (autoScrollSpeed.value != 0f) {
while (isActive) {
state.scrollBy(autoScrollSpeed.value)
delay(10)
}
}
}
val getUris: () -> Set<Int> = {
val indexes = apngFrames
.getFramePositions(imageUris.size)
.map { it - 1 }
imageUris.mapIndexedNotNull { index, _ ->
if (index in indexes) index + 1
else null
}.toSet()
}
val selectedItems by remember(imageUris, apngFrames) {
mutableStateOf(getUris())
}
val privateSelectedItems = remember {
mutableStateOf(selectedItems)
}
LaunchedEffect(apngFrames, selectedItems) {
if (apngFrames !is ApngFrames.ManualSelection || selectedItems.isEmpty()) {
privateSelectedItems.value = selectedItems
}
}
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val modifier = if (isPortrait) {
Modifier.height(
(130.dp * imageUris.size).coerceAtMost(420.dp)
)
} else {
Modifier
}.layout { measurable, constraints ->
val result =
measurable.measure(
if (isPortrait) {
constraints.copy(
maxWidth = screenWidth.roundToPx()
)
} else {
constraints.copy(
maxHeight = constraints.maxHeight + 48.dp.roundToPx()
)
}
)
layout(result.measuredWidth, result.measuredHeight) {
result.place(0, 0)
}
}
Box(modifier = modifier) {
if (isPortrait) {
LazyHorizontalGrid(
rows = GridCells.Adaptive(120.dp),
state = state,
modifier = Modifier
.fillMaxSize()
.dragHandler(
key = null,
lazyGridState = state,
isVertical = false,
haptics = LocalHapticFeedback.current,
selectedItems = privateSelectedItems,
onSelectionChange = {
onApngFramesChange(ApngFrames.ManualSelection(it.toList()))
},
autoScrollSpeed = autoScrollSpeed,
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }
),
verticalArrangement = Arrangement.spacedBy(
space = spacing,
alignment = Alignment.CenterVertically
),
horizontalArrangement = Arrangement.spacedBy(
space = spacing,
alignment = Alignment.CenterHorizontally
),
contentPadding = PaddingValues(12.dp),
) {
itemsIndexed(
items = imageUris,
key = { index, uri -> "$uri-${index + 1}" }
) { index, uri ->
val selected by remember(index, privateSelectedItems.value) {
derivedStateOf {
index + 1 in privateSelectedItems.value
}
}
ImageItem(
selected = selected,
modifier = Modifier
.fillMaxSize()
.aspectRatio(1f),
index = index,
uri = uri
)
}
item {
AnimatedVisibility(isLoadingApngImages) {
Box(
modifier = Modifier
.fillMaxSize()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
Loading()
}
}
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(90.dp),
state = state,
modifier = Modifier
.fillMaxSize()
.dragHandler(
key = null,
lazyGridState = state,
isVertical = true,
haptics = LocalHapticFeedback.current,
selectedItems = privateSelectedItems,
onSelectionChange = {
onApngFramesChange(ApngFrames.ManualSelection(it.toList()))
},
autoScrollSpeed = autoScrollSpeed,
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }
),
verticalArrangement = Arrangement.spacedBy(
space = spacing,
alignment = Alignment.CenterVertically
),
horizontalArrangement = Arrangement.spacedBy(
space = spacing,
alignment = Alignment.CenterHorizontally
),
contentPadding = PaddingValues(12.dp),
) {
itemsIndexed(
items = imageUris,
key = { index, uri -> "$uri-${index + 1}" }
) { index, uri ->
val selected by remember(index, privateSelectedItems.value) {
derivedStateOf {
index + 1 in privateSelectedItems.value
}
}
ImageItem(
selected = selected,
modifier = Modifier
.fillMaxSize()
.aspectRatio(1f),
index = index,
uri = uri
)
}
item {
AnimatedVisibility(isLoadingApngImages) {
Box(
modifier = Modifier
.fillMaxSize()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
Loading()
}
}
}
}
}
}
}
@Composable
private fun ImageItem(
modifier: Modifier,
uri: String,
index: Int,
selected: Boolean
) {
val transition = updateTransition(selected)
val padding by transition.animateDp { s ->
if (s) 10.dp else 0.dp
}
val corners by transition.animateDp { s ->
if (s) 16.dp else 0.dp
}
val bgColor = MaterialTheme.colorScheme.secondaryContainer
Box(
modifier
.clip(RoundedCornerShape(4.dp))
.background(bgColor)
) {
Picture(
modifier = Modifier
.matchParentSize()
.padding(padding)
.clip(RoundedCornerShape(corners))
.background(Color.White),
shape = RectangleShape,
model = uri
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.clip(RoundedCornerShape(corners))
.background(Color.Black.copy(0.3f)),
contentAlignment = Alignment.Center
) {
Text(
text = (index + 1).toString(),
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Medium
)
}
AnimatedContent(
targetState = selected,
transitionSpec = {
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
}
) { selected ->
if (selected) {
Icon(
imageVector = Icons.Filled.CheckCircle,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier
.padding(4.dp)
.border(2.dp, bgColor, CircleShape)
.clip(CircleShape)
.background(bgColor)
)
} else {
Icon(
imageVector = Icons.Filled.RadioButtonUnchecked,
tint = Color.White.copy(alpha = 0.7f),
contentDescription = null,
modifier = Modifier.padding(6.dp)
)
}
}
}
}

View File

@ -0,0 +1,388 @@
/*
* 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 ru.tech.imageresizershrinker.feature.apng_tools.presentation.viewModel
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.tech.imageresizershrinker.core.domain.image.ImageCompressor
import ru.tech.imageresizershrinker.core.domain.image.ImageGetter
import ru.tech.imageresizershrinker.core.domain.image.ShareProvider
import ru.tech.imageresizershrinker.core.domain.model.ImageFormat
import ru.tech.imageresizershrinker.core.domain.model.ImageInfo
import ru.tech.imageresizershrinker.core.domain.model.IntegerSize
import ru.tech.imageresizershrinker.core.domain.model.Quality
import ru.tech.imageresizershrinker.core.domain.saving.FileController
import ru.tech.imageresizershrinker.core.domain.saving.SaveResult
import ru.tech.imageresizershrinker.core.domain.saving.model.ImageSaveTarget
import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen
import ru.tech.imageresizershrinker.core.ui.utils.state.update
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngConverter
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngFrames
import ru.tech.imageresizershrinker.feature.apng_tools.domain.ApngParams
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlin.random.Random
@HiltViewModel
class ApngToolsViewModel @Inject constructor(
private val imageCompressor: ImageCompressor<Bitmap>,
private val imageGetter: ImageGetter<Bitmap, ExifInterface>,
private val fileController: FileController,
private val apngConverter: ApngConverter,
private val shareProvider: ShareProvider<Bitmap>
) : ViewModel() {
private val _type: MutableState<Screen.ApngTools.Type?> = mutableStateOf(null)
val type by _type
private val _isLoading: MutableState<Boolean> = mutableStateOf(false)
val isLoading by _isLoading
private val _isLoadingApngImages: MutableState<Boolean> = mutableStateOf(false)
val isLoadingApngImages by _isLoadingApngImages
private val _params: MutableState<ApngParams> = mutableStateOf(ApngParams.Default)
val params by _params
private val _convertedImageUris: MutableState<List<String>> = mutableStateOf(emptyList())
val convertedImageUris by _convertedImageUris
private val _imageFormat: MutableState<ImageFormat> = mutableStateOf(ImageFormat.Default())
val imageFormat by _imageFormat
private val _apngFrames: MutableState<ApngFrames> = mutableStateOf(ApngFrames.All)
val apngFrames by _apngFrames
private val _done: MutableState<Int> = mutableIntStateOf(0)
val done by _done
private val _left: MutableState<Int> = mutableIntStateOf(-1)
val left by _left
private val _isSaving: MutableState<Boolean> = mutableStateOf(false)
val isSaving: Boolean by _isSaving
private var apngData: ByteArray? = null
fun setType(type: Screen.ApngTools.Type) {
when (type) {
is Screen.ApngTools.Type.ApngToImage -> {
type.apngUri?.let { setApngUri(it) } ?: _type.update { null }
}
is Screen.ApngTools.Type.ImageToApng -> {
_type.update { type }
}
}
}
fun setImageUris(uris: List<Uri>) {
clearAll()
_type.update {
Screen.ApngTools.Type.ImageToApng(uris)
}
}
private var collectionJob: Job? = null
fun setApngUri(uri: Uri) {
clearAll()
_type.update {
Screen.ApngTools.Type.ApngToImage(uri)
}
updateApngFrames(ApngFrames.All)
collectionJob?.cancel()
collectionJob = viewModelScope.launch(Dispatchers.IO) {
_isLoading.update { true }
_isLoadingApngImages.update { true }
apngConverter.extractFramesFromApng(
apngUri = uri.toString(),
imageFormat = imageFormat,
quality = params.quality
).onCompletion {
_isLoading.update { false }
_isLoadingApngImages.update { false }
}.collect { nextUri ->
if (isLoading) {
_isLoading.update { false }
}
_convertedImageUris.update { it + nextUri }
}
}
}
fun clearAll() {
collectionJob?.cancel()
collectionJob = null
_type.update { null }
_convertedImageUris.update { emptyList() }
apngData = null
savingJob?.cancel()
savingJob = null
_params.update { ApngParams.Default }
}
fun updateApngFrames(apngFrames: ApngFrames) {
_apngFrames.update { apngFrames }
}
fun clearConvertedImagesSelection() = updateApngFrames(ApngFrames.ManualSelection(emptyList()))
fun selectAllConvertedImages() = updateApngFrames(ApngFrames.All)
private var savingJob: Job? = null
fun saveApngTo(
outputStream: OutputStream?,
onComplete: (Throwable?) -> Unit
) {
_isSaving.value = false
savingJob?.cancel()
savingJob = viewModelScope.launch {
withContext(Dispatchers.IO) {
_isSaving.value = true
kotlin.runCatching {
outputStream?.use {
it.write(apngData)
}
}.exceptionOrNull().let(onComplete)
_isSaving.value = false
apngData = null
}
}
}
fun saveBitmaps(
onApngSaveResult: (String) -> Unit,
onResult: (List<SaveResult>, String) -> Unit
) {
_isSaving.value = false
savingJob?.cancel()
savingJob = viewModelScope.launch(Dispatchers.IO) {
_isSaving.value = true
_left.value = 1
_done.value = 0
when (val type = _type.value) {
is Screen.ApngTools.Type.ApngToImage -> {
val results = mutableListOf<SaveResult>()
type.apngUri?.toString()?.also { apngUri ->
apngConverter.extractFramesFromApng(
apngUri = apngUri,
imageFormat = imageFormat,
quality = params.quality,
// onGetFramesCount = {
// if (it == 0) {
// _isSaving.value = false
// savingJob?.cancel()
// onResult(
// listOf(SaveResult.Error.MissingPermissions), ""
// )
// }
// _left.value = gifFrames.getFramePositions(it).size
// }
).onCompletion {
onResult(results, fileController.savingPath)
}.collect { uri ->
imageGetter.getImage(
data = uri,
originalSize = true
)?.let { localBitmap ->
val imageInfo = ImageInfo(
imageFormat = imageFormat,
width = localBitmap.width,
height = localBitmap.height
)
results.add(
fileController.save(
saveTarget = ImageSaveTarget<ExifInterface>(
imageInfo = imageInfo,
originalUri = uri,
sequenceNumber = _done.value + 1,
data = imageCompressor.compressAndTransform(
image = localBitmap,
imageInfo = ImageInfo(
imageFormat = imageFormat,
quality = params.quality,
width = localBitmap.width,
height = localBitmap.height
)
)
),
keepOriginalMetadata = false
)
)
} ?: results.add(
SaveResult.Error.Exception(Throwable())
)
_done.value++
}
}
}
is Screen.ApngTools.Type.ImageToApng -> {
_left.value = type.imageUris?.size ?: -1
apngData = type.imageUris?.map { it.toString() }?.let { list ->
apngConverter.createApngFromImageUris(
imageUris = list,
params = params,
onProgress = {
_done.update { it + 1 }
}
).also {
val timeStamp = SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss",
Locale.getDefault()
).format(Date()) + "_${
Random(Random.nextInt()).hashCode().toString().take(4)
}"
onApngSaveResult("APNG_$timeStamp")
}
}
}
null -> Unit
}
_isSaving.value = false
}
}
fun cancelSaving() {
savingJob?.cancel()
savingJob = null
_isSaving.value = false
}
fun reorderImageUris(uris: List<Uri>?) {
if (type is Screen.ApngTools.Type.ImageToApng) {
_type.update {
Screen.ApngTools.Type.ImageToApng(uris)
}
}
}
fun addImageToUris(uris: List<Uri>) {
val type = _type.value
if (type is Screen.ApngTools.Type.ImageToApng) {
_type.update {
val newUris = type.imageUris?.plus(uris)?.toSet()?.toList()
Screen.ApngTools.Type.ImageToApng(newUris)
}
}
}
fun removeImageAt(index: Int) {
val type = _type.value
if (type is Screen.ApngTools.Type.ImageToApng) {
_type.update {
val newUris = type.imageUris?.toMutableList()?.apply {
removeAt(index)
}
Screen.ApngTools.Type.ImageToApng(newUris)
}
}
}
fun setImageFormat(imageFormat: ImageFormat) {
_imageFormat.update { imageFormat }
}
fun setQuality(quality: Quality) {
_params.update { it.copy(quality = quality) }
}
fun updateParams(params: ApngParams) {
_params.update { params }
}
fun setUseOriginalSize(value: Boolean) {
_params.update {
it.copy(size = if (value) null else IntegerSize(1000, 1000))
}
}
fun performSharing(onComplete: () -> Unit) {
_isSaving.value = false
savingJob?.cancel()
savingJob = viewModelScope.launch(Dispatchers.IO) {
_isSaving.value = true
_left.value = 1
_done.value = 0
when (val type = _type.value) {
is Screen.ApngTools.Type.ApngToImage -> {
_left.value = -1
val positions =
apngFrames.getFramePositions(convertedImageUris.size).map { it - 1 }
val uris = convertedImageUris.filterIndexed { index, _ ->
index in positions
}
shareProvider.shareImageUris(uris)
onComplete()
}
is Screen.ApngTools.Type.ImageToApng -> {
_left.value = type.imageUris?.size ?: -1
type.imageUris?.map { it.toString() }?.let { list ->
apngConverter.createApngFromImageUris(
imageUris = list,
params = params,
onProgress = {
_done.update { it + 1 }
}
).also { byteArray ->
val timeStamp = SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss",
Locale.getDefault()
).format(Date()) + "_${
Random(Random.nextInt()).hashCode().toString().take(4)
}"
val apngName = "APNG_$timeStamp"
shareProvider.shareByteArray(
byteArray = byteArray,
filename = "$apngName.png",
onComplete = onComplete
)
}
}
}
null -> {
Unit
}
}
_isSaving.value = false
}
}
}

View File

@ -46,4 +46,5 @@ dependencies {
implementation(projects.feature.gradientMaker)
implementation(projects.feature.watermarking)
implementation(projects.feature.gifTools)
implementation(projects.feature.apngTools)
}

View File

@ -39,6 +39,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ru.tech.imageresizershrinker.core.settings.presentation.LocalSettingsState
import ru.tech.imageresizershrinker.core.ui.utils.navigation.Screen
import ru.tech.imageresizershrinker.feature.apng_tools.presentation.ApngToolsScreen
import ru.tech.imageresizershrinker.feature.bytes_resize.presentation.BytesResizeScreen
import ru.tech.imageresizershrinker.feature.cipher.presentation.FileCipherScreen
import ru.tech.imageresizershrinker.feature.compare.presentation.CompareScreen
@ -270,6 +271,13 @@ fun ScreenSelector(
onGoBack = onGoBack
)
}
is Screen.ApngTools -> {
ApngToolsScreen(
typeState = screen.type,
onGoBack = onGoBack
)
}
}
}
val currentScreen by remember(navController.backstack.entries) {

View File

@ -63,6 +63,7 @@ include(":feature:recognize-text")
include(":feature:watermarking")
include(":feature:gradient-maker")
include(":feature:gif-tools")
include(":feature:apng-tools")
include(":core:settings")
include(":core:resources")