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
+74
View File
@@ -0,0 +1,74 @@
/*
* 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>.
*/
plugins {
alias(libs.plugins.image.toolbox.library)
alias(libs.plugins.image.toolbox.hilt)
}
android.namespace = "com.t8rin.imagetoolbox.core.data"
dependencies {
api(libs.coil)
implementation(libs.coilNetwork)
api(libs.ktor)
api(libs.ktor.logging)
implementation(libs.coilGif)
implementation(libs.coilSvg)
implementation(libs.coil.resvg)
implementation(libs.trickle)
implementation(libs.androidx.compose.ui.graphics)
api(libs.datastore.preferences.android)
api(libs.datastore.core.android)
implementation(libs.avif.coder)
implementation(libs.jxl.coder.coil) {
exclude(module = "com.github.awxkee:jxl-coder")
}
implementation(libs.jxl.coder)
implementation(libs.aire)
implementation(libs.jpegli.coder)
implementation(libs.moshi)
implementation(libs.moshi.adapters)
api(libs.androidx.documentfile)
implementation(libs.toolbox.gifConverter)
implementation(libs.toolbox.exif)
implementation(libs.tiffdecoder)
implementation(libs.toolbox.qoiCoder)
implementation(libs.toolbox.jp2decoder)
implementation(libs.toolbox.awebp)
implementation(libs.toolbox.psd)
implementation(libs.toolbox.apng)
implementation(libs.toolbox.djvuCoder)
implementation(libs.pdfbox)
implementation(libs.trickle)
implementation(projects.core.domain)
implementation(projects.core.resources)
implementation(projects.core.filters)
implementation(projects.core.settings)
implementation(projects.core.di)
api(projects.core.utils)
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".saving.KeepAliveForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="true" />
</application>
</manifest>
@@ -0,0 +1,68 @@
/*
* 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.core.data.coil
import android.util.Base64
import coil3.ImageLoader
import coil3.Uri
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import com.t8rin.imagetoolbox.core.domain.utils.isBase64
import com.t8rin.imagetoolbox.core.domain.utils.trimToBase64
import okio.Buffer
internal class Base64Fetcher(
private val options: Options,
private val base64: String
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val byteArray = runCatching {
Base64.decode(base64, Base64.DEFAULT)
}.getOrNull() ?: return null
return SourceFetchResult(
source = ImageSource(
source = Buffer().apply { write(byteArray) },
fileSystem = options.fileSystem,
),
mimeType = null,
dataSource = DataSource.MEMORY,
)
}
class Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
val stripped = data.toString().trimToBase64()
return if (stripped.isBase64()) {
Base64Fetcher(
options = options,
base64 = stripped
)
} else null
}
}
}
@@ -0,0 +1,65 @@
/*
* 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.core.data.coil
import coil3.util.DebugLogger
import coil3.util.Logger
import com.t8rin.imagetoolbox.core.utils.makeLog
import com.t8rin.imagetoolbox.core.utils.Logger as RealLogger
internal class CoilLogger : Logger {
private val delegate = DebugLogger()
override var minLevel: Logger.Level
get() = delegate.minLevel
set(value) {
delegate.minLevel = value
}
override fun log(
tag: String,
level: Logger.Level,
message: String?,
throwable: Throwable?
) {
message?.takeIf {
"NullRequestData" !in it && "PDF" !in it
}?.makeLog(tag, level.toLogger())
throwable?.takeIf {
"The request's data is null" !in it.message.orEmpty() && "PDF" !in it.message.orEmpty()
}?.makeLog(tag)
delegate.log(
tag = tag,
level = level,
message = message,
throwable = throwable
)
}
private fun Logger.Level.toLogger(): RealLogger.Level = when (this) {
Logger.Level.Verbose -> RealLogger.Level.Verbose
Logger.Level.Debug -> RealLogger.Level.Debug
Logger.Level.Info -> RealLogger.Level.Info
Logger.Level.Warn -> RealLogger.Level.Warn
Logger.Level.Error -> RealLogger.Level.Error
}
}
@@ -0,0 +1,143 @@
/*
* 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.core.data.coil
import android.graphics.Bitmap
import android.os.Build
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.allowHardware
import coil3.request.allowRgb565
import coil3.request.bitmapConfig
import coil3.size.Scale
import coil3.size.Size
import coil3.size.pxOrElse
import com.radzivon.bartoshyk.avif.coder.Coder
import com.radzivon.bartoshyk.avif.coder.PreferredColorConfig
import com.radzivon.bartoshyk.avif.coder.ScaleMode
import com.t8rin.imagetoolbox.core.utils.makeLog
import kotlinx.coroutines.runInterruptible
import okio.ByteString.Companion.encodeUtf8
class HeifDecoder(
private val source: SourceFetchResult,
private val options: Options,
private val exceptionLogger: ((Exception) -> Unit)? = null,
) : Decoder {
private val coder = Coder()
override suspend fun decode(): DecodeResult? = runInterruptible {
try {
// ColorSpace is preferred to be ignored due to lib is trying to handle all color profile by itself
val sourceData = source.source.source().readByteArray()
var mPreferredColorConfig: PreferredColorConfig = when (options.bitmapConfig) {
Bitmap.Config.ALPHA_8 -> PreferredColorConfig.RGBA_8888
Bitmap.Config.RGB_565 -> if (options.allowRgb565) PreferredColorConfig.RGB_565 else PreferredColorConfig.DEFAULT
Bitmap.Config.ARGB_8888 -> PreferredColorConfig.RGBA_8888
else -> {
if (options.allowHardware) {
PreferredColorConfig.DEFAULT
} else {
PreferredColorConfig.RGBA_8888
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && options.bitmapConfig == Bitmap.Config.RGBA_F16) {
mPreferredColorConfig = PreferredColorConfig.RGBA_F16
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.bitmapConfig == Bitmap.Config.HARDWARE) {
mPreferredColorConfig = PreferredColorConfig.HARDWARE
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && options.bitmapConfig == Bitmap.Config.RGBA_1010102) {
mPreferredColorConfig = PreferredColorConfig.RGBA_1010102
}
if (options.size == Size.ORIGINAL) {
val originalImage = coder.decode(
byteArray = sourceData,
preferredColorConfig = mPreferredColorConfig
)
return@runInterruptible DecodeResult(
image = originalImage.asImage(),
isSampled = false
)
}
val dstWidth = options.size.width.pxOrElse { 0 }
val dstHeight = options.size.height.pxOrElse { 0 }
val scaleMode = when (options.scale) {
Scale.FILL -> ScaleMode.FILL
Scale.FIT -> ScaleMode.FIT
}
val originalImage = coder.decodeSampled(
byteArray = sourceData,
scaledWidth = dstWidth,
scaledHeight = dstHeight,
preferredColorConfig = mPreferredColorConfig,
scaleMode = scaleMode,
)
return@runInterruptible DecodeResult(
image = originalImage.asImage(),
isSampled = true
)
} catch (e: Exception) {
e.makeLog("HeifDecoder")
exceptionLogger?.invoke(e)
return@runInterruptible null
}
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? {
return if (
AVAILABLE_BRANDS.any {
result.source.source().rangeEquals(4, it)
}
) {
HeifDecoder(
source = result,
options = options
)
} else null
}
companion object {
private val MIF = "ftypmif1".encodeUtf8()
private val MSF = "ftypmsf1".encodeUtf8()
private val HEIC = "ftypheic".encodeUtf8()
private val HEIX = "ftypheix".encodeUtf8()
private val HEVC = "ftyphevc".encodeUtf8()
private val HEVX = "ftyphevx".encodeUtf8()
private val AVIF = "ftypavif".encodeUtf8()
private val AVIS = "ftypavis".encodeUtf8()
private val AVAILABLE_BRANDS = listOf(MIF, MSF, HEIC, HEIX, HEVC, HEVX, AVIF, AVIS)
}
}
}
@@ -0,0 +1,26 @@
/*
* 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.core.data.coil
import coil3.memory.MemoryCache
fun MemoryCache.remove(key: String) = keys.filter {
it.key == key
}.ifEmpty {
listOf(MemoryCache.Key(key))
}.all(::remove)
@@ -0,0 +1,462 @@
/*
* 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>.
*/
@file:Suppress("MagicNumber", "ReturnCount")
package com.t8rin.imagetoolbox.core.data.coil
import android.graphics.Bitmap
import android.graphics.Matrix
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.BitmapFactoryDecoder
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.toBitmap
import okio.Buffer
import okio.BufferedSource
import okio.ByteString.Companion.toByteString
import java.io.File
import java.io.RandomAccessFile
import java.util.Locale
internal class NefDecoder private constructor(
private val source: ImageSource,
private val options: Options
) : Decoder {
override suspend fun decode(): DecodeResult? {
val preview = runCatching {
NefPreviewExtractor(
file = source.file().toFile()
).extract()
}.getOrNull() ?: return null
val result = BitmapFactoryDecoder(
source = ImageSource(
source = Buffer().write(preview.bytes),
fileSystem = options.fileSystem
),
options = options
).decode()
return result.applyOrientation(preview.orientation)
}
private fun DecodeResult.applyOrientation(
orientation: Int
): DecodeResult {
if (orientation == ORIENTATION_NORMAL) return this
val bitmap = image.toBitmap()
val matrix = Matrix().applyTiffOrientation(orientation) ?: return this
val rotated = Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
if (rotated != bitmap) bitmap.recycle()
return DecodeResult(
image = rotated.asImage(),
isSampled = isSampled
)
}
private fun Matrix.applyTiffOrientation(
orientation: Int
): Matrix? {
val transformation = TIFF_ORIENTATION_TRANSFORMS[orientation] ?: return null
transformation(this)
return this
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? {
return if (isTiff(result.source.source())) {
NefDecoder(
source = result.source,
options = options
)
} else {
null
}
}
private fun isTiff(source: BufferedSource): Boolean {
val littleEndianMagic = byteArrayOf(0x49, 0x49, 0x2a, 0x00)
val bigEndianMagic = byteArrayOf(0x4d, 0x4d, 0x00, 0x2a)
return source.rangeEquals(0, littleEndianMagic.toByteString()) ||
source.rangeEquals(0, bigEndianMagic.toByteString())
}
}
private class NefPreviewExtractor(
private val file: File
) {
fun extract(): PreviewData? = RandomAccessFile(file, "r").use { raf ->
val reader = TiffReader.create(raf) ?: return null
val rootDirectory = reader.readDirectory(reader.firstDirectoryOffset) ?: return null
val make = rootDirectory.asciiValue(TAG_MAKE, reader)
?.uppercase(Locale.ROOT)
.orEmpty()
if (!make.contains("NIKON")) return null
val subIfdOffsets = rootDirectory.longValues(TAG_SUB_IFD, reader)
if (subIfdOffsets.isEmpty()) return null
val subIfds = subIfdOffsets.mapNotNull(reader::readDirectory)
if (!subIfds.any { it.isNikonRawImage(reader) }) return null
val preview = subIfds
.mapNotNull { it.jpegPreview(reader) }
.filter(reader::isJpeg)
.maxByOrNull(JpegPreview::length)
?: return null
val bytes = reader.readBytes(
offset = preview.offset,
byteCount = preview.length
) ?: return null
PreviewData(
bytes = bytes,
orientation = rootDirectory.longValue(TAG_ORIENTATION, reader)?.toInt()
?: ORIENTATION_NORMAL
)
}
}
private class TiffReader private constructor(
private val raf: RandomAccessFile,
private val byteOrder: ByteOrder,
val firstDirectoryOffset: Long
) {
fun readDirectory(offset: Long): TiffDirectory? {
if (offset <= 0 || offset >= raf.length()) return null
raf.seek(offset)
val entryCount = readUShort()
if (entryCount > MAX_IFD_ENTRIES) return null
val entries = buildMap {
repeat(entryCount) {
val bytes = ByteArray(IFD_ENTRY_SIZE)
raf.readFully(bytes)
val entry = TiffEntry(
tag = readUShort(bytes, offset = 0),
type = readUShort(bytes, offset = 2),
count = readUInt(bytes, offset = 4),
valueOrOffset = readUInt(bytes, offset = 8),
inlineValue = bytes.copyOfRange(8, 12)
)
put(entry.tag, entry)
}
}
return TiffDirectory(entries)
}
fun values(entry: TiffEntry): ByteArray? {
val typeSize = TYPE_SIZES[entry.type] ?: return null
val byteCount = entry.count * typeSize
if (byteCount <= 0 || byteCount > Int.MAX_VALUE) return null
if (byteCount <= INLINE_VALUE_SIZE) {
return entry.inlineValue.copyOf(byteCount.toInt())
}
return readBytes(
offset = entry.valueOrOffset,
byteCount = byteCount
)
}
fun readBytes(
offset: Long,
byteCount: Long
): ByteArray? {
if (offset < 0 || byteCount <= 0 || byteCount > Int.MAX_VALUE) return null
if (offset + byteCount > raf.length()) return null
val bytes = ByteArray(byteCount.toInt())
raf.seek(offset)
raf.readFully(bytes)
return bytes
}
fun isJpeg(preview: JpegPreview): Boolean {
val bytes = readBytes(
offset = preview.offset,
byteCount = JPEG_SIGNATURE_SIZE
) ?: return false
return bytes[0] == JPEG_START_BYTE_0 && bytes[1] == JPEG_START_BYTE_1
}
fun readUShort(bytes: ByteArray, offset: Int): Int {
val first = bytes[offset].toInt() and 0xff
val second = bytes[offset + 1].toInt() and 0xff
return when (byteOrder) {
ByteOrder.LittleEndian -> first or (second shl 8)
ByteOrder.BigEndian -> (first shl 8) or second
}
}
fun readUInt(bytes: ByteArray, offset: Int): Long {
val first = bytes[offset].toLong() and 0xff
val second = bytes[offset + 1].toLong() and 0xff
val third = bytes[offset + 2].toLong() and 0xff
val fourth = bytes[offset + 3].toLong() and 0xff
return when (byteOrder) {
ByteOrder.LittleEndian -> first or (second shl 8) or (third shl 16) or (fourth shl 24)
ByteOrder.BigEndian -> (first shl 24) or (second shl 16) or (third shl 8) or fourth
}
}
private fun readUShort(): Int {
val bytes = ByteArray(2)
raf.readFully(bytes)
return readUShort(bytes, offset = 0)
}
companion object {
fun create(raf: RandomAccessFile): TiffReader? {
if (raf.length() < TIFF_HEADER_SIZE) return null
raf.seek(0)
val header = ByteArray(TIFF_HEADER_SIZE)
raf.readFully(header)
val byteOrder = when {
header[0] == LITTLE_ENDIAN_BYTE && header[1] == LITTLE_ENDIAN_BYTE -> {
ByteOrder.LittleEndian
}
header[0] == BIG_ENDIAN_BYTE && header[1] == BIG_ENDIAN_BYTE -> {
ByteOrder.BigEndian
}
else -> return null
}
val reader = TiffReader(
raf = raf,
byteOrder = byteOrder,
firstDirectoryOffset = 0
)
val magic = reader.readUShort(header, offset = 2)
if (magic != TIFF_MAGIC) return null
return TiffReader(
raf = raf,
byteOrder = byteOrder,
firstDirectoryOffset = reader.readUInt(header, offset = 4)
)
}
}
}
private data class TiffDirectory(
private val entries: Map<Int, TiffEntry>
) {
fun longValue(
tag: Int,
reader: TiffReader
): Long? = longValues(tag, reader).firstOrNull()
fun longValues(
tag: Int,
reader: TiffReader
): List<Long> {
val entry = entries[tag] ?: return emptyList()
val bytes = reader.values(entry) ?: return emptyList()
val count = entry.count.toIntOrNull() ?: return emptyList()
return when (entry.type) {
TYPE_BYTE -> List(count) { index ->
bytes[index].toLong() and 0xff
}
TYPE_SHORT -> List(count) { index ->
reader.readUShort(bytes, index * SHORT_SIZE).toLong()
}
TYPE_LONG -> List(count) { index ->
reader.readUInt(bytes, index * LONG_SIZE)
}
else -> emptyList()
}
}
fun asciiValue(
tag: Int,
reader: TiffReader
): String? {
val entry = entries[tag] ?: return null
if (entry.type != TYPE_ASCII) return null
val bytes = reader.values(entry) ?: return null
return String(bytes, Charsets.US_ASCII).substringBefore(NULL_CHAR)
}
fun isNikonRawImage(reader: TiffReader): Boolean {
val compression = longValue(TAG_COMPRESSION, reader)
val photometric = longValue(TAG_PHOTOMETRIC, reader)
val bitsPerSample = longValue(TAG_BITS_PER_SAMPLE, reader)
return compression == COMPRESSION_NIKON_RAW ||
(photometric == PHOTOMETRIC_CFA && bitsPerSample != null && bitsPerSample > 8)
}
fun jpegPreview(reader: TiffReader): JpegPreview? {
val offset = longValue(TAG_JPEG_INTERCHANGE_FORMAT, reader) ?: return null
val length = longValue(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, reader) ?: return null
if (length <= 0) return null
return JpegPreview(
offset = offset,
length = length
)
}
}
private class TiffEntry(
val tag: Int,
val type: Int,
val count: Long,
val valueOrOffset: Long,
val inlineValue: ByteArray
)
private data class JpegPreview(
val offset: Long,
val length: Long
)
private class PreviewData(
val bytes: ByteArray,
val orientation: Int
)
private enum class ByteOrder {
LittleEndian,
BigEndian
}
private companion object {
private const val TIFF_HEADER_SIZE = 8
private const val TIFF_MAGIC = 42
private const val IFD_ENTRY_SIZE = 12
private const val INLINE_VALUE_SIZE = 4
private const val MAX_IFD_ENTRIES = 512
private const val SHORT_SIZE = 2
private const val LONG_SIZE = 4
private const val JPEG_SIGNATURE_SIZE = 2L
private const val LITTLE_ENDIAN_BYTE = 0x49.toByte()
private const val BIG_ENDIAN_BYTE = 0x4d.toByte()
private const val JPEG_START_BYTE_0 = 0xff.toByte()
private const val JPEG_START_BYTE_1 = 0xd8.toByte()
private const val NULL_CHAR = '\u0000'
private const val TYPE_BYTE = 1
private const val TYPE_ASCII = 2
private const val TYPE_SHORT = 3
private const val TYPE_LONG = 4
private const val TAG_SUB_IFD = 330
private const val TAG_MAKE = 271
private const val TAG_ORIENTATION = 274
private const val TAG_BITS_PER_SAMPLE = 258
private const val TAG_COMPRESSION = 259
private const val TAG_PHOTOMETRIC = 262
private const val TAG_JPEG_INTERCHANGE_FORMAT = 513
private const val TAG_JPEG_INTERCHANGE_FORMAT_LENGTH = 514
private const val COMPRESSION_NIKON_RAW = 34713L
private const val PHOTOMETRIC_CFA = 32803L
private const val ORIENTATION_NORMAL = 1
private const val ORIENTATION_FLIP_HORIZONTAL = 2
private const val ORIENTATION_ROTATE_180 = 3
private const val ORIENTATION_FLIP_VERTICAL = 4
private const val ORIENTATION_TRANSPOSE = 5
private const val ORIENTATION_ROTATE_90 = 6
private const val ORIENTATION_TRANSVERSE = 7
private const val ORIENTATION_ROTATE_270 = 8
private val TIFF_ORIENTATION_TRANSFORMS = mapOf<Int, Matrix.() -> Unit>(
ORIENTATION_FLIP_HORIZONTAL to {
postScale(-1f, 1f)
},
ORIENTATION_ROTATE_180 to {
postRotate(180f)
},
ORIENTATION_FLIP_VERTICAL to {
postScale(1f, -1f)
},
ORIENTATION_TRANSPOSE to {
postRotate(90f)
postScale(1f, -1f)
},
ORIENTATION_ROTATE_90 to {
postRotate(90f)
},
ORIENTATION_TRANSVERSE to {
postRotate(90f)
postScale(-1f, 1f)
},
ORIENTATION_ROTATE_270 to {
postRotate(270f)
}
)
private val TYPE_SIZES = mapOf(
TYPE_BYTE to 1L,
TYPE_ASCII to 1L,
TYPE_SHORT to SHORT_SIZE.toLong(),
TYPE_LONG to LONG_SIZE.toLong()
)
private fun Long.toIntOrNull(): Int? = takeIf {
it in 0..Int.MAX_VALUE.toLong()
}?.toInt()
}
}
@@ -0,0 +1,38 @@
/*
* 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.core.data.coil
import coil3.map.Mapper
import coil3.request.Options
import coil3.toUri
import com.t8rin.imagetoolbox.core.utils.UriReplacements
internal object OriginalUriMapper : Mapper<Any, Any> {
override fun map(
data: Any,
options: Options
): Any? = when (data) {
is String -> UriReplacements.resolve(data).takeIf { it != data }
is android.net.Uri -> UriReplacements.resolve(data).takeIf { it != data }
is coil3.Uri -> UriReplacements.resolve(data.toString())
.takeIf { it != data.toString() }
?.toUri()
else -> null
}
}
@@ -0,0 +1,152 @@
/*
* 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>.
*/
@file:Suppress("FunctionName", "unused")
package com.t8rin.imagetoolbox.core.data.coil
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.core.graphics.createBitmap
import coil3.Extras
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.getExtra
import coil3.request.ImageRequest
import coil3.request.Options
import coil3.size.Size
import coil3.size.pxOrElse
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.flexibleResize
import com.t8rin.imagetoolbox.core.utils.appContext
import okio.ByteString.Companion.toByteString
import kotlin.math.roundToInt
internal class PdfDecoder(
private val source: ImageSource,
private val options: Options,
) : Decoder {
override suspend fun decode(): DecodeResult {
val file = source.file().toFile()
val image = ParcelFileDescriptor.open(
file,
ParcelFileDescriptor.MODE_READ_ONLY
).use { fileDescriptor ->
PdfRenderer(fileDescriptor).use { renderer ->
val pageIndex = options.pdfPage.coerceIn(0, renderer.pageCount - 1)
renderer.openPage(pageIndex).use { page ->
val originalWidth = page.width
val originalHeight = page.height
val targetSize = IntegerSize(
width = originalWidth,
height = originalHeight
).flexibleResize(
w = options.size.width.pxOrElse { 0 },
h = options.size.height.pxOrElse { 0 }
)
val scaleX = targetSize.width.toFloat() / originalWidth
val scaleY = targetSize.height.toFloat() / originalHeight
val scale = minOf(scaleX, scaleY).coerceAtMost(2f)
val bitmap = createBitmap(
(originalWidth * scale).roundToInt().coerceAtLeast(1),
(originalHeight * scale).roundToInt().coerceAtLeast(1)
).apply {
eraseColor(Color.WHITE)
}
page.render(
bitmap,
null,
Matrix().apply { setScale(scale, scale) },
PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
)
bitmap.asImage()
}
}
}
return DecodeResult(
image = image,
isSampled = true
)
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? {
return if (isPdf(result)) {
PdfDecoder(
source = result.source,
options = options
)
} else null
}
private fun isPdf(result: SourceFetchResult): Boolean {
val pdfMagic = byteArrayOf(0x25, 0x50, 0x44, 0x46).toByteString()
return result.source.source()
.rangeEquals(0, pdfMagic) || result.mimeType == "application/pdf"
}
}
}
fun PdfImageRequest(
data: Any?,
pdfPage: Int = 0,
size: Size? = null
): ImageRequest = ImageRequest.Builder(appContext)
.data(data)
.pdfPage(pdfPage)
.memoryCacheKey(data.toString() + pdfPage)
.diskCacheKey(data.toString() + pdfPage)
.apply {
size?.let(::size)
}
.build()
fun ImageRequest.Builder.pdfPage(pdfPage: Int) = apply {
extras[pdfPageKey] = pdfPage
}
val ImageRequest.pdfPage: Int
get() = getExtra(pdfPageKey)
val Options.pdfPage: Int
get() = getExtra(pdfPageKey)
val Extras.Key.Companion.pdfPage: Extras.Key<Int>
get() = pdfPageKey
private val pdfPageKey = Extras.Key(default = 0)
@@ -0,0 +1,114 @@
/*
* 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.core.data.coil
import coil3.ImageLoader
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.svg.SvgDecoder
import coil3.svg.isSvg
import com.hashsequence.coilresvg.ResvgDecoder
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.utils.makeLog
internal class SvgDecoderCompat(
private val source: ImageSource,
options: Options,
minimumSize: Int? = null
) : Decoder {
private val options = minimumSize?.let {
options.copy(
size = options.size.coerceAtLeast(minimumSize)
)
} ?: options
private fun source() = ImageSource(
file = source.file(),
fileSystem = source.fileSystem,
metadata = source.metadata
)
override suspend fun decode(): DecodeResult {
if (!isResvgAvailable) return decodeDefault()
return runSuspendCatching {
decodeNative()
}.onFailure {
if (it is LinkageError) isResvgAvailable = false
}.getOrNull() ?: decodeDefault()
}
private suspend fun decodeNative() = ResvgDecoder(
source = source(),
options = options
).decode()
private suspend fun decodeDefault() = SvgDecoder(
source = source(),
options = options
).decode().also {
"fallback coil-svg decoder".makeLog()
}
private fun Size.coerceAtLeast(size: Int): Size = Size(
width = width.pxOrElse { 0 }.coerceAtLeast(size),
height = height.pxOrElse { 0 }.coerceAtLeast(size)
)
class Factory(
private val minimumSize: Int? = null
) : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? {
if (!isApplicable(result)) return null
return SvgDecoderCompat(
source = result.source,
options = options,
minimumSize = minimumSize
)
}
private fun isApplicable(result: SourceFetchResult): Boolean {
return result.mimeType == MIME_TYPE_SVG ||
result.mimeType == MIME_TYPE_XML ||
DecodeUtils.isSvg(result.source.source())
}
private companion object {
private const val MIME_TYPE_SVG = "image/svg+xml"
private const val MIME_TYPE_XML = "text/xml"
}
}
private companion object {
@Volatile
private var isResvgAvailable = true
}
}
@@ -0,0 +1,105 @@
/*
* 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.core.data.coil
import android.graphics.Bitmap
import android.os.Build
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.bitmapConfig
import coil3.size.Size
import coil3.size.pxOrElse
import okio.BufferedSource
import okio.ByteString.Companion.toByteString
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import oupson.apng.utils.Utils.flexibleResize
internal class TiffDecoder private constructor(
private val source: ImageSource,
private val options: Options
) : Decoder {
@Suppress("DEPRECATION")
override suspend fun decode(): DecodeResult? {
val config = options.bitmapConfig.takeIf {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
it != Bitmap.Config.HARDWARE
} else true
} ?: Bitmap.Config.ARGB_8888
val decoded = runCatching {
TiffBitmapFactory.decodeFile(
source.file().toFile()
)
}.getOrNull() ?: return null
val image = decoded
.createScaledBitmap(options.size)
.copy(config, false)
.asImage()
return DecodeResult(
image = image,
isSampled = options.size != Size.ORIGINAL
)
}
private fun Bitmap.createScaledBitmap(
size: Size
): Bitmap {
if (size == Size.ORIGINAL) return this
return flexibleResize(
maxOf(
size.width.pxOrElse { 1 },
size.height.pxOrElse { 1 }
)
)
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? {
return if (isTiff(result.source.source())) {
TiffDecoder(
source = result.source,
options = options
)
} else null
}
private fun isTiff(source: BufferedSource): Boolean {
val magic1 = byteArrayOf(0x49, 0x49, 0x2a, 0x00)
val magic2 = byteArrayOf(0x4d, 0x4d, 0x00, 0x2a)
val cr2Magic = byteArrayOf(0x49, 0x49, 0x2a, 0x00, 0x10, 0x00, 0x00, 0x00, 0x43, 0x52)
if (source.rangeEquals(0, cr2Magic.toByteString())) return false
if (source.rangeEquals(0, magic1.toByteString())) return true
return source.rangeEquals(0, magic2.toByteString())
}
}
}
@@ -0,0 +1,49 @@
/*
* 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.core.data.coil
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.transformations
import com.t8rin.imagetoolbox.core.utils.makeLog
internal object TimeMeasureInterceptor : Interceptor {
override suspend fun intercept(
chain: Interceptor.Chain
): ImageResult {
val time = System.currentTimeMillis()
val result = chain.proceed()
val endTime = System.currentTimeMillis()
val delta = endTime - time
val transformations = chain.request.transformations.joinToString(", ") {
it.toString()
}
if (transformations.isNotEmpty()) {
"Time $delta ms for transformations = $transformations, with ${result.request.sizeResolver.size()}".makeLog(
"RealImageLoader"
)
}
"Time $delta ms for ${chain.size}".makeLog("RealImageLoader")
return result
}
}
@@ -0,0 +1,103 @@
/*
* 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.core.data.coil
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Scale
import coil3.size.Size
import coil3.size.pxOrElse
import com.t8rin.trickle.VvcDecoder
import com.t8rin.trickle.VvcScaleMode
import kotlinx.coroutines.runInterruptible
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
class VVCDecoder(
private val source: SourceFetchResult,
private val options: Options,
private val exceptionLogger: ((Exception) -> Unit)? = null
) : Decoder {
override suspend fun decode(): DecodeResult? = runInterruptible {
try {
val sourceData = source.source.source().readByteArray()
if (options.size == Size.ORIGINAL) {
return@runInterruptible DecodeResult(
image = VvcDecoder.decode(sourceData).asImage(),
isSampled = false
)
}
val width = options.size.width.pxOrElse { 0 }
val height = options.size.height.pxOrElse { 0 }
val scaleMode = when (options.scale) {
Scale.FILL -> VvcScaleMode.FILL
Scale.FIT -> VvcScaleMode.FIT
}
DecodeResult(
image = VvcDecoder.decodeSampled(
encoded = sourceData,
scaledWidth = width,
scaledHeight = height,
scaleMode = scaleMode
).asImage(),
isSampled = true
)
} catch (e: Exception) {
exceptionLogger?.invoke(e)
null
}
}
class Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder? {
val source = result.source.source()
val isRawVvc = RAW_VVC_START_CODES.any { source.rangeEquals(0, it) }
val isVvcInHeif = source.rangeEquals(4, FTYP) &&
source.indexOf(VVC_ITEM_TYPE, 0, SEARCH_LIMIT) >= 0
return if (isRawVvc || isVvcInHeif) {
VVCDecoder(
source = result,
options = options
)
} else null
}
private companion object {
const val SEARCH_LIMIT = 64L * 1024L
val FTYP = "ftyp".encodeUtf8()
val VVC_ITEM_TYPE = "vvc1".encodeUtf8()
val RAW_VVC_START_CODES = listOf(
byteArrayOf(0, 0, 1).toByteString(),
byteArrayOf(0, 0, 0, 1).toByteString()
)
}
}
}
@@ -0,0 +1,35 @@
/*
* 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.core.data.coroutines
import com.t8rin.imagetoolbox.core.di.DecodingDispatcher
import com.t8rin.imagetoolbox.core.di.DefaultDispatcher
import com.t8rin.imagetoolbox.core.di.EncodingDispatcher
import com.t8rin.imagetoolbox.core.di.IoDispatcher
import com.t8rin.imagetoolbox.core.di.UiDispatcher
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
internal data class AndroidDispatchersHolder @Inject constructor(
@UiDispatcher override val uiDispatcher: CoroutineContext,
@IoDispatcher override val ioDispatcher: CoroutineContext,
@EncodingDispatcher override val encodingDispatcher: CoroutineContext,
@DecodingDispatcher override val decodingDispatcher: CoroutineContext,
@DefaultDispatcher override val defaultDispatcher: CoroutineContext
) : DispatchersHolder
@@ -0,0 +1,34 @@
/*
* 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.core.data.coroutines
import com.t8rin.imagetoolbox.core.domain.coroutines.AppScope
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.utils.makeLog
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.SupervisorJob
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
internal class AppScopeImpl @Inject constructor(
dispatchersHolder: DispatchersHolder
) : AppScope, DispatchersHolder by dispatchersHolder {
override val coroutineContext: CoroutineContext =
defaultDispatcher + CoroutineExceptionHandler { _, e -> e.makeLog("AppScopeImpl") } + SupervisorJob()
}
@@ -0,0 +1,94 @@
/*
* 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.core.data.di
import com.t8rin.imagetoolbox.core.data.coroutines.AndroidDispatchersHolder
import com.t8rin.imagetoolbox.core.data.coroutines.AppScopeImpl
import com.t8rin.imagetoolbox.core.data.utils.executorDispatcher
import com.t8rin.imagetoolbox.core.di.DecodingDispatcher
import com.t8rin.imagetoolbox.core.di.DefaultDispatcher
import com.t8rin.imagetoolbox.core.di.EncodingDispatcher
import com.t8rin.imagetoolbox.core.di.IoDispatcher
import com.t8rin.imagetoolbox.core.di.UiDispatcher
import com.t8rin.imagetoolbox.core.domain.coroutines.AppScope
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.Executors
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext
@Module
@InstallIn(SingletonComponent::class)
internal interface CoroutinesModule {
@Binds
@Singleton
fun dispatchersHolder(
dispatchers: AndroidDispatchersHolder
): DispatchersHolder
@Binds
@Singleton
fun appScope(
impl: AppScopeImpl
): AppScope
companion object {
@DefaultDispatcher
@Singleton
@Provides
fun defaultDispatcher(): CoroutineContext = executorDispatcher {
Executors.newCachedThreadPool()
}
@DecodingDispatcher
@Singleton
@Provides
fun decodingDispatcher(): CoroutineContext = executorDispatcher {
Executors.newFixedThreadPool(
2 * Runtime.getRuntime().availableProcessors() + 1
)
}
@EncodingDispatcher
@Singleton
@Provides
fun encodingDispatcher(): CoroutineContext = executorDispatcher {
Executors.newSingleThreadExecutor()
}
@IoDispatcher
@Singleton
@Provides
fun ioDispatcher(): CoroutineContext = Dispatchers.IO
@UiDispatcher
@Singleton
@Provides
fun uiDispatcher(): CoroutineContext = Dispatchers.Main.immediate
}
}
@@ -0,0 +1,138 @@
/*
* 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.core.data.di
import android.content.Context
import android.os.Build
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.imageLoader
import coil3.memory.MemoryCache
import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.allowHardware
import coil3.request.maxBitmapSize
import coil3.size.Size
import coil3.util.Logger
import com.awxkee.jxlcoder.coil.AnimatedJxlDecoder
import com.gemalto.jp2.coil.Jpeg2000Decoder
import com.t8rin.awebp.coil.AnimatedWebPDecoder
import com.t8rin.djvu_coder.coil.DjvuDecoder
import com.t8rin.imagetoolbox.core.data.coil.Base64Fetcher
import com.t8rin.imagetoolbox.core.data.coil.CoilLogger
import com.t8rin.imagetoolbox.core.data.coil.HeifDecoder
import com.t8rin.imagetoolbox.core.data.coil.NefDecoder
import com.t8rin.imagetoolbox.core.data.coil.OriginalUriMapper
import com.t8rin.imagetoolbox.core.data.coil.PdfDecoder
import com.t8rin.imagetoolbox.core.data.coil.SvgDecoderCompat
import com.t8rin.imagetoolbox.core.data.coil.TiffDecoder
import com.t8rin.imagetoolbox.core.data.coil.TimeMeasureInterceptor
import com.t8rin.imagetoolbox.core.data.coil.VVCDecoder
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.resources.BuildConfig
import com.t8rin.psd.coil.PsdDecoder
import com.t8rin.qoi_coder.coil.QoiDecoder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import oupson.apng.coil.AnimatedPngDecoder
import java.io.File
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ImageLoaderModule {
@Provides
@Singleton
fun provideImageLoader(
@ApplicationContext context: Context,
logger: Logger?,
componentRegistry: ComponentRegistry,
dispatchersHolder: DispatchersHolder
): ImageLoader = context.imageLoader.newBuilder()
.components(componentRegistry)
.coroutineContext(dispatchersHolder.defaultDispatcher)
.decoderCoroutineContext(dispatchersHolder.decodingDispatcher)
.fetcherCoroutineContext(dispatchersHolder.ioDispatcher)
.allowHardware(false)
.maxBitmapSize(Size.ORIGINAL)
.diskCache {
DiskCache.Builder()
.directory(File(context.cacheDir, "coil").apply(File::mkdirs))
.maxSizePercent(0.2)
.cleanupCoroutineContext(dispatchersHolder.ioDispatcher)
.build()
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.3)
.build()
}
.logger(logger)
.build()
.also(SingletonImageLoader::setUnsafe)
@Provides
@Singleton
fun provideCoilLogger(): Logger = CoilLogger()
@Provides
@Singleton
fun provideComponentRegistry(
client: HttpClient
): ComponentRegistry = ComponentRegistry.Builder()
.apply {
add(OriginalUriMapper)
add(
KtorNetworkFetcherFactory(
httpClient = client,
concurrentRequestStrategy = DeDupeConcurrentRequestStrategy()
)
)
add(AnimatedPngDecoder.Factory())
if (Build.VERSION.SDK_INT >= 28) add(AnimatedImageDecoder.Factory())
else {
add(GifDecoder.Factory())
add(AnimatedWebPDecoder.Factory())
}
add(SvgDecoderCompat.Factory())
add(VVCDecoder.Factory())
add(HeifDecoder.Factory())
add(AnimatedJxlDecoder.Factory())
add(Jpeg2000Decoder.Factory())
add(NefDecoder.Factory())
add(TiffDecoder.Factory())
add(QoiDecoder.Factory())
add(PsdDecoder.Factory())
add(DjvuDecoder.Factory())
add(Base64Fetcher.Factory())
add(PdfDecoder.Factory())
if (BuildConfig.DEBUG) add(TimeMeasureInterceptor)
}
.build()
}
@@ -0,0 +1,94 @@
/*
* 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.core.data.di
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.AndroidImageCompressor
import com.t8rin.imagetoolbox.core.data.image.AndroidImageGetter
import com.t8rin.imagetoolbox.core.data.image.AndroidImagePreviewCreator
import com.t8rin.imagetoolbox.core.data.image.AndroidImageScaler
import com.t8rin.imagetoolbox.core.data.image.AndroidImageTransformer
import com.t8rin.imagetoolbox.core.data.image.AndroidShareProvider
import com.t8rin.imagetoolbox.core.data.image.ImageExportProfilesUseCaseImpl
import com.t8rin.imagetoolbox.core.domain.image.ImageCompressor
import com.t8rin.imagetoolbox.core.domain.image.ImageExportProfilesUseCase
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImagePreviewCreator
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.ImageShareProvider
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.image.ShareProvider
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 ImageModule {
@Singleton
@Binds
fun provideImageManager(
transformer: AndroidImageTransformer
): ImageTransformer<Bitmap>
@Singleton
@Binds
fun provideImageScaler(
scaler: AndroidImageScaler
): ImageScaler<Bitmap>
@Singleton
@Binds
fun provideImageCompressor(
compressor: AndroidImageCompressor
): ImageCompressor<Bitmap>
@Singleton
@Binds
fun provideImageGetter(
getter: AndroidImageGetter
): ImageGetter<Bitmap>
@Singleton
@Binds
fun provideImagePreviewCreator(
creator: AndroidImagePreviewCreator
): ImagePreviewCreator<Bitmap>
@Singleton
@Binds
fun provideShareProvider(
provider: AndroidShareProvider
): ShareProvider
@Singleton
@Binds
fun provideImageShareProvider(
provider: AndroidShareProvider
): ImageShareProvider<Bitmap>
@Singleton
@Binds
fun provideImageExportProfilesUseCase(
useCase: ImageExportProfilesUseCaseImpl
): ImageExportProfilesUseCase
}
@@ -0,0 +1,94 @@
/*
* 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.core.data.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.t8rin.imagetoolbox.core.data.json.ImageFormatJsonAdapter
import com.t8rin.imagetoolbox.core.data.json.ImageScaleModeJsonAdapter
import com.t8rin.imagetoolbox.core.data.json.MoshiParser
import com.t8rin.imagetoolbox.core.data.json.PresetJsonAdapter
import com.t8rin.imagetoolbox.core.data.json.ResizeTypeJsonAdapter
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.imagetoolbox.core.domain.json.JsonParser
import com.t8rin.imagetoolbox.core.settings.domain.model.FilenameBehavior
import com.t8rin.imagetoolbox.core.settings.domain.model.ShapeType
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface JsonModule {
@Binds
@Singleton
fun parser(
impl: MoshiParser,
): JsonParser
companion object {
@Provides
@Singleton
fun moshi(): Moshi = Moshi.Builder()
.add(
PolymorphicJsonAdapterFactory.of(Quality::class.java, "quality_type")
.withSubtype(Quality.Jxl::class.java, "jxl")
.withSubtype(Quality.Avif::class.java, "avif")
.withSubtype(Quality.Heic::class.java, "heic")
.withSubtype(Quality.Vvc::class.java, "vvc")
.withSubtype(Quality.PngLossy::class.java, "png")
.withSubtype(Quality.PngQuant::class.java, "pngquant")
.withSubtype(Quality.Tiff::class.java, "tiff")
.withSubtype(Quality.Base::class.java, "base")
.withDefaultValue(Quality.Base())
)
.add(
PolymorphicJsonAdapterFactory.of(ShapeType::class.java, "shape_type")
.withSubtype(ShapeType.Rounded::class.java, "rounded")
.withSubtype(ShapeType.Cut::class.java, "cut")
.withSubtype(ShapeType.Squircle::class.java, "squircle")
.withSubtype(ShapeType.Smooth::class.java, "smooth")
.withSubtype(ShapeType.Wavy::class.java, "wavy")
.withSubtype(ShapeType.Scoop::class.java, "scoop")
.withSubtype(ShapeType.Notch::class.java, "notch")
.withDefaultValue(ShapeType.Rounded())
)
.add(
PolymorphicJsonAdapterFactory.of(FilenameBehavior::class.java, "filename_type")
.withSubtype(FilenameBehavior.None::class.java, "none")
.withSubtype(FilenameBehavior.Overwrite::class.java, "overwrite")
.withSubtype(FilenameBehavior.Checksum::class.java, "checksum")
.withSubtype(FilenameBehavior.Random::class.java, "random")
.withDefaultValue(FilenameBehavior.None())
)
.add(ImageFormatJsonAdapter())
.add(PresetJsonAdapter())
.add(ResizeTypeJsonAdapter())
.add(ImageScaleModeJsonAdapter())
.addLast(KotlinJsonAdapterFactory())
.build()
}
}
@@ -0,0 +1,45 @@
/*
* 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.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.t8rin.imagetoolbox.core.domain.GLOBAL_STORAGE_NAME
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object LocalModule {
@Provides
@Singleton
fun dataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile(GLOBAL_STORAGE_NAME) }
)
}
@@ -0,0 +1,76 @@
/*
* 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.core.data.di
import com.t8rin.imagetoolbox.core.data.remote.AndroidDownloadManager
import com.t8rin.imagetoolbox.core.data.remote.AndroidRemoteResourcesStore
import com.t8rin.imagetoolbox.core.domain.remote.DownloadManager
import com.t8rin.imagetoolbox.core.domain.remote.RemoteResourcesStore
import com.t8rin.imagetoolbox.core.utils.makeLog
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes
@Module
@InstallIn(SingletonComponent::class)
internal interface RemoteModule {
@Binds
@Singleton
fun remoteResources(
impl: AndroidRemoteResourcesStore
): RemoteResourcesStore
@Binds
@Singleton
fun downloadManager(
impl: AndroidDownloadManager
): DownloadManager
companion object {
@Provides
@Singleton
fun client(): HttpClient = HttpClient {
install(HttpTimeout) {
val timeout = 5.minutes.inWholeMilliseconds
requestTimeoutMillis = timeout
connectTimeoutMillis = timeout
socketTimeoutMillis = timeout
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
message.makeLog("Ktor")
}
}
level = LogLevel.HEADERS
}
}
}
}
@@ -0,0 +1,40 @@
/*
* 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.core.data.di
import com.t8rin.imagetoolbox.core.data.resource.AndroidResourceManager
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
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 ResourcesModule {
@Binds
@Singleton
fun resManager(
impl: AndroidResourceManager
): ResourceManager
}
@@ -0,0 +1,80 @@
/*
* 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.core.data.di
import com.t8rin.imagetoolbox.core.data.history.AppHistoryRepositoryImpl
import com.t8rin.imagetoolbox.core.data.saving.AndroidFileController
import com.t8rin.imagetoolbox.core.data.saving.AndroidFilenameCreator
import com.t8rin.imagetoolbox.core.data.saving.AndroidKeepAliveService
import com.t8rin.imagetoolbox.core.data.saving.FileControllerEventEmitter
import com.t8rin.imagetoolbox.core.data.saving.OriginalFileDeletionHelper
import com.t8rin.imagetoolbox.core.domain.history.AppHistoryRepository
import com.t8rin.imagetoolbox.core.domain.image.MetadataProvider
import com.t8rin.imagetoolbox.core.domain.saving.FileController
import com.t8rin.imagetoolbox.core.domain.saving.FileController.Companion.toMetadataProvider
import com.t8rin.imagetoolbox.core.domain.saving.FilenameCreator
import com.t8rin.imagetoolbox.core.domain.saving.KeepAliveService
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SavingModule {
@Singleton
@Binds
fun provideFileController(
impl: AndroidFileController
): FileController
@Binds
fun provideFileControllerEventEmitter(
impl: OriginalFileDeletionHelper
): FileControllerEventEmitter
@Singleton
@Binds
fun filenameCreator(
impl: AndroidFilenameCreator
): FilenameCreator
@Singleton
@Binds
fun service(
impl: AndroidKeepAliveService
): KeepAliveService
@Singleton
@Binds
fun history(
impl: AppHistoryRepositoryImpl
): AppHistoryRepository
companion object {
@Singleton
@Provides
fun provideMetadata(
impl: AndroidFileController
): MetadataProvider = impl.toMetadataProvider()
}
}
@@ -0,0 +1,211 @@
/*
* 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.core.data.history
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.t8rin.imagetoolbox.core.domain.history.AppHistoryRepository
import com.t8rin.imagetoolbox.core.domain.history.model.AppUsageStatistics
import com.t8rin.imagetoolbox.core.domain.history.model.LastUsedTool
import com.t8rin.imagetoolbox.core.domain.json.JsonParser
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.time.LocalDate
import javax.inject.Inject
internal class AppHistoryRepositoryImpl @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val jsonParser: JsonParser,
private val settingsProvider: SettingsProvider
) : AppHistoryRepository {
private val rawLastUsedTools: Flow<List<LastUsedTool>>
get() = dataStore.data.map { preferences ->
preferences[LAST_USED_TOOLS]?.let { lastTools ->
jsonParser.fromJson<LastUsedTools>(
json = lastTools,
type = LastUsedTools::class.java
)?.tools
}.orEmpty()
}
override fun lastUsedTools(
maxCount: Int
): Flow<List<LastUsedTool>> = settingsProvider.settingsState.flatMapLatest { settings ->
if (settings.showToolsHistory) {
rawLastUsedTools.map { rawList ->
rawList.sortedByDescending { it.openCount }.let {
if (maxCount < Int.MAX_VALUE) {
val last = rawList.lastOrNull()
val limited = it.take(maxCount)
if (last != null) {
listOf(last) + limited.minus(last)
} else {
limited
}
} else {
it
}
}
}
} else {
flowOf(emptyList())
}
}
override fun toolUsageStatistics(): Flow<List<LastUsedTool>> = rawLastUsedTools.map { rawList ->
rawList.sortedWith(
compareByDescending<LastUsedTool> { it.openCount }
.thenByDescending { it.lastOpenedTimestamp }
)
}
override fun successfulSavesCount(): Flow<Int> = dataStore.data.map { preferences ->
preferences[SUCCESSFUL_SAVES_COUNT] ?: 0
}
override fun appUsageStatistics(): Flow<AppUsageStatistics> =
dataStore.data.map { preferences ->
AppUsageStatistics(
successfulSavesCount = preferences[SUCCESSFUL_SAVES_COUNT] ?: 0,
savedBytes = preferences[SAVED_BYTES] ?: 0,
lastActivityDayEpoch = preferences[LAST_ACTIVITY_DAY_EPOCH] ?: 0,
currentActivityStreak = preferences[CURRENT_ACTIVITY_STREAK] ?: 0,
savedFormatCounts = preferences[SAVED_FORMAT_COUNTERS]?.let { counters ->
jsonParser.fromJson<SavedFormatCounters>(
json = counters,
type = SavedFormatCounters::class.java
)?.counters
}.orEmpty()
)
}
override suspend fun pushLastTool(screenId: Int) {
dataStore.edit { preferences ->
val todayEpochDay = LocalDate.now().toEpochDay()
val lastActivityDayEpoch = preferences[LAST_ACTIVITY_DAY_EPOCH]
if (lastActivityDayEpoch != todayEpochDay) {
preferences[CURRENT_ACTIVITY_STREAK] =
if (lastActivityDayEpoch == todayEpochDay - 1) {
(preferences[CURRENT_ACTIVITY_STREAK] ?: 0) + 1
} else {
1
}
preferences[LAST_ACTIVITY_DAY_EPOCH] = todayEpochDay
}
val current = preferences[LAST_USED_TOOLS]?.let {
jsonParser.fromJson(
json = it,
type = LastUsedTools::class.java
)
} ?: LastUsedTools(emptyList())
val screenEntry = current.tools.find {
it.screenId == screenId
} ?: LastUsedTool(
screenId = screenId,
openCount = 0,
lastOpenedTimestamp = System.currentTimeMillis()
)
val newValue = current.copy(
tools = current.tools
.minus(screenEntry)
.plus(
screenEntry.copy(
openCount = screenEntry.openCount + 1,
lastOpenedTimestamp = System.currentTimeMillis()
)
)
)
preferences[LAST_USED_TOOLS] = jsonParser.toJson(
obj = newValue,
type = LastUsedTools::class.java
) ?: preferences[LAST_USED_TOOLS].orEmpty()
}
}
override suspend fun registerSuccessfulSave(
savedBytes: Long,
savedFormat: String
) {
dataStore.edit { preferences ->
preferences[SUCCESSFUL_SAVES_COUNT] = (preferences[SUCCESSFUL_SAVES_COUNT] ?: 0) + 1
preferences[SAVED_BYTES] = (preferences[SAVED_BYTES] ?: 0) + savedBytes.coerceAtLeast(0)
val format = savedFormat.lowercase().trim()
if (format.isNotEmpty()) {
val current = preferences[SAVED_FORMAT_COUNTERS]?.let {
runSuspendCatching {
jsonParser.fromJson<SavedFormatCounters>(
json = it,
type = SavedFormatCounters::class.java
)
}.getOrNull()
} ?: SavedFormatCounters(emptyMap())
preferences[SAVED_FORMAT_COUNTERS] = jsonParser.toJson(
obj = current.copy(
counters = current.counters + (format to ((current.counters[format]
?: 0) + 1))
),
type = SavedFormatCounters::class.java
) ?: preferences[SAVED_FORMAT_COUNTERS].orEmpty()
}
}
}
override suspend fun resetUsageStatistics() {
dataStore.edit { preferences ->
preferences.remove(LAST_USED_TOOLS)
preferences.remove(SUCCESSFUL_SAVES_COUNT)
preferences.remove(SAVED_BYTES)
preferences.remove(LAST_ACTIVITY_DAY_EPOCH)
preferences.remove(CURRENT_ACTIVITY_STREAK)
preferences.remove(SAVED_FORMAT_COUNTERS)
}
}
}
private data class LastUsedTools(
val tools: List<LastUsedTool>
)
private data class SavedFormatCounters(
val counters: Map<String, Int>
)
private val LAST_USED_TOOLS = stringPreferencesKey("LAST_USED_TOOLS")
private val SUCCESSFUL_SAVES_COUNT = intPreferencesKey("SUCCESSFUL_SAVES_COUNT")
private val SAVED_BYTES = longPreferencesKey("SAVED_BYTES")
private val LAST_ACTIVITY_DAY_EPOCH = longPreferencesKey("LAST_ACTIVITY_DAY_EPOCH")
private val CURRENT_ACTIVITY_STREAK = intPreferencesKey("CURRENT_ACTIVITY_STREAK")
private val SAVED_FORMAT_COUNTERS = stringPreferencesKey("SAVED_FORMAT_COUNTERS")
@@ -0,0 +1,174 @@
/*
* 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.core.data.image
import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.data.utils.toSoftware
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageCompressor
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.image.ShareProvider
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.imagetoolbox.core.domain.image.model.alphaContainedEntries
import com.t8rin.imagetoolbox.core.domain.model.sizeTo
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
import com.t8rin.imagetoolbox.core.settings.domain.model.FilenameBehavior
import com.t8rin.imagetoolbox.core.utils.fileSize
import com.t8rin.trickle.Trickle
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class AndroidImageCompressor @Inject constructor(
@ApplicationContext private val context: Context,
private val imageTransformer: ImageTransformer<Bitmap>,
private val imageScaler: ImageScaler<Bitmap>,
private val imageGetter: ImageGetter<Bitmap>,
private val shareProvider: Lazy<ShareProvider>,
private val settingsProvider: SettingsProvider,
dispatchersHolder: DispatchersHolder
) : DispatchersHolder by dispatchersHolder, ImageCompressor<Bitmap> {
private val settingsState get() = settingsProvider.settingsState.value
override suspend fun compress(
image: Bitmap,
imageFormat: ImageFormat,
quality: Quality
): ByteArray = withContext(encodingDispatcher) {
val coercedQuality = quality.coerceIn(imageFormat)
val transformedImage = image.toSoftware().let { software ->
val enableForAlpha = settingsState.enableBackgroundColorForAlphaFormats
val isNonAlpha = imageFormat !in ImageFormat.alphaContainedEntries
if (isNonAlpha || coercedQuality.isNonAlpha() || enableForAlpha) {
withContext(defaultDispatcher) {
Trickle.drawColorBehind(
color = settingsState.backgroundForNoAlphaImageFormats.colorInt,
input = software
)
}
} else software
}
ImageCompressorBackend.Factory()
.create(
imageFormat = imageFormat,
context = context,
imageScaler = imageScaler
)
.compress(
image = transformedImage,
quality = coercedQuality
)
}
override suspend fun compressAndTransform(
image: Bitmap,
imageInfo: ImageInfo,
onImageReadyToCompressInterceptor: suspend (Bitmap) -> Bitmap,
applyImageTransformations: Boolean
): ByteArray = withContext(encodingDispatcher) {
val currentImage = if (applyImageTransformations) {
val size = imageInfo.originalUri?.let {
imageGetter.getImage(
data = it,
originalSize = true
)?.run { width sizeTo height }
}
ensureActive()
imageScaler
.scaleImage(
image = imageTransformer.rotate(
image = image.apply { setHasAlpha(true) },
degrees = imageInfo.rotationDegrees
),
width = imageInfo.width,
height = imageInfo.height,
resizeType = imageInfo.resizeType.withOriginalSizeIfCrop(size),
imageScaleMode = imageInfo.imageScaleMode
)
.let {
imageTransformer.flip(
image = it,
isFlipped = imageInfo.isFlipped
)
}
.let {
onImageReadyToCompressInterceptor(it)
}
} else onImageReadyToCompressInterceptor(image)
val extension = imageInfo.originalUri?.let { imageGetter.getExtension(it) }
val imageFormat =
if (settingsState.filenameBehavior is FilenameBehavior.Overwrite && extension != null) {
val target = ImageFormat[extension]
if (imageInfo.imageFormat.extension == target.extension) {
imageInfo.imageFormat
} else {
target
}
} else imageInfo.imageFormat
ensureActive()
compress(
image = currentImage,
imageFormat = imageFormat,
quality = imageInfo.quality
)
}
override suspend fun calculateImageSize(
image: Bitmap,
imageInfo: ImageInfo
): Long = withContext(encodingDispatcher) {
val newInfo = imageInfo.let {
if (it.width == 0 || it.height == 0) {
it.copy(
width = image.width,
height = image.height
)
} else it
}
compressAndTransform(
image = image,
imageInfo = newInfo
).let {
shareProvider.get().cacheByteArray(
byteArray = it,
filename = "temp.${newInfo.imageFormat.extension}"
)?.toUri()
?.fileSize() ?: it.size.toLong()
}
}
}
@@ -0,0 +1,263 @@
/*
* 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.core.data.image
import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.size.Precision
import coil3.size.Size
import coil3.toBitmap
import com.t8rin.imagetoolbox.core.data.image.utils.static
import com.t8rin.imagetoolbox.core.data.utils.toCoil
import com.t8rin.imagetoolbox.core.domain.coroutines.AppScope
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.MetadataProvider
import com.t8rin.imagetoolbox.core.domain.image.model.ImageData
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.saving.FailureNotifier
import com.t8rin.imagetoolbox.core.domain.transformation.Transformation
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
import com.t8rin.imagetoolbox.core.utils.extension
import com.t8rin.imagetoolbox.core.utils.makeLog
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class AndroidImageGetter @Inject constructor(
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
private val appScope: AppScope,
private val failureNotifier: FailureNotifier,
metadataProvider: Lazy<MetadataProvider>,
settingsProvider: SettingsProvider,
dispatchersHolder: DispatchersHolder,
) : DispatchersHolder by dispatchersHolder, ImageGetter<Bitmap> {
private val _settingsState = settingsProvider.settingsState
private val settingsState get() = _settingsState.value
private val metadataProvider by lazy {
metadataProvider.get()
}
override suspend fun getImage(
uri: String,
originalSize: Boolean,
onFailure: ((Throwable) -> Unit)?
): ImageData<Bitmap>? = withContext(defaultDispatcher) {
getImageImpl(
data = uri,
size = null,
addSizeToRequest = originalSize,
onFailure = onFailure ?: failureNotifier::send
)?.let { bitmap ->
ImageData(
image = bitmap,
imageInfo = ImageInfo(
width = bitmap.width,
height = bitmap.height,
imageFormat = settingsState.defaultImageFormat
?: ImageFormat[getExtension(uri)],
originalUri = uri,
resizeType = settingsState.defaultResizeType
),
metadata = metadataProvider.readMetadata(uri)
)
}
}
override suspend fun getImage(
data: Any,
originalSize: Boolean
): Bitmap? = getImageImpl(
data = data,
size = null,
addSizeToRequest = originalSize
)
override suspend fun getImage(
data: Any,
size: IntegerSize?
): Bitmap? = getImageImpl(
data = data,
size = size
)
override suspend fun getImage(
data: Any,
size: Int?
): Bitmap? = getImageImpl(
data = data,
size = size?.let {
IntegerSize(
width = it,
height = it
)
},
precision = Precision.INEXACT
)
override suspend fun getImageData(
uri: String,
size: Int?,
onFailure: (Throwable) -> Unit
): ImageData<Bitmap>? = withContext(defaultDispatcher) {
getImageImpl(
data = uri,
size = size?.let {
IntegerSize(
width = it,
height = it
)
},
precision = Precision.INEXACT,
onFailure = onFailure
)?.let { bitmap ->
ImageData(
image = bitmap,
imageInfo = ImageInfo(
width = bitmap.width,
height = bitmap.height,
imageFormat = settingsState.defaultImageFormat
?: ImageFormat[getExtension(uri)],
originalUri = uri,
resizeType = settingsState.defaultResizeType
),
metadata = metadataProvider.readMetadata(uri)
)
}
}
override suspend fun getImageWithTransformations(
uri: String,
transformations: List<Transformation<Bitmap>>,
originalSize: Boolean
): ImageData<Bitmap>? = withContext(defaultDispatcher) {
getImageImpl(
data = uri,
transformations = transformations,
size = null,
addSizeToRequest = originalSize
)?.let { bitmap ->
ImageData(
image = bitmap,
imageInfo = ImageInfo(
width = bitmap.width,
height = bitmap.height,
imageFormat = ImageFormat[getExtension(uri)],
originalUri = uri,
resizeType = settingsState.defaultResizeType
),
metadata = metadataProvider.readMetadata(uri)
)
}
}
override suspend fun getImageWithTransformations(
data: Any,
transformations: List<Transformation<Bitmap>>,
size: IntegerSize?
): Bitmap? = getImageImpl(
data = data,
transformations = transformations,
size = size
)
override fun getImageAsync(
uri: String,
originalSize: Boolean,
onGetImage: (ImageData<Bitmap>) -> Unit,
onFailure: (Throwable) -> Unit
) {
appScope.launch {
var failureDelivered = false
val imageData = getImage(
uri = uri,
originalSize = originalSize,
onFailure = {
failureDelivered = true
onFailure(it)
}
)
if (imageData != null) {
onGetImage(imageData)
} else if (!failureDelivered) {
onFailure(
IllegalStateException(context.getString(R.string.failed_to_open))
)
}
}
}
override fun getExtension(uri: String): String? = uri.toUri().extension(context)
private suspend fun getImageImpl(
data: Any,
size: IntegerSize?,
precision: Precision = Precision.EXACT,
transformations: List<Transformation<Bitmap>> = emptyList(),
onFailure: (Throwable) -> Unit = {},
addSizeToRequest: Boolean = true
): Bitmap? = withContext(defaultDispatcher) {
if ((size == null || !addSizeToRequest) && data is Bitmap) return@withContext data
val request = ImageRequest
.Builder(context)
.data(data)
.static()
.precision(precision)
.transformations(
transformations.map(Transformation<Bitmap>::toCoil)
)
.apply {
if (addSizeToRequest) {
size(
size?.let {
Size(size.width, size.height)
} ?: Size.ORIGINAL
)
}
}
.build()
ensureActive()
runSuspendCatching {
imageLoader.execute(request).image?.toBitmap()
}.onFailure {
it.makeLog("ImageGetter")
onFailure(it)
}.getOrNull()
}
}
@@ -0,0 +1,135 @@
/*
* 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.core.data.image
import android.graphics.Bitmap
import android.os.Build
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageCompressor
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImagePreviewCreator
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeType
import com.t8rin.imagetoolbox.core.domain.transformation.Transformation
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.roundToInt
internal class AndroidImagePreviewCreator @Inject constructor(
private val imageCompressor: ImageCompressor<Bitmap>,
private val imageGetter: ImageGetter<Bitmap>,
private val imageTransformer: ImageTransformer<Bitmap>,
private val imageScaler: ImageScaler<Bitmap>,
settingsProvider: SettingsProvider,
dispatchersHolder: DispatchersHolder
) : DispatchersHolder by dispatchersHolder, ImagePreviewCreator<Bitmap> {
private val _settingsState = settingsProvider.settingsState
private val settingsState get() = _settingsState.value
override suspend fun createPreview(
image: Bitmap,
imageInfo: ImageInfo,
transformations: List<Transformation<Bitmap>>,
onGetByteCount: (Int) -> Unit
): Bitmap? = withContext(defaultDispatcher) {
launch(encodingDispatcher) {
onGetByteCount(0)
ensureActive()
onGetByteCount(
imageCompressor.calculateImageSize(
image = image,
imageInfo = imageInfo
).toInt()
)
}
if (!settingsState.generatePreviews) return@withContext null
if (imageInfo.height == 0 || imageInfo.width == 0) return@withContext image
ensureActive()
val shouldTransform = transformations.isNotEmpty()
|| (imageInfo.width != image.width)
|| (imageInfo.height != image.height)
|| !imageInfo.quality.isDefault()
|| (imageInfo.rotationDegrees != 0f)
|| imageInfo.isFlipped
val targetImage = if (shouldTransform) {
var width = imageInfo.width
var height = imageInfo.height
var scaleFactor = 1f
while (height * width * 4 > SMALL_SIZE) {
height = (height * 0.85f).roundToInt()
width = (width * 0.85f).roundToInt()
scaleFactor *= 0.85f
}
ensureActive()
val bytes = imageCompressor.compressAndTransform(
image = image,
imageInfo = imageInfo.copy(
width = width,
height = height,
resizeType = if (imageInfo.resizeType is ResizeType.CenterCrop) {
(imageInfo.resizeType as ResizeType.CenterCrop).copy(scaleFactor = scaleFactor)
} else imageInfo.resizeType
),
onImageReadyToCompressInterceptor = {
ensureActive()
imageTransformer.transform(
image = it,
transformations = transformations
) ?: it
}
)
ensureActive()
imageGetter.getImage(bytes) ?: image
} else {
image
}
ensureActive()
imageScaler.scaleUntilCanShow(targetImage)
}
override fun canShow(image: Bitmap?): Boolean = image?.run { size() <= BIG_SIZE } ?: false
private val Bitmap.configSize: Int
get() = when (config) {
Bitmap.Config.RGB_565 -> 2
else -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (config == Bitmap.Config.RGBA_F16) 8 else 4
} else 4
}
}
private fun Bitmap.size(): Int = width * height * configSize
}
private const val SMALL_SIZE = 1500 * 1500 * 3
private const val BIG_SIZE = 3096 * 3096 * 4
@@ -0,0 +1,497 @@
/*
* 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.core.data.image
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PorterDuff
import androidx.core.graphics.BitmapCompat
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import com.awxkee.aire.Aire
import com.awxkee.aire.ResizeFunction
import com.t8rin.imagetoolbox.core.data.image.utils.drawBitmap
import com.t8rin.imagetoolbox.core.data.utils.aspectRatio
import com.t8rin.imagetoolbox.core.data.utils.safeConfig
import com.t8rin.imagetoolbox.core.data.utils.toSoftware
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.image.model.ImageScaleMode
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeAnchor
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeType
import com.t8rin.imagetoolbox.core.domain.image.model.ScaleColorSpace
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Position
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.filters.domain.FilterProvider
import com.t8rin.imagetoolbox.core.filters.domain.model.Filter
import com.t8rin.imagetoolbox.core.filters.domain.model.createFilter
import com.t8rin.imagetoolbox.core.settings.domain.SettingsProvider
import com.t8rin.imagetoolbox.core.utils.makeLog
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import com.awxkee.aire.ScaleColorSpace as AireScaleColorSpace
internal class AndroidImageScaler @Inject constructor(
settingsProvider: SettingsProvider,
private val imageTransformer: ImageTransformer<Bitmap>,
private val filterProvider: FilterProvider<Bitmap>,
dispatchersHolder: DispatchersHolder
) : DispatchersHolder by dispatchersHolder, ImageScaler<Bitmap> {
private val _settingsState = settingsProvider.settingsState
private val settingsState get() = _settingsState.value
override suspend fun scaleImage(
image: Bitmap,
width: Int,
height: Int,
resizeType: ResizeType,
imageScaleMode: ImageScaleMode
): Bitmap = withContext(defaultDispatcher) {
val widthInternal = width.takeIf { it > 0 } ?: image.width
val heightInternal = height.takeIf { it > 0 } ?: image.height
runSuspendCatching {
when (resizeType) {
ResizeType.Explicit -> {
createScaledBitmap(
image = image,
width = widthInternal,
height = heightInternal,
imageScaleMode = imageScaleMode
)
}
is ResizeType.Flexible -> {
flexibleResize(
image = image,
width = widthInternal,
height = heightInternal,
resizeAnchor = resizeType.resizeAnchor,
imageScaleMode = imageScaleMode
)
}
is ResizeType.CenterCrop -> {
resizeType.performCenterCrop(
image = image,
targetWidth = widthInternal,
targetHeight = heightInternal,
imageScaleMode = imageScaleMode
)
}
is ResizeType.Fit -> {
resizeType.performFitResize(
image = image,
targetWidth = widthInternal,
targetHeight = heightInternal,
imageScaleMode = imageScaleMode
)
}
}
}.onFailure {
it.makeLog("AndroidImageScaler")
}.getOrNull() ?: image
}
override suspend fun scaleUntilCanShow(
image: Bitmap?
): Bitmap? = withContext(defaultDispatcher) {
if (image == null) return@withContext null
if (canShow(image.width * image.height * 4)) return@withContext image
var (height, width) = image.run { height to width }
var iterations = 0
while (!canShow(size = height * width * 4)) {
height = (height * 0.85f).roundToInt()
width = (width * 0.85f).roundToInt()
iterations++
}
if (iterations == 0) image
else scaleImage(
image = image,
height = height,
width = width,
imageScaleMode = ImageScaleMode.Bicubic()
)
}
private fun canShow(size: Int): Boolean {
return size < 3096 * 3096 * 3
}
private suspend fun Bitmap.fitResize(
targetWidth: Int,
targetHeight: Int,
imageScaleMode: ImageScaleMode
): Bitmap {
val aspectRatio = width.toFloat() / height.toFloat()
val targetAspectRatio = targetWidth.toFloat() / targetHeight.toFloat()
val finalWidth: Int
val finalHeight: Int
if (aspectRatio > targetAspectRatio) {
// Image is wider than the target aspect ratio
finalWidth = targetWidth
finalHeight = (targetWidth / aspectRatio).toInt()
} else {
// Image is taller than or equal to the target aspect ratio
finalWidth = (targetHeight * aspectRatio).toInt()
finalHeight = targetHeight
}
return createScaledBitmap(
image = this,
width = finalWidth,
height = finalHeight,
imageScaleMode = imageScaleMode
)
}
private suspend fun ResizeType.Fit.performFitResize(
image: Bitmap,
targetWidth: Int,
targetHeight: Int,
imageScaleMode: ImageScaleMode
): Bitmap = withContext(defaultDispatcher) {
if (targetWidth == image.width && targetHeight == image.height) {
return@withContext image
}
val originalWidth: Int
val originalHeight: Int
val aspect = image.aspectRatio
val originalAspect = image.aspectRatio
if (abs(aspect - originalAspect) > 0.001f) {
originalWidth = image.height
originalHeight = image.width
} else {
originalWidth = image.width
originalHeight = image.height
}
val drawImage = image.fitResize(
targetWidth = targetWidth,
targetHeight = targetHeight,
imageScaleMode = imageScaleMode
)
val blurredBitmap = imageTransformer.transform(
image = drawImage.let { bitmap ->
val xScale: Float = targetWidth.toFloat() / originalWidth
val yScale: Float = targetHeight.toFloat() / originalHeight
val scale = xScale.coerceAtLeast(yScale)
createScaledBitmap(
image = bitmap,
width = (scale * originalWidth).toInt(),
height = (scale * originalHeight).toInt(),
imageScaleMode = imageScaleMode
)
},
transformations = listOf(
filterProvider.filterToTransformation(
createFilter<Float, Filter.NativeStackBlur>(
blurRadius.toFloat() / 1000 * max(targetWidth, targetHeight)
)
)
)
)
createBitmap(targetWidth, targetHeight, drawImage.safeConfig).apply { setHasAlpha(true) }
.applyCanvas {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
canvasColor?.let {
drawColor(it)
} ?: blurredBitmap?.let {
drawBitmap(
bitmap = blurredBitmap,
position = Position.Center
)
}
drawBitmap(
bitmap = drawImage,
position = position
)
}
}
private suspend fun ResizeType.CenterCrop.performCenterCrop(
image: Bitmap,
targetWidth: Int,
targetHeight: Int,
imageScaleMode: ImageScaleMode
): Bitmap = withContext(defaultDispatcher) {
val originalSize = if (!originalSize.isDefined()) {
IntegerSize(
width = image.width,
height = image.height
)
} else {
originalSize
} * scaleFactor
if (targetWidth == originalSize.width && targetHeight == originalSize.height) {
return@withContext image
}
val originalWidth: Int
val originalHeight: Int
val aspect = image.aspectRatio
val originalAspect = originalSize.aspectRatio
if (abs(aspect - originalAspect) > 0.001f) {
originalWidth = originalSize.height
originalHeight = originalSize.width
} else {
originalWidth = originalSize.width
originalHeight = originalSize.height
}
val drawImage = createScaledBitmap(
image = image,
width = originalWidth,
height = originalHeight,
imageScaleMode = imageScaleMode
)
val blurredBitmap = if (canvasColor == null) {
imageTransformer.transform(
image = drawImage.let { bitmap ->
val xScale: Float = targetWidth.toFloat() / originalWidth
val yScale: Float = targetHeight.toFloat() / originalHeight
val scale = xScale.coerceAtLeast(yScale)
createScaledBitmap(
image = bitmap,
width = (scale * originalWidth).toInt(),
height = (scale * originalHeight).toInt(),
imageScaleMode = imageScaleMode
)
},
transformations = listOf(
filterProvider.filterToTransformation(
createFilter<Float, Filter.NativeStackBlur>(
blurRadius.toFloat() / 1000 * max(targetWidth, targetHeight)
)
)
)
)
} else null
createBitmap(targetWidth, targetHeight, drawImage.safeConfig).apply { setHasAlpha(true) }
.applyCanvas {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
canvasColor?.let {
drawColor(it)
} ?: blurredBitmap?.let {
drawBitmap(
bitmap = blurredBitmap,
position = Position.Center
)
}
drawBitmap(
bitmap = drawImage,
position = position
)
}
}
private suspend fun createScaledBitmap(
image: Bitmap,
width: Int,
height: Int,
imageScaleMode: ImageScaleMode
): Bitmap = withContext(defaultDispatcher) {
if (width == image.width && height == image.height) return@withContext image
val softwareImage = image.toSoftware()
if (imageScaleMode is ImageScaleMode.Base) {
return@withContext if (width < softwareImage.width && height < softwareImage.width) {
BitmapCompat.createScaledBitmap(softwareImage, width, height, null, true)
} else {
softwareImage.scale(width, height)
}
}
val mode = imageScaleMode.takeIf {
it != ImageScaleMode.NotPresent && it.value >= 0
} ?: settingsState.defaultImageScaleMode
Aire.scale(
bitmap = softwareImage,
dstWidth = width,
dstHeight = height,
scaleMode = mode.toResizeFunction(),
colorSpace = mode.scaleColorSpace.toColorSpace()
)
}
private fun ImageScaleMode.toResizeFunction(): ResizeFunction = when (this) {
ImageScaleMode.NotPresent,
ImageScaleMode.Base -> ResizeFunction.Bilinear
is ImageScaleMode.Bilinear -> ResizeFunction.Bilinear
is ImageScaleMode.Nearest -> ResizeFunction.Nearest
is ImageScaleMode.Cubic -> ResizeFunction.Cubic
is ImageScaleMode.Mitchell -> ResizeFunction.MitchellNetravalli
is ImageScaleMode.Catmull -> ResizeFunction.CatmullRom
is ImageScaleMode.Hermite -> ResizeFunction.Hermite
is ImageScaleMode.BSpline -> ResizeFunction.BSpline
is ImageScaleMode.Hann -> ResizeFunction.Hann
is ImageScaleMode.Bicubic -> ResizeFunction.Bicubic
is ImageScaleMode.Hamming -> ResizeFunction.Hamming
is ImageScaleMode.Hanning -> ResizeFunction.Hanning
is ImageScaleMode.Blackman -> ResizeFunction.Blackman
is ImageScaleMode.Welch -> ResizeFunction.Welch
is ImageScaleMode.Quadric -> ResizeFunction.Quadric
is ImageScaleMode.Gaussian -> ResizeFunction.Gaussian
is ImageScaleMode.Sphinx -> ResizeFunction.Sphinx
is ImageScaleMode.Bartlett -> ResizeFunction.Bartlett
is ImageScaleMode.Robidoux -> ResizeFunction.Robidoux
is ImageScaleMode.RobidouxSharp -> ResizeFunction.RobidouxSharp
is ImageScaleMode.Spline16 -> ResizeFunction.Spline16
is ImageScaleMode.Spline36 -> ResizeFunction.Spline36
is ImageScaleMode.Spline64 -> ResizeFunction.Spline64
is ImageScaleMode.Kaiser -> ResizeFunction.Kaiser
is ImageScaleMode.BartlettHann -> ResizeFunction.BartlettHann
is ImageScaleMode.Box -> ResizeFunction.Box
is ImageScaleMode.Bohman -> ResizeFunction.Bohman
is ImageScaleMode.Lanczos2 -> ResizeFunction.Lanczos2
is ImageScaleMode.Lanczos3 -> ResizeFunction.Lanczos3
is ImageScaleMode.Lanczos4 -> ResizeFunction.Lanczos4
is ImageScaleMode.Lanczos2Jinc -> ResizeFunction.Lanczos2Jinc
is ImageScaleMode.Lanczos3Jinc -> ResizeFunction.Lanczos3Jinc
is ImageScaleMode.Lanczos4Jinc -> ResizeFunction.Lanczos4Jinc
is ImageScaleMode.EwaHanning -> ResizeFunction.EwaHanning
is ImageScaleMode.EwaRobidoux -> ResizeFunction.EwaRobidoux
is ImageScaleMode.EwaBlackman -> ResizeFunction.EwaBlackman
is ImageScaleMode.EwaQuadric -> ResizeFunction.EwaQuadric
is ImageScaleMode.EwaRobidouxSharp -> ResizeFunction.EwaRobidouxSharp
is ImageScaleMode.EwaLanczos3Jinc -> ResizeFunction.EwaLanczos3Jinc
is ImageScaleMode.Ginseng -> ResizeFunction.Ginseng
is ImageScaleMode.EwaGinseng -> ResizeFunction.EwaGinseng
is ImageScaleMode.EwaLanczosSharp -> ResizeFunction.EwaLanczosSharp
is ImageScaleMode.EwaLanczos4Sharpest -> ResizeFunction.EwaLanczos4Sharpest
is ImageScaleMode.EwaLanczosSoft -> ResizeFunction.EwaLanczosSoft
is ImageScaleMode.HaasnSoft -> ResizeFunction.HaasnSoft
is ImageScaleMode.Lagrange2 -> ResizeFunction.Lagrange2
is ImageScaleMode.Lagrange3 -> ResizeFunction.Lagrange3
is ImageScaleMode.Lanczos6 -> ResizeFunction.Lanczos6
is ImageScaleMode.Lanczos6Jinc -> ResizeFunction.Lanczos6Jinc
is ImageScaleMode.Area -> ResizeFunction.Area
}
private fun ScaleColorSpace.toColorSpace(): AireScaleColorSpace = when (this) {
ScaleColorSpace.LAB -> AireScaleColorSpace.LAB
ScaleColorSpace.Linear -> AireScaleColorSpace.LINEAR
ScaleColorSpace.SRGB -> AireScaleColorSpace.SRGB
ScaleColorSpace.LUV -> AireScaleColorSpace.LUV
ScaleColorSpace.Sigmoidal -> AireScaleColorSpace.SIGMOIDAL
ScaleColorSpace.XYZ -> AireScaleColorSpace.XYZ
ScaleColorSpace.F32Gamma22 -> AireScaleColorSpace.LINEAR_F32_GAMMA_2_2
ScaleColorSpace.F32Gamma28 -> AireScaleColorSpace.LINEAR_F32_GAMMA_2_8
ScaleColorSpace.F32Rec709 -> AireScaleColorSpace.LINEAR_F32_REC709
ScaleColorSpace.F32sRGB -> AireScaleColorSpace.LINEAR_F32_SRGB
ScaleColorSpace.LCH -> AireScaleColorSpace.LCH
ScaleColorSpace.OklabGamma22 -> AireScaleColorSpace.OKLAB_GAMMA_2_2
ScaleColorSpace.OklabGamma28 -> AireScaleColorSpace.OKLAB_GAMMA_2_8
ScaleColorSpace.OklabRec709 -> AireScaleColorSpace.OKLAB_REC709
ScaleColorSpace.OklabSRGB -> AireScaleColorSpace.OKLAB_SRGB
ScaleColorSpace.JzazbzGamma22 -> AireScaleColorSpace.JZAZBZ_GAMMA_2_2
ScaleColorSpace.JzazbzGamma28 -> AireScaleColorSpace.JZAZBZ_GAMMA_2_8
ScaleColorSpace.JzazbzRec709 -> AireScaleColorSpace.JZAZBZ_REC709
ScaleColorSpace.JzazbzSRGB -> AireScaleColorSpace.JZAZBZ_SRGB
}
private suspend fun flexibleResize(
image: Bitmap,
width: Int,
height: Int,
resizeAnchor: ResizeAnchor,
imageScaleMode: ImageScaleMode
): Bitmap = withContext(defaultDispatcher) {
val max = max(width, height)
val scaleByWidth = suspend {
val aspectRatio = image.aspectRatio
createScaledBitmap(
image = image,
width = width,
height = (width / aspectRatio).toInt(),
imageScaleMode = imageScaleMode
)
}
val scaleByHeight = suspend {
val aspectRatio = image.aspectRatio
createScaledBitmap(
image = image,
width = (height * aspectRatio).toInt(),
height = height,
imageScaleMode = imageScaleMode
)
}
when (resizeAnchor) {
ResizeAnchor.Max -> {
if (width >= height) {
scaleByWidth()
} else scaleByHeight()
}
ResizeAnchor.Min -> {
if (width >= height) {
scaleByHeight()
} else scaleByWidth()
}
ResizeAnchor.Width -> scaleByWidth()
ResizeAnchor.Height -> scaleByHeight()
ResizeAnchor.Default -> {
runSuspendCatching {
if (image.height >= image.width) {
val aspectRatio = image.aspectRatio
val targetWidth = (max * aspectRatio).toInt()
createScaledBitmap(image, targetWidth, max, imageScaleMode)
} else {
val aspectRatio = 1f / image.aspectRatio
val targetHeight = (max * aspectRatio).toInt()
createScaledBitmap(image, max, targetHeight, imageScaleMode)
}
}.getOrNull() ?: image
}
}
}
}
@@ -0,0 +1,189 @@
/*
* 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.core.data.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.size.Size
import coil3.toBitmap
import com.t8rin.imagetoolbox.core.data.utils.toCoil
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.ImageTransformer
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.image.model.Preset
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeType
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.sizeTo
import com.t8rin.imagetoolbox.core.domain.transformation.Transformation
import com.t8rin.imagetoolbox.core.utils.makeLog
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.roundToInt
internal class AndroidImageTransformer @Inject constructor(
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
defaultDispatchersHolder: DispatchersHolder
) : DispatchersHolder by defaultDispatchersHolder, ImageTransformer<Bitmap> {
override suspend fun transform(
image: Bitmap,
transformations: List<Transformation<Bitmap>>,
originalSize: Boolean
): Bitmap? = withContext(defaultDispatcher) {
if (transformations.isEmpty()) return@withContext image
val request = ImageRequest
.Builder(context)
.data(image)
.transformations(
transformations.map {
it.toCoil()
}
)
.apply {
if (originalSize) size(Size.ORIGINAL)
}
.build()
return@withContext imageLoader.execute(request).image?.toBitmap()
}
override suspend fun transform(
image: Bitmap,
transformations: List<Transformation<Bitmap>>,
size: IntegerSize
): Bitmap? = withContext(defaultDispatcher) {
if (transformations.isEmpty()) return@withContext image
val request = ImageRequest
.Builder(context)
.data(image)
.transformations(
transformations.map {
it.toCoil()
}
)
.size(size.width, size.height)
.build()
return@withContext imageLoader.execute(request).image?.toBitmap()
}
override suspend fun applyPresetBy(
image: Bitmap?,
preset: Preset,
currentInfo: ImageInfo
): ImageInfo = withContext(defaultDispatcher) {
if (image == null || preset is Preset.None) return@withContext currentInfo
val size = currentInfo.originalUri.makeLog("applyPresetBy originalUri")?.let { uri ->
imageLoader.execute(
ImageRequest.Builder(context)
.data(uri)
.size(Size.ORIGINAL)
.build()
).image?.run { width sizeTo height }.makeLog("applyPresetBy using orig size")
} ?: IntegerSize(image.width, image.height).makeLog("applyPresetBy using image size")
val rotated = abs(currentInfo.rotationDegrees) % 180 != 0f
fun calcWidth() = if (rotated) size.height else size.width
fun calcHeight() = if (rotated) size.width else size.height
fun Int.calc(cnt: Int): Int = (this * (cnt / 100f)).toInt()
when (preset) {
is Preset.Telegram -> {
currentInfo.copy(
width = 512,
height = 512,
imageFormat = ImageFormat.Png.Lossless,
resizeType = ResizeType.Flexible,
quality = Quality.Base(100)
)
}
is Preset.Percentage -> currentInfo.copy(
width = calcWidth().calc(preset.value),
height = calcHeight().calc(preset.value),
)
is Preset.AspectRatio -> {
val originalWidth = calcWidth().toFloat()
val originalHeight = calcHeight().toFloat()
val newWidth: Float
val newHeight: Float
val condition = if (preset.isFit) preset.ratio > originalWidth / originalHeight
else preset.ratio < originalWidth / originalHeight
if (condition) {
newWidth = originalHeight * preset.ratio
newHeight = originalHeight
} else {
newWidth = originalWidth
newHeight = originalWidth / preset.ratio
}
currentInfo.copy(
width = newWidth.roundToInt(),
height = newHeight.roundToInt()
)
}
Preset.None -> currentInfo
}
}
override suspend fun flip(
image: Bitmap,
isFlipped: Boolean
): Bitmap = withContext(defaultDispatcher) {
if (isFlipped) {
val matrix = Matrix().apply { postScale(-1f, 1f, image.width / 2f, image.height / 2f) }
Bitmap.createBitmap(image, 0, 0, image.width, image.height, matrix, true)
} else image
}
override suspend fun rotate(
image: Bitmap,
degrees: Float
): Bitmap = withContext(defaultDispatcher) {
if (degrees == 0f) return@withContext image
if (degrees % 90 == 0f) {
val matrix = Matrix().apply { postRotate(degrees) }
Bitmap.createBitmap(image, 0, 0, image.width, image.height, matrix, true)
} else {
val matrix = Matrix().apply {
setRotate(degrees, image.width.toFloat() / 2, image.height.toFloat() / 2)
}
Bitmap.createBitmap(image, 0, 0, image.width, image.height, matrix, true)
}
}
}
@@ -0,0 +1,51 @@
/*
* 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.core.data.image
import com.t8rin.exif.ExifInterface
import com.t8rin.imagetoolbox.core.domain.image.Metadata
import com.t8rin.imagetoolbox.core.domain.image.model.MetadataTag
import com.t8rin.imagetoolbox.core.domain.image.toMap
import java.io.FileDescriptor
private data class ExifInterfaceMetadata(
private val exifInterface: ExifInterface
) : Metadata {
override fun saveAttributes(): Metadata = apply {
exifInterface.saveAttributes()
}
override fun getAttribute(
tag: MetadataTag
): String? = exifInterface.getAttribute(tag.key)
override fun setAttribute(
tag: MetadataTag,
value: String?
): Metadata = apply {
exifInterface.setAttribute(tag.key, value)
}
override fun toString(): String = "Android(${toMap()})"
}
internal fun ExifInterface.toMetadata(): Metadata = ExifInterfaceMetadata(this)
internal fun FileDescriptor.toMetadata(): Metadata = ExifInterface(this).toMetadata()
@@ -0,0 +1,321 @@
/*
* 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.core.data.image
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.t8rin.imagetoolbox.core.data.saving.io.FileWriteable
import com.t8rin.imagetoolbox.core.data.saving.io.UriReadable
import com.t8rin.imagetoolbox.core.domain.PDF
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.history.AppHistoryRepository
import com.t8rin.imagetoolbox.core.domain.image.ImageCompressor
import com.t8rin.imagetoolbox.core.domain.image.ImageGetter
import com.t8rin.imagetoolbox.core.domain.image.ImageShareProvider
import com.t8rin.imagetoolbox.core.domain.image.ShareProvider
import com.t8rin.imagetoolbox.core.domain.image.model.ImageInfo
import com.t8rin.imagetoolbox.core.domain.model.MimeType
import com.t8rin.imagetoolbox.core.domain.model.toMimeType
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
import com.t8rin.imagetoolbox.core.domain.saving.FilenameCreator
import com.t8rin.imagetoolbox.core.domain.saving.io.Writeable
import com.t8rin.imagetoolbox.core.domain.saving.io.use
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.utils.fileSize
import com.t8rin.imagetoolbox.core.utils.makeLog
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import kotlin.random.Random
internal class AndroidShareProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val imageGetter: ImageGetter<Bitmap>,
private val imageCompressor: ImageCompressor<Bitmap>,
private val filenameCreator: Lazy<FilenameCreator>,
private val appHistoryRepository: AppHistoryRepository,
resourceManager: ResourceManager,
dispatchersHolder: DispatchersHolder
) : DispatchersHolder by dispatchersHolder,
ResourceManager by resourceManager,
ShareProvider, ImageShareProvider<Bitmap> {
override suspend fun shareImages(
uris: List<String>,
imageLoader: suspend (String) -> Pair<Bitmap, ImageInfo>?,
onProgressChange: (Int) -> Unit
) = withContext(ioDispatcher) {
val cachedUris = uris.mapIndexedNotNull { index, uri ->
imageLoader(uri)?.let { (image, imageInfo) ->
cacheImage(
image = image,
imageInfo = imageInfo
)?.also {
onProgressChange(index + 1)
}
}
}
onProgressChange(-1)
shareUris(cachedUris)
}
override suspend fun cacheImage(
image: Bitmap,
imageInfo: ImageInfo,
filename: String?
): String? = withContext(ioDispatcher) {
runSuspendCatching {
val saveTarget = ImageSaveTarget(
imageInfo = imageInfo,
originalUri = imageInfo.originalUri ?: "share",
sequenceNumber = null,
data = byteArrayOf()
)
val realFilename = filename ?: filenameCreator.get().constructImageFilename(saveTarget)
val byteArray = imageCompressor.compressAndTransform(image, imageInfo)
cacheByteArray(
byteArray = byteArray,
filename = realFilename
)
}.getOrNull()
}
override suspend fun shareImage(
imageInfo: ImageInfo,
image: Bitmap,
onComplete: () -> Unit
) = withContext(ioDispatcher) {
cacheImage(
image = image,
imageInfo = imageInfo
)?.let {
shareUri(
uri = it,
type = imageInfo.imageFormat.mimeType,
onComplete = {}
)
}
onComplete()
}
override suspend fun shareUri(
uri: String,
type: MimeType.Single?,
onComplete: () -> Unit
) {
withContext(defaultDispatcher) {
runSuspendCatching {
shareUriImpl(
uri = uri,
type = type
)
onComplete()
}.onFailure {
val newUri = cacheData(
writeData = {
UriReadable(
uri = uri.toUri(),
context = context
).copyTo(it)
},
filename = filenameCreator.get()
.constructRandomFilename(
extension = imageGetter.getExtension(uri) ?: ""
)
)
shareUriImpl(
uri = newUri ?: return@onFailure,
type = type
)
onComplete()
}
}
}
private fun shareUriImpl(
uri: String,
type: MimeType.Single?
) {
val mimeType = type ?: MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(
imageGetter.getExtension(uri)
)?.toMimeType() ?: MimeType.All
val sendIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri.toUri())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
this.type = mimeType.entry
}
val shareIntent = Intent.createChooser(sendIntent, getString(R.string.share))
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(shareIntent)
}
override suspend fun shareUris(
uris: List<String>
) = shareImageUris(uris.map { it.toUri() })
private suspend fun shareImageUris(
uris: List<Uri>
) = withContext(defaultDispatcher) {
if (uris.isEmpty()) return@withContext
val sendIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(
imageGetter.getExtension(uris.first().toString())
) ?: "*/*"
type = mimeType.makeLog("shareImageUris")
}
val shareIntent = Intent.createChooser(sendIntent, getString(R.string.share))
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(shareIntent)
}
override suspend fun cacheByteArray(
byteArray: ByteArray,
filename: String
): String? = withContext(ioDispatcher) {
cacheData(
writeData = { it.writeBytes(byteArray) },
filename = filename,
)
}
override suspend fun shareByteArray(
byteArray: ByteArray,
filename: String,
onComplete: () -> Unit
) = withContext(ioDispatcher) {
shareData(
writeData = { it.writeBytes(byteArray) },
filename = filename,
onComplete = {}
)
onComplete()
}
override suspend fun cacheData(
filename: String,
writeData: suspend (Writeable) -> Unit
): String? = withContext(ioDispatcher) {
runSuspendCatching {
cacheDataOrThrow(
filename = filename,
writeData = writeData
)
}.onFailure { it.makeLog("cacheData") }.getOrNull()
}
override suspend fun cacheDataOrThrow(
filename: String,
writeData: suspend (Writeable) -> Unit
): String = withContext(ioDispatcher) {
val imagesFolder = if (filename.startsWith("temp.")) {
File(context.cacheDir, "temp")
} else if (filename.startsWith(PDF)) {
File(context.cacheDir, "$PDF${Random.nextInt()}")
} else {
File(context.cacheDir, "cache/${Random.nextInt()}")
}
imagesFolder.mkdirs()
val file = File(imagesFolder, filename.removePrefix(PDF))
FileWriteable(file).use {
writeData(it)
}
FileProvider.getUriForFile(
context,
getString(R.string.file_provider),
file
).also { uri ->
runCatching {
context.grantUriPermission(
context.packageName,
uri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}.toString().also { uri ->
if (filename.startsWith(PDF)) {
appHistoryRepository.registerSuccessfulSave(
savedBytes = uri.toUri().fileSize() ?: 0,
savedFormat = "PDF"
)
}
}
}
override suspend fun shareData(
writeData: suspend (Writeable) -> Unit,
filename: String,
onComplete: () -> Unit
) = withContext(ioDispatcher) {
cacheData(
writeData = writeData,
filename = filename
)?.let {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(
imageGetter.getExtension(it)
)?.toMimeType() ?: MimeType.All
shareUri(
uri = it,
type = mimeType,
onComplete = {}
)
}
onComplete()
}
override fun shareText(
value: String,
onComplete: () -> Unit
) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, value)
}
val shareIntent = Intent.createChooser(sendIntent, getString(R.string.share))
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(shareIntent)
onComplete()
}
}
@@ -0,0 +1,155 @@
/*
* 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.core.data.image
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.t8rin.imagetoolbox.core.domain.image.ImageExportProfilesUseCase
import com.t8rin.imagetoolbox.core.domain.image.ShareProvider
import com.t8rin.imagetoolbox.core.domain.image.model.ImageExportProfile
import com.t8rin.imagetoolbox.core.domain.image.model.ImageExportProfiles
import com.t8rin.imagetoolbox.core.domain.json.JsonParser
import com.t8rin.imagetoolbox.core.domain.saving.FileController
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
internal class ImageExportProfilesUseCaseImpl @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val fileController: FileController,
private val jsonParser: JsonParser,
private val shareProvider: ShareProvider
) : ImageExportProfilesUseCase {
override val profiles: Flow<List<ImageExportProfile>> = dataStore.data.map { preferences ->
preferences.readPresets().presets.asReversed()
}
override suspend fun upsert(profile: ImageExportProfile) {
val normalized = profile.copy(name = profile.name.trim())
if (normalized.name.isBlank()) return
dataStore.edit { preferences ->
val list = preferences.readPresets().presets.toMutableList()
val index = list.indexOfFirst {
it.name.equals(normalized.name, ignoreCase = true)
}
if (index >= 0) {
list[index] = normalized.copy(name = list[index].name)
} else {
list.add(normalized)
}
preferences.writePresets(ImageExportProfiles(list))
}
}
override suspend fun delete(profile: ImageExportProfile) {
dataStore.edit { preferences ->
val current = preferences.readPresets()
preferences.writePresets(
current.copy(
presets = current.presets.filterNot {
it.name.equals(profile.name, ignoreCase = true)
}
)
)
}
}
override suspend fun export(
profile: ImageExportProfile,
uri: String
) {
profile.toJson()?.let { json ->
fileController.writeBytes(uri) {
it.writeBytes(json.encodeToByteArray())
}
}
}
override suspend fun share(profile: ImageExportProfile) {
profile.toJson()?.let { json ->
shareProvider.shareData(
filename = profile.fileName(),
writeData = {
it.writeBytes(json.encodeToByteArray())
}
)
}
}
override suspend fun importProfile(uri: String) {
runSuspendCatching {
fileController.readBytes(uri).decodeToString()
}.mapCatching { json ->
jsonParser.fromJson<ImageExportProfile>(
json = json,
type = ImageExportProfile::class.java
)
}.getOrNull()?.let { preset ->
upsert(preset)
}
}
private fun Preferences.readPresets(): ImageExportProfiles {
val json = this[PresetsKey]
return json?.let {
jsonParser.fromJson<ImageExportProfiles>(
json = it,
type = ImageExportProfiles::class.java
)
} ?: ImageExportProfiles()
}
private fun MutablePreferences.writePresets(
presets: ImageExportProfiles
) {
if (presets.presets.isEmpty()) {
remove(PresetsKey)
return
}
jsonParser.toJson(
obj = presets,
type = ImageExportProfiles::class.java
)?.let { json ->
this[PresetsKey] = json
}
}
private fun ImageExportProfile.toJson(): String? = jsonParser.toJson(
obj = this,
type = ImageExportProfile::class.java
)
private fun ImageExportProfile.fileName(): String = "${name.safePresetFileName()}.itpreset"
private fun String.safePresetFileName(): String = trim()
.replace(Regex("""[^\w.-]+"""), "_")
.trim('_')
.ifBlank { "preset" }
}
private val PresetsKey = stringPreferencesKey("PRESETS_KEY")
@@ -0,0 +1,99 @@
/*
* 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.core.data.image.utils
import android.graphics.Paint
import android.graphics.PorterDuffXfermode
import android.os.Build
import androidx.annotation.RequiresApi
import com.t8rin.imagetoolbox.core.domain.image.model.BlendingMode
import android.graphics.BlendMode as AndroidBlendMode
import android.graphics.PorterDuff.Mode as PorterDuffMode
fun BlendingMode.toPorterDuffMode(): PorterDuffMode = when (this) {
BlendingMode.Clear -> PorterDuffMode.CLEAR
BlendingMode.Src -> PorterDuffMode.SRC
BlendingMode.Dst -> PorterDuffMode.DST
BlendingMode.SrcOver -> PorterDuffMode.SRC_OVER
BlendingMode.DstOver -> PorterDuffMode.DST_OVER
BlendingMode.SrcIn -> PorterDuffMode.SRC_IN
BlendingMode.DstIn -> PorterDuffMode.DST_IN
BlendingMode.SrcOut -> PorterDuffMode.SRC_OUT
BlendingMode.DstOut -> PorterDuffMode.DST_OUT
BlendingMode.SrcAtop -> PorterDuffMode.SRC_ATOP
BlendingMode.DstAtop -> PorterDuffMode.DST_ATOP
BlendingMode.Xor -> PorterDuffMode.XOR
BlendingMode.Plus -> PorterDuffMode.ADD
BlendingMode.Screen -> PorterDuffMode.SCREEN
BlendingMode.Overlay -> PorterDuffMode.OVERLAY
BlendingMode.Darken -> PorterDuffMode.DARKEN
BlendingMode.Lighten -> PorterDuffMode.LIGHTEN
BlendingMode.Modulate -> {
// b/73224934 Android PorterDuff Multiply maps to Skia Modulate
PorterDuffMode.MULTIPLY
}
// Always return SRC_OVER as the default if there is no valid alternative
else -> PorterDuffMode.SRC_OVER
}
/**
* Convert the domain [BlendingMode] to the underlying Android platform [AndroidBlendMode]
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun BlendingMode.toAndroidBlendMode(): AndroidBlendMode = when (this) {
BlendingMode.Clear -> AndroidBlendMode.CLEAR
BlendingMode.Src -> AndroidBlendMode.SRC
BlendingMode.Dst -> AndroidBlendMode.DST
BlendingMode.SrcOver -> AndroidBlendMode.SRC_OVER
BlendingMode.DstOver -> AndroidBlendMode.DST_OVER
BlendingMode.SrcIn -> AndroidBlendMode.SRC_IN
BlendingMode.DstIn -> AndroidBlendMode.DST_IN
BlendingMode.SrcOut -> AndroidBlendMode.SRC_OUT
BlendingMode.DstOut -> AndroidBlendMode.DST_OUT
BlendingMode.SrcAtop -> AndroidBlendMode.SRC_ATOP
BlendingMode.DstAtop -> AndroidBlendMode.DST_ATOP
BlendingMode.Xor -> AndroidBlendMode.XOR
BlendingMode.Plus -> AndroidBlendMode.PLUS
BlendingMode.Modulate -> AndroidBlendMode.MODULATE
BlendingMode.Screen -> AndroidBlendMode.SCREEN
BlendingMode.Overlay -> AndroidBlendMode.OVERLAY
BlendingMode.Darken -> AndroidBlendMode.DARKEN
BlendingMode.Lighten -> AndroidBlendMode.LIGHTEN
BlendingMode.ColorDodge -> AndroidBlendMode.COLOR_DODGE
BlendingMode.ColorBurn -> AndroidBlendMode.COLOR_BURN
BlendingMode.Hardlight -> AndroidBlendMode.HARD_LIGHT
BlendingMode.Softlight -> AndroidBlendMode.SOFT_LIGHT
BlendingMode.Difference -> AndroidBlendMode.DIFFERENCE
BlendingMode.Exclusion -> AndroidBlendMode.EXCLUSION
BlendingMode.Multiply -> AndroidBlendMode.MULTIPLY
BlendingMode.Hue -> AndroidBlendMode.HUE
BlendingMode.Saturation -> AndroidBlendMode.SATURATION
BlendingMode.Color -> AndroidBlendMode.COLOR
BlendingMode.Luminosity -> AndroidBlendMode.LUMINOSITY
// Always return SRC_OVER as the default if there is no valid alternative
else -> AndroidBlendMode.SRC_OVER
}
fun BlendingMode.toPaint(): Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
blendMode = toAndroidBlendMode()
} else {
xfermode = PorterDuffXfermode(toPorterDuffMode())
}
}
@@ -0,0 +1,95 @@
/*
* 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.core.data.image.utils
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import androidx.core.graphics.get
import androidx.core.graphics.set
import com.t8rin.imagetoolbox.core.domain.model.Position
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
fun Canvas.drawBitmap(
bitmap: Bitmap,
position: Position,
paint: Paint? = null,
horizontalPadding: Float = 0f,
verticalPadding: Float = 0f
) {
val left = when (position) {
Position.TopLeft, Position.CenterLeft, Position.BottomLeft -> horizontalPadding
Position.TopCenter, Position.Center, Position.BottomCenter -> (width - bitmap.width) / 2f
Position.TopRight, Position.CenterRight, Position.BottomRight -> (width - bitmap.width - horizontalPadding)
}
val top = when (position) {
Position.TopLeft, Position.TopCenter, Position.TopRight -> verticalPadding
Position.CenterLeft, Position.Center, Position.CenterRight -> (height - bitmap.height) / 2f
Position.BottomLeft, Position.BottomCenter, Position.BottomRight -> (height - bitmap.height - verticalPadding)
}
drawBitmap(
bitmap,
null,
RectF(
left,
top,
bitmap.width + left,
bitmap.height + top
),
paint
)
}
fun Canvas.drawBitmap(
bitmap: Bitmap,
left: Float = 0f,
top: Float = 0f
) = drawBitmap(bitmap, left, top, Paint(Paint.ANTI_ALIAS_FLAG))
fun Canvas.drawBitmap(
bitmap: Bitmap,
left: Float = 0f,
top: Float = 0f,
paint: Paint
) = drawBitmap(bitmap, left, top, paint)
suspend fun Bitmap.healAlpha(
original: Bitmap
): Bitmap = coroutineScope {
val processed = this@healAlpha
copy(Bitmap.Config.ARGB_8888, true).also { result ->
for (y in 0 until original.height) {
for (x in 0 until original.width) {
ensureActive()
val origPixel = original[x, y]
val procPixel = processed[x, y]
val origAlpha = origPixel ushr 24
if (origAlpha >= 255) continue
val newPixel = (origAlpha shl 24) or (procPixel and 0x00FFFFFF)
result[x, y] = newPixel
}
}
}
}
@@ -0,0 +1,53 @@
/*
* 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("NOTHING_TO_INLINE")
package com.t8rin.imagetoolbox.core.data.image.utils
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.t8rin.imagetoolbox.core.domain.model.ColorModel
object ColorUtils {
inline fun ColorModel.toColor() = Color(colorInt)
inline fun Color.toModel() = ColorModel(toArgb())
inline val ColorModel.red: Float
get() = toColor().red
inline val ColorModel.green: Float
get() = toColor().green
inline val ColorModel.blue: Float
get() = toColor().blue
inline val ColorModel.alpha: Float
get() = toColor().alpha
inline fun Color.toAbgr() = run {
Color(
red = alpha,
green = blue,
blue = green,
alpha = red,
colorSpace = colorSpace
).toArgb()
}
}
@@ -0,0 +1,106 @@
/*
* 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.core.data.image.utils
import android.content.Context
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.AvifBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.BmpBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.HeicBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.IcoBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.Jpeg2000Backend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.JpegliBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.JpgBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.JxlBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.MozJpegBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.OxiPngBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.PngImageQuantBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.PngLosslessBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.PngLossyBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.QoiBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.StaticGifBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.TiffBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.VvcBackend
import com.t8rin.imagetoolbox.core.data.image.utils.compressor.WebpBackend
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.imagetoolbox.core.domain.image.model.isLossless
internal interface ImageCompressorBackend {
suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray
class Factory {
fun create(
imageFormat: ImageFormat,
context: Context,
imageScaler: ImageScaler<Bitmap>
): ImageCompressorBackend = when (imageFormat) {
ImageFormat.Jpeg,
ImageFormat.Jpg -> JpgBackend
ImageFormat.Jpegli -> JpegliBackend
ImageFormat.MozJpeg -> MozJpegBackend
ImageFormat.Png.Lossless -> PngLosslessBackend
ImageFormat.Png.Lossy -> PngLossyBackend
ImageFormat.Png.OxiPNG -> OxiPngBackend
ImageFormat.Png.ImageQuant -> PngImageQuantBackend
ImageFormat.Webp.Lossless,
ImageFormat.Webp.Lossy -> WebpBackend(isLossless = imageFormat.isLossless)
ImageFormat.Jxl.Lossless,
ImageFormat.Jxl.Lossy -> JxlBackend(isLossless = imageFormat.isLossless)
ImageFormat.Tif,
ImageFormat.Tiff -> TiffBackend(context)
ImageFormat.Heic.Lossless,
ImageFormat.Heic.HeifLossless,
ImageFormat.Heic.Lossy,
ImageFormat.Heic.HeifLossy -> HeicBackend(isLossless = imageFormat.isLossless)
ImageFormat.Heic.VvcLossless,
ImageFormat.Heic.VvcLossy -> VvcBackend(isLossless = imageFormat.isLossless)
ImageFormat.Avif.LosslessAv1,
ImageFormat.Avif.LossyAv1,
ImageFormat.Avif.LosslessAv2,
ImageFormat.Avif.LossyAv2 -> AvifBackend(
isLossless = imageFormat.isLossless,
isAv1 = imageFormat == ImageFormat.Avif.LosslessAv1 ||
imageFormat == ImageFormat.Avif.LossyAv1
)
ImageFormat.Jpeg2000.J2k -> Jpeg2000Backend(isJ2K = true)
ImageFormat.Jpeg2000.Jp2 -> Jpeg2000Backend(isJ2K = false)
ImageFormat.Gif -> StaticGifBackend
ImageFormat.Bmp -> BmpBackend
ImageFormat.Qoi -> QoiBackend
ImageFormat.Ico -> IcoBackend(imageScaler)
}
}
}
@@ -0,0 +1,27 @@
/*
* 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.core.data.image.utils
import coil3.gif.repeatCount
import coil3.request.ImageRequest
import com.awxkee.jxlcoder.coil.enableJxlAnimation
import com.t8rin.imagetoolbox.core.data.coil.SvgDecoderCompat
fun ImageRequest.Builder.static() = repeatCount(0)
.enableJxlAnimation(false)
.decoderFactory(SvgDecoderCompat.Factory(minimumSize = 2048))
@@ -0,0 +1,70 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.radzivon.bartoshyk.avif.coder.AvKind
import com.radzivon.bartoshyk.avif.coder.AvSpeed
import com.radzivon.bartoshyk.avif.coder.Coder
import com.radzivon.bartoshyk.avif.coder.PreciseMode
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.AvifChromaSubsampling
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.radzivon.bartoshyk.avif.coder.AvifChromaSubsampling as BackendChromaSubsampling
internal data class AvifBackend(
private val isLossless: Boolean,
private val isAv1: Boolean
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val avifQuality = quality as? Quality.Avif ?: Quality.Avif()
return Coder().encodeAvif(
bitmap = image,
quality = avifQuality.qualityValue,
preciseMode = if (isLossless) {
PreciseMode.LOSSLESS
} else {
PreciseMode.LOSSY
},
avifChromaSubsampling = avifQuality.chromaSubsampling.toBackend(),
avKind = if (isAv1) {
AvKind.AV1
} else {
AvKind.AV2
},
speed = AvSpeed.entries.firstOrNull {
it.ordinal == (3 - avifQuality.effort)
} ?: AvSpeed.FAST
)
}
}
private fun AvifChromaSubsampling.toBackend(): BackendChromaSubsampling = when (this) {
AvifChromaSubsampling.Auto -> BackendChromaSubsampling.AUTO
AvifChromaSubsampling.Yuv420 -> BackendChromaSubsampling.YUV420
AvifChromaSubsampling.Yuv422 -> BackendChromaSubsampling.YUV422
AvifChromaSubsampling.Yuv444 -> BackendChromaSubsampling.YUV444
AvifChromaSubsampling.Yuv400 -> BackendChromaSubsampling.YUV400
AvifChromaSubsampling.Lossless -> BackendChromaSubsampling.LOSELESS
}
@@ -0,0 +1,34 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.trickle.BmpCompressor
internal data object BmpBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = runCatching {
BmpCompressor.compress(image)
}.getOrNull() ?: ByteArray(0)
}
@@ -0,0 +1,59 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.radzivon.bartoshyk.avif.coder.Coder
import com.radzivon.bartoshyk.avif.coder.HeifQualityArg
import com.radzivon.bartoshyk.avif.coder.PreciseMode
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.HeicChromaSubsampling
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.radzivon.bartoshyk.avif.coder.HeicChromaSubsampling as BackendChromaSubsampling
internal data class HeicBackend(
private val isLossless: Boolean
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val heicQuality = quality as? Quality.Heic ?: Quality.Heic(
qualityValue = quality.qualityValue
)
return Coder().encodeHeic(
bitmap = image,
quality = HeifQualityArg.Quality(heicQuality.qualityValue),
preciseMode = if (isLossless) {
PreciseMode.LOSSLESS
} else {
PreciseMode.LOSSY
},
chromaSubsampling = heicQuality.chromaSubsampling.toBackend()
)
}
}
private fun HeicChromaSubsampling.toBackend(): BackendChromaSubsampling = when (this) {
HeicChromaSubsampling.Yuv420 -> BackendChromaSubsampling.YUV420
HeicChromaSubsampling.Yuv422 -> BackendChromaSubsampling.YUV422
HeicChromaSubsampling.Yuv444 -> BackendChromaSubsampling.YUV444
}
@@ -0,0 +1,109 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import androidx.core.graphics.get
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.ImageScaler
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeType
import kotlinx.coroutines.coroutineScope
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
internal data class IcoBackend(
private val imageScaler: ImageScaler<Bitmap>
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = coroutineScope {
val bitmap = if (image.width > 256 || image.height > 256) {
imageScaler.scaleImage(
image = image,
width = 256,
height = 256,
resizeType = ResizeType.Flexible
)
} else image
val width = bitmap.width
val height = bitmap.height
val outputStream = ByteArrayOutputStream()
val header = ByteArray(6)
val entry = ByteArray(16)
val infoHeader = ByteArray(40)
// ICO Header
header[2] = 1 // Image type: Icon
header[4] = 1 // Number of images
outputStream.write(header)
// Image entry
entry[0] = if (width > 256) 0 else width.toByte()
entry[1] = if (height > 256) 0 else height.toByte()
entry[4] = 1 // Color planes
entry[6] = 32 // Bits per pixel
val andMaskSize = ((width + 31) / 32) * 4 * height
val xorMaskSize = width * height * 4
val imageDataSize = infoHeader.size + xorMaskSize + andMaskSize
ByteBuffer.wrap(entry, 8, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(imageDataSize)
ByteBuffer.wrap(entry, 12, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.putInt(header.size + entry.size)
outputStream.write(entry)
// BITMAP INFO HEADER
ByteBuffer.wrap(infoHeader).order(ByteOrder.LITTLE_ENDIAN).apply {
putInt(40) // Header size
putInt(width) // Width
putInt(height * 2) // Height (XOR + AND masks)
putShort(1) // Color planes
putShort(32) // Bits per pixel
putInt(0) // Compression (BI_RGB)
putInt(xorMaskSize + andMaskSize) // Image size
}
outputStream.write(infoHeader)
// XOR mask (pixel data)
for (y in height - 1 downTo 0) {
for (x in 0 until width) {
val pixel = bitmap[x, y]
outputStream.write(pixel and 0xFF) // B
outputStream.write((pixel shr 8) and 0xFF) // G
outputStream.write((pixel shr 16) and 0xFF) // R
outputStream.write((pixel shr 24) and 0xFF) // A
}
}
// AND mask (all 0 for no transparency mask)
outputStream.write(ByteArray(andMaskSize))
outputStream.toByteArray()
}
}
@@ -0,0 +1,43 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.gemalto.jp2.JP2Encoder
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data class Jpeg2000Backend(
private val isJ2K: Boolean
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = JP2Encoder(image)
.setOutputFormat(
if (isJ2K) {
JP2Encoder.FORMAT_J2K
} else {
JP2Encoder.FORMAT_JP2
}
)
.setVisualQuality(quality.qualityValue.toFloat())
.encode()
}
@@ -0,0 +1,46 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import io.github.awxkee.jpegli.coder.IccStrategy
import io.github.awxkee.jpegli.coder.JpegliCoder
import io.github.awxkee.jpegli.coder.Scalar
import java.io.ByteArrayOutputStream
internal data object JpegliBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = ByteArrayOutputStream().apply {
use { out ->
JpegliCoder.compress(
bitmap = image,
quality = quality.qualityValue,
background = Scalar.ZERO,
progressive = true,
strategy = IccStrategy.DEFAULT,
outputStream = out
)
}
}.toByteArray()
}
@@ -0,0 +1,35 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.data.utils.compress
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data object JpgBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = image.compress(
format = Bitmap.CompressFormat.JPEG,
quality = quality.qualityValue
)
}
@@ -0,0 +1,59 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.awxkee.aire.Aire
import com.awxkee.jxlcoder.JxlChannelsConfiguration
import com.awxkee.jxlcoder.JxlCoder
import com.awxkee.jxlcoder.JxlCompressionOption
import com.awxkee.jxlcoder.JxlDecodingSpeed
import com.awxkee.jxlcoder.JxlEffort
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data class JxlBackend(
private val isLossless: Boolean
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val jxlQuality = quality as? Quality.Jxl ?: Quality.Jxl()
return JxlCoder.encode(
bitmap = if (jxlQuality.channels == Quality.Channels.Monochrome) {
Aire.grayscale(image)
} else image,
channelsConfiguration = when (jxlQuality.channels) {
Quality.Channels.RGBA -> JxlChannelsConfiguration.RGBA
Quality.Channels.RGB -> JxlChannelsConfiguration.RGB
Quality.Channels.Monochrome -> JxlChannelsConfiguration.MONOCHROME
},
compressionOption = if (isLossless) {
JxlCompressionOption.LOSSLESS
} else {
JxlCompressionOption.LOSSY
},
quality = if (isLossless) 100 else jxlQuality.qualityValue,
effort = JxlEffort.entries.first { it.ordinal == jxlQuality.effort - 1 },
decodingSpeed = JxlDecodingSpeed.entries.first { it.ordinal == jxlQuality.speed }
)
}
}
@@ -0,0 +1,35 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.awxkee.aire.Aire
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data object MozJpegBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = Aire.mozjpeg(
bitmap = image,
quality = quality.qualityValue
)
}
@@ -0,0 +1,37 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.trickle.Oxipng
internal data object OxiPngBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = Oxipng.optimize(
bitmap = image,
options = Oxipng.SimpleOptions(
level = quality.qualityValue.coerceIn(0..6)
)
)
}
@@ -0,0 +1,42 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.trickle.ImageQuant
internal data object PngImageQuantBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val pngQuantQuality = quality as? Quality.PngQuant ?: Quality.PngQuant()
return ImageQuant.compress(
bitmap = image,
options = ImageQuant.Options(
quality = pngQuantQuality.quality,
speed = pngQuantQuality.speed,
maxColors = pngQuantQuality.maxColors
)
)
}
}
@@ -0,0 +1,35 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.data.utils.compress
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data object PngLosslessBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = image.compress(
format = Bitmap.CompressFormat.PNG,
quality = quality.qualityValue
)
}
@@ -0,0 +1,45 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.awxkee.aire.Aire
import com.awxkee.aire.AireColorMapper
import com.awxkee.aire.AirePaletteDithering
import com.awxkee.aire.AireQuantize
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data object PngLossyBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val pngLossyQuality = quality as? Quality.PngLossy ?: Quality.PngLossy()
return Aire.toPNG(
bitmap = image,
maxColors = pngLossyQuality.maxColors,
quantize = AireQuantize.XIAOLING_WU,
dithering = AirePaletteDithering.JARVIS_JUDICE_NINKE,
colorMapper = AireColorMapper.COVER_TREE,
compressionLevel = pngLossyQuality.compressionLevel.coerceIn(0..9)
)
}
}
@@ -0,0 +1,32 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.qoi_coder.QOIEncoder
internal data object QoiBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = QOIEncoder(image).encode()
}
@@ -0,0 +1,50 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.t8rin.gif_converter.GifEncoder
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import java.io.ByteArrayOutputStream
internal data object StaticGifBackend : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = ByteArrayOutputStream().use { out ->
GifEncoder()
.setSize(
width = image.width,
height = image.height
)
.setRepeat(1)
.setQuality(quality.qualityValue)
.setTransparent(Color.Transparent.toArgb()).apply {
start(out)
addFrame(image)
finish()
}
out.toByteArray()
}
}
@@ -0,0 +1,50 @@
/*
* 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.core.data.image.utils.compressor
import android.content.Context
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import org.beyka.tiffbitmapfactory.CompressionScheme
import org.beyka.tiffbitmapfactory.Orientation
import org.beyka.tiffbitmapfactory.TiffSaver
import java.io.File
internal data class TiffBackend(
private val context: Context
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val tiffQuality = quality as? Quality.Tiff ?: Quality.Tiff()
val file = File(context.cacheDir, "temp.tiff").apply { createNewFile() }
val options = TiffSaver.SaveOptions().apply {
compressionScheme = CompressionScheme.entries[tiffQuality.compressionScheme]
orientation = Orientation.TOP_LEFT
}
TiffSaver.saveBitmap(file, image, options)
return file.readBytes().also {
file.delete()
}
}
}
@@ -0,0 +1,66 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
import com.t8rin.imagetoolbox.core.domain.image.model.VvcBitDepth
import com.t8rin.imagetoolbox.core.domain.image.model.VvcChroma
import com.t8rin.trickle.VvcContainer
import com.t8rin.trickle.VvcEncoder
import com.t8rin.trickle.VvcBitDepth as BackendBitDepth
import com.t8rin.trickle.VvcChroma as BackendChroma
internal data class VvcBackend(
private val isLossless: Boolean
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray {
val vvcQuality = quality as? Quality.Vvc ?: Quality.Vvc(
qualityValue = quality.qualityValue.coerceIn(1..100)
)
return VvcEncoder.encode(
bitmap = image,
options = VvcEncoder.Options(
quality = vvcQuality.qualityValue,
lossless = isLossless,
chroma = vvcQuality.chroma.toBackend(),
bitDepth = vvcQuality.bitDepth.toBackend(),
container = VvcContainer.HEIF
)
)
}
}
private fun VvcChroma.toBackend(): BackendChroma = when (this) {
VvcChroma.MONOCHROME -> BackendChroma.MONOCHROME
VvcChroma.YUV_420 -> BackendChroma.YUV_420
VvcChroma.YUV_422 -> BackendChroma.YUV_422
VvcChroma.YUV_444 -> BackendChroma.YUV_444
}
private fun VvcBitDepth.toBackend(): BackendBitDepth = when (this) {
VvcBitDepth.EIGHT -> BackendBitDepth.EIGHT
VvcBitDepth.TEN -> BackendBitDepth.TEN
VvcBitDepth.TWELVE -> BackendBitDepth.TWELVE
}
@@ -0,0 +1,50 @@
/*
* 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.core.data.image.utils.compressor
import android.graphics.Bitmap
import android.os.Build
import com.t8rin.imagetoolbox.core.data.image.utils.ImageCompressorBackend
import com.t8rin.imagetoolbox.core.data.utils.compress
import com.t8rin.imagetoolbox.core.domain.image.model.Quality
internal data class WebpBackend(
private val isLossless: Boolean
) : ImageCompressorBackend {
override suspend fun compress(
image: Bitmap,
quality: Quality
): ByteArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
image.compress(
format = if (isLossless) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
Bitmap.CompressFormat.WEBP_LOSSY
},
quality = quality.qualityValue
)
} else {
@Suppress("DEPRECATION")
image.compress(
format = Bitmap.CompressFormat.WEBP,
quality = quality.qualityValue
)
}
}
@@ -0,0 +1,176 @@
/*
* 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.core.data.json
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import com.t8rin.imagetoolbox.core.domain.image.model.ImageFormat
import com.t8rin.imagetoolbox.core.domain.image.model.ImageScaleMode
import com.t8rin.imagetoolbox.core.domain.image.model.Preset
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeAnchor
import com.t8rin.imagetoolbox.core.domain.image.model.ResizeType
import com.t8rin.imagetoolbox.core.domain.image.model.ScaleColorSpace
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.Position
internal class ImageFormatJsonAdapter {
@FromJson
fun fromJson(value: String?): ImageFormat =
ImageFormat.fromTitle(value) ?: ImageFormat.Default
@ToJson
fun toJson(value: ImageFormat): String = value.title
}
internal class PresetJsonAdapter {
@FromJson
fun fromJson(value: PresetJson?): Preset = when (value?.type) {
Telegram -> Preset.Telegram
Percentage -> value.value?.let(Preset::Percentage) ?: Preset.None
AspectRatio -> Preset.AspectRatio(
ratio = value.ratio ?: 1f,
isFit = value.isFit ?: false
)
else -> Preset.None
}
@ToJson
fun toJson(value: Preset): PresetJson = when (value) {
Preset.None -> PresetJson(type = None)
Preset.Telegram -> PresetJson(type = Telegram)
is Preset.Percentage -> PresetJson(
type = Percentage,
value = value.value
)
is Preset.AspectRatio -> PresetJson(
type = AspectRatio,
ratio = value.ratio,
isFit = value.isFit
)
}
private companion object {
const val None = "none"
const val Telegram = "telegram"
const val Percentage = "percentage"
const val AspectRatio = "aspect_ratio"
}
}
internal class ResizeTypeJsonAdapter {
@FromJson
fun fromJson(value: ResizeTypeJson?): ResizeType = when (value?.type) {
Flexible -> ResizeType.Flexible(
resizeAnchor = value.resizeAnchor
)
CenterCrop -> ResizeType.CenterCrop(
canvasColor = value.canvasColor,
blurRadius = value.blurRadius,
originalSize = value.originalSize,
scaleFactor = value.scaleFactor,
position = value.position
)
Fit -> ResizeType.Fit(
canvasColor = value.canvasColor,
blurRadius = value.blurRadius,
position = value.position
)
else -> ResizeType.Explicit
}
@ToJson
fun toJson(value: ResizeType): ResizeTypeJson = when (value) {
ResizeType.Explicit -> ResizeTypeJson(type = Explicit)
is ResizeType.Flexible -> ResizeTypeJson(
type = Flexible,
resizeAnchor = value.resizeAnchor
)
is ResizeType.CenterCrop -> ResizeTypeJson(
type = CenterCrop,
canvasColor = value.canvasColor,
blurRadius = value.blurRadius,
originalSize = value.originalSize,
scaleFactor = value.scaleFactor,
position = value.position
)
is ResizeType.Fit -> ResizeTypeJson(
type = Fit,
canvasColor = value.canvasColor,
blurRadius = value.blurRadius,
position = value.position
)
}
private companion object {
const val Explicit = "explicit"
const val Flexible = "flexible"
const val CenterCrop = "center_crop"
const val Fit = "fit"
}
}
internal class ImageScaleModeJsonAdapter {
@FromJson
fun fromJson(value: ImageScaleModeJson?): ImageScaleMode = ImageScaleMode
.fromInt(value?.value ?: ImageScaleMode.Default.value)
.copy(
scaleColorSpace = ScaleColorSpace.fromOrdinal(
value?.scaleColorSpace ?: ScaleColorSpace.Default.ordinal
)
)
@ToJson
fun toJson(value: ImageScaleMode): ImageScaleModeJson = ImageScaleModeJson(
value = value.value,
scaleColorSpace = value.scaleColorSpace.ordinal
)
}
internal data class PresetJson(
val type: String = "none",
val value: Int? = null,
val ratio: Float? = null,
val isFit: Boolean? = null
)
internal data class ResizeTypeJson(
val type: String = "explicit",
val resizeAnchor: ResizeAnchor = ResizeAnchor.Default,
val canvasColor: Int? = 0,
val blurRadius: Int = 35,
val originalSize: IntegerSize = IntegerSize.Undefined,
val scaleFactor: Float = 1f,
val position: Position = Position.Center
)
internal data class ImageScaleModeJson(
val value: Int = ImageScaleMode.Default.value,
val scaleColorSpace: Int = ScaleColorSpace.Default.ordinal
)
@@ -0,0 +1,50 @@
/*
* 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.core.data.json
import com.squareup.moshi.Moshi
import com.squareup.moshi.rawType
import com.t8rin.imagetoolbox.core.domain.json.JsonParser
import com.t8rin.imagetoolbox.core.utils.makeLog
import java.lang.reflect.Type
import javax.inject.Inject
internal class MoshiParser @Inject constructor(
private val moshi: Moshi
) : JsonParser {
override fun <T> toJson(
obj: T,
type: Type,
): String? = runCatching {
moshi.adapter<T>(type).toJson(obj)
}.onFailure { it.makeLog("MoshiParser toJson T = ${obj?.run { this::class }}") }.getOrNull()
override fun <T> fromJson(
json: String,
type: Type,
): T? = if (json.isBlank()) {
"json is empty".makeLog("MoshiParser fromJson T = ${type.rawType.name}")
null
} else {
runCatching {
moshi.adapter<T>(type).fromJson(json)
}.onFailure { it.makeLog("MoshiParser fromJson T = ${type.rawType.name}") }.getOrNull()
}
}
@@ -0,0 +1,193 @@
/*
* 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.core.data.remote
import android.content.Context
import com.t8rin.imagetoolbox.core.data.utils.getWithProgress
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.remote.DownloadManager
import com.t8rin.imagetoolbox.core.domain.remote.DownloadProgress
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
import com.t8rin.imagetoolbox.core.domain.saving.KeepAliveService
import com.t8rin.imagetoolbox.core.domain.saving.track
import com.t8rin.imagetoolbox.core.domain.saving.updateProgress
import com.t8rin.imagetoolbox.core.domain.utils.throttleLatest
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.utils.decodeEscaped
import com.t8rin.imagetoolbox.core.utils.makeLog
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.utils.io.jvm.javaio.copyTo
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import javax.inject.Inject
import kotlin.math.roundToInt
internal class AndroidDownloadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val keepAliveService: KeepAliveService,
private val client: HttpClient,
resourceManager: ResourceManager,
dispatchersHolder: DispatchersHolder
) : DownloadManager,
ResourceManager by resourceManager,
DispatchersHolder by dispatchersHolder {
override suspend fun download(
url: String,
destinationPath: String,
onStart: suspend () -> Unit,
onProgress: suspend (DownloadProgress) -> Unit,
onFinish: suspend (Throwable?) -> Unit
): Unit = withContext(defaultDispatcher) {
keepAliveService.track(
initial = {
updateOrStart(
title = getString(R.string.downloading)
)
},
onFailure = onFinish
) {
channelFlow {
onStart()
url.makeLog("Start Download")
val tmp = File(
File(context.cacheDir, "downloadCache").apply(File::mkdirs),
"${url.substringAfterLast('/').substringBefore('?')}.tmp"
)
client.getWithProgress(
url = url,
onFailure = onFinish,
onProgress = { bytesSentTotal, contentLength ->
val progress = contentLength?.let {
bytesSentTotal.toFloat() / contentLength.toFloat()
} ?: 0f
onProgress(
DownloadProgress(
currentPercent = progress,
currentTotalSize = contentLength ?: -1
).also(::trySend)
)
},
onOpen = { response ->
tmp.outputStream().use { fos ->
response.copyTo(fos)
tmp.renameTo(File(destinationPath))
tmp.delete()
onFinish(null)
}
}
)
tmp.delete()
close()
}.throttleLatest(50).collect {
updateProgress(
title = getString(R.string.downloading),
done = (it.currentPercent * 100).roundToInt(),
total = 100
)
}
}
}
override suspend fun downloadZip(
url: String,
destinationPath: String,
onStart: suspend () -> Unit,
onProgress: (DownloadProgress) -> Unit,
downloadOnlyNewData: Boolean
): Unit = withContext(defaultDispatcher) {
keepAliveService.track(
initial = {
updateOrStart(
title = getString(R.string.downloading)
)
}
) {
channelFlow {
client.getWithProgress(
url = url,
onProgress = { bytesSentTotal, contentLength ->
val progress = contentLength?.let {
bytesSentTotal.toFloat() / contentLength.toFloat()
} ?: 0f
onProgress(
DownloadProgress(
currentPercent = progress,
currentTotalSize = contentLength ?: -1
).also(::trySend)
)
},
onOpen = { response ->
ZipInputStream(response.toInputStream()).use { zipIn ->
var entry: ZipEntry?
while (zipIn.nextEntry.also { entry = it } != null) {
entry?.let { zipEntry ->
val filename = zipEntry.name
if (filename.isNullOrBlank() || filename.startsWith("__")) return@let
val outFile = File(
destinationPath,
filename.decodeEscaped()
).apply {
delete()
parentFile?.mkdirs()
createNewFile()
}
if (downloadOnlyNewData) {
val file = File(destinationPath).listFiles()?.find {
it.name == filename && it.length() > 0L
}
if (file != null) return@let
}
FileOutputStream(outFile).use { fos ->
zipIn.copyTo(fos)
}
zipIn.closeEntry()
}
}
}
}
)
close()
}.throttleLatest(50).collect {
updateProgress(
title = getString(R.string.downloading),
done = (it.currentPercent * 100).roundToInt(),
total = 100
)
}
}
}
}
@@ -0,0 +1,123 @@
/*
* 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.core.data.remote
import android.content.Context
import androidx.core.net.toUri
import com.t8rin.imagetoolbox.core.domain.RES_BASE_URL
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.remote.DownloadManager
import com.t8rin.imagetoolbox.core.domain.remote.DownloadProgress
import com.t8rin.imagetoolbox.core.domain.remote.RemoteResource
import com.t8rin.imagetoolbox.core.domain.remote.RemoteResources
import com.t8rin.imagetoolbox.core.domain.remote.RemoteResourcesStore
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.utils.decodeEscaped
import com.t8rin.imagetoolbox.core.utils.makeLog
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
internal class AndroidRemoteResourcesStore @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadManager: DownloadManager,
resourceManager: ResourceManager,
dispatchersHolder: DispatchersHolder
) : RemoteResourcesStore,
DispatchersHolder by dispatchersHolder,
ResourceManager by resourceManager {
override suspend fun getResources(
name: String,
forceUpdate: Boolean,
onDownloadRequest: suspend (name: String) -> RemoteResources?
): RemoteResources? = withContext(ioDispatcher) {
val availableFiles = getSavingDir(name).listFiles()
val shouldDownload = forceUpdate || availableFiles.isNullOrEmpty()
val availableResources = if (!availableFiles.isNullOrEmpty()) {
RemoteResources(
name = name,
list = availableFiles.mapNotNull {
it.toUri().toString()
}.map { uri ->
RemoteResource(
uri = uri,
name = uri.takeLastWhile { it != '/' }.decodeEscaped()
)
}.sortedBy { it.name }
)
} else null
if (shouldDownload) onDownloadRequest(name) ?: availableResources
else availableResources
}
override suspend fun downloadResources(
name: String,
onProgress: (DownloadProgress) -> Unit,
onFailure: (Throwable) -> Unit,
downloadOnlyNewData: Boolean
): RemoteResources? = withContext(defaultDispatcher) {
runSuspendCatching {
val url = getResourcesLink(name)
val savingDir = getSavingDir(name)
downloadManager.downloadZip(
url = url,
destinationPath = savingDir.absolutePath,
onProgress = onProgress,
downloadOnlyNewData = downloadOnlyNewData
)
name to url makeLog "downloadResources"
val savedAlready = savingDir.listFiles()?.mapNotNull {
it.toUri().toString()
}?.map { uri ->
RemoteResource(
uri = uri,
name = uri.takeLastWhile { it != '/' }.decodeEscaped()
)
} ?: emptyList()
RemoteResources(
name = name,
list = savedAlready.sortedBy { it.name }
)
}.onFailure {
it.makeLog()
onFailure(it)
}.getOrNull()
}
private fun getResourcesLink(
dirName: String
): String = RES_BASE_URL.replace("*", dirName)
private fun getSavingDir(
dirName: String
): File = File(rootDir, dirName.substringBefore("/")).apply(File::mkdirs)
private val rootDir = File(context.filesDir, "remoteResources").apply(File::mkdirs)
}
@@ -0,0 +1,75 @@
/*
* 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.core.data.resource
import android.content.Context
import android.content.res.Configuration
import androidx.annotation.StringRes
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import javax.inject.Inject
internal class AndroidResourceManager @Inject constructor(
@ApplicationContext private val context: Context
) : ResourceManager {
override fun getString(
resId: Int
): String = context.getString(resId)
override fun getString(
resId: Int,
vararg formatArgs: Any
): String = context.getString(resId, *formatArgs)
override fun getStringLocalized(
resId: Int,
language: String
): String = context.getStringLocalized(
resId = resId,
locale = Locale.forLanguageTag(language)
)
override fun getStringLocalized(
resId: Int,
language: String,
vararg formatArgs: Any
): String = context.getStringLocalized(
resId = resId,
locale = Locale.forLanguageTag(language),
formatArgs = formatArgs
)
private fun Context.getStringLocalized(
@StringRes
resId: Int,
locale: Locale,
): String = createConfigurationContext(
Configuration(resources.configuration).apply { setLocale(locale) }
).getText(resId).toString()
private fun Context.getStringLocalized(
@StringRes
resId: Int,
locale: Locale,
vararg formatArgs: Any
): String = createConfigurationContext(
Configuration(resources.configuration).apply { setLocale(locale) }
).getString(resId, *formatArgs)
}
@@ -0,0 +1,621 @@
/*
* 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.core.data.saving
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.documentfile.provider.DocumentFile
import coil3.ImageLoader
import com.t8rin.exif.ExifInterface
import com.t8rin.imagetoolbox.core.data.coil.remove
import com.t8rin.imagetoolbox.core.data.image.toMetadata
import com.t8rin.imagetoolbox.core.data.saving.io.UriReadable
import com.t8rin.imagetoolbox.core.data.saving.io.UriWriteable
import com.t8rin.imagetoolbox.core.data.utils.cacheSize
import com.t8rin.imagetoolbox.core.data.utils.clearCache
import com.t8rin.imagetoolbox.core.data.utils.isExternalStorageWritable
import com.t8rin.imagetoolbox.core.data.utils.openFileDescriptor
import com.t8rin.imagetoolbox.core.domain.coroutines.AppScope
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.history.AppHistoryRepository
import com.t8rin.imagetoolbox.core.domain.image.Metadata
import com.t8rin.imagetoolbox.core.domain.image.ShareProvider
import com.t8rin.imagetoolbox.core.domain.image.clearAllAttributes
import com.t8rin.imagetoolbox.core.domain.image.copyTo
import com.t8rin.imagetoolbox.core.domain.image.model.MetadataTag
import com.t8rin.imagetoolbox.core.domain.image.readOnly
import com.t8rin.imagetoolbox.core.domain.json.JsonParser
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
import com.t8rin.imagetoolbox.core.domain.saving.FileController
import com.t8rin.imagetoolbox.core.domain.saving.FilenameCreator
import com.t8rin.imagetoolbox.core.domain.saving.io.Writeable
import com.t8rin.imagetoolbox.core.domain.saving.io.use
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
import com.t8rin.imagetoolbox.core.domain.saving.model.SaveResult
import com.t8rin.imagetoolbox.core.domain.saving.model.SaveTarget
import com.t8rin.imagetoolbox.core.domain.utils.FileMode
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.settings.domain.SettingsManager
import com.t8rin.imagetoolbox.core.settings.domain.model.CopyToClipboardMode
import com.t8rin.imagetoolbox.core.settings.domain.model.FilenameBehavior
import com.t8rin.imagetoolbox.core.settings.domain.model.OneTimeSaveLocation
import com.t8rin.imagetoolbox.core.utils.UriReplacements
import com.t8rin.imagetoolbox.core.utils.fileSize
import com.t8rin.imagetoolbox.core.utils.filename
import com.t8rin.imagetoolbox.core.utils.getPath
import com.t8rin.imagetoolbox.core.utils.listFilesInDirectory
import com.t8rin.imagetoolbox.core.utils.listFilesInDirectoryProgressive
import com.t8rin.imagetoolbox.core.utils.makeLog
import com.t8rin.imagetoolbox.core.utils.tryExtractOriginal
import com.t8rin.imagetoolbox.core.utils.uiPath
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.FileOutputStream
import javax.inject.Inject
import kotlin.reflect.KClass
internal class AndroidFileController @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsManager: SettingsManager,
private val shareProvider: ShareProvider,
private val filenameCreator: FilenameCreator,
private val jsonParser: JsonParser,
private val appHistoryRepository: AppHistoryRepository,
private val appScope: AppScope,
private val originalFileDeletionHelper: OriginalFileDeletionHelper,
private val dataStore: DataStore<Preferences>,
private val imageLoader: ImageLoader,
dispatchersHolder: DispatchersHolder,
resourceManager: ResourceManager,
) : DispatchersHolder by dispatchersHolder,
ResourceManager by resourceManager,
FileController {
private val _settingsState = settingsManager.settingsState
private val settingsState get() = _settingsState.value
override fun getSize(uri: String): Long? = uri.toUri().fileSize()
override val defaultSavingPath: String
get() = settingsState.saveFolderUri.getPath(context)
override suspend fun save(
saveTarget: SaveTarget,
keepOriginalMetadata: Boolean,
oneTimeSaveLocationUri: String?,
): SaveResult {
val result = saveImpl(
saveTarget = saveTarget,
keepOriginalMetadata = keepOriginalMetadata,
oneTimeSaveLocationUri = oneTimeSaveLocationUri
)
Triple(
first = result,
second = keepOriginalMetadata,
third = oneTimeSaveLocationUri
).makeLog("File Controller save")
if (result is SaveResult.Success) {
appHistoryRepository.registerSuccessfulSave(
savedBytes = result.savedBytes,
savedFormat = if (saveTarget is ImageSaveTarget) {
saveTarget.imageFormat.title
} else {
saveTarget.extension
}
)
}
return result
}
private suspend fun saveImpl(
saveTarget: SaveTarget,
keepOriginalMetadata: Boolean,
oneTimeSaveLocationUri: String?,
): SaveResult = withContext(ioDispatcher) {
if (!context.isExternalStorageWritable()) {
return@withContext SaveResult.Error.MissingPermissions
}
val shouldKeepMetadata = keepOriginalMetadata && !settingsState.isAlwaysClearExif
val data = if (saveTarget is ImageSaveTarget && saveTarget.readFromUriInsteadOfData) {
readBytes(saveTarget.originalUri)
} else {
saveTarget.data
}
val savingPath = oneTimeSaveLocationUri?.getPath(context) ?: defaultSavingPath
runSuspendCatching {
if (settingsState.copyToClipboardMode is CopyToClipboardMode.Enabled) {
val clipboardManager = context.getSystemService<ClipboardManager>()
shareProvider.cacheByteArray(
byteArray = data,
filename = filenameCreator.constructRandomFilename(saveTarget.extension)
)?.toUri()?.let { uri ->
clipboardManager?.setPrimaryClip(
ClipData.newUri(
context.contentResolver,
"IMAGE",
uri
)
)
}
}
if (settingsState.copyToClipboardMode is CopyToClipboardMode.Enabled.WithoutSaving) {
return@withContext SaveResult.Success(
message = getString(R.string.copied),
savingPath = savingPath,
savedBytes = data.size.toLong()
)
}
val originalUri = UriReplacements.resolve(saveTarget.originalUri.toUri())
if (saveTarget is ImageSaveTarget && saveTarget.canSkipIfLarger && settingsState.allowSkipIfLarger) {
val originalSize = originalUri.fileSize()
val newSize = data.size
if (originalSize != null && newSize > originalSize) {
return@withContext SaveResult.Skipped(saveTarget.originalUri)
}
}
if (settingsState.filenameBehavior is FilenameBehavior.Overwrite) {
val providedMetadata = (saveTarget as? ImageSaveTarget)
?.metadata
?.takeUnless { settingsState.isAlwaysClearExif }
val targetMetadata = if (shouldKeepMetadata) {
readMetadata(originalUri.toString()) ?: providedMetadata
} else {
providedMetadata
}
val parcel = runCatching {
if (originalUri == Uri.EMPTY) throw IllegalStateException()
context.openFileDescriptor(
uri = originalUri,
mode = FileMode.WriteTruncate
)
}.getOrNull()
if (parcel == null) {
settingsManager.setImagePickerMode(FILE_EXPLORER_PICKER_MODE)
return@withContext SaveResult.Error.Exception(
Throwable(getString(R.string.overwrite_file_requirements))
)
}
parcel.use {
FileOutputStream(parcel.fileDescriptor).use { out ->
out.write(data)
copyMetadata(
initialExif = targetMetadata,
fileUri = originalUri,
keepOriginalMetadata = shouldKeepMetadata,
originalUri = originalUri
)
}
imageLoader.apply {
memoryCache?.remove(originalUri.toString())
diskCache?.remove(originalUri.toString())
}
return@withContext SaveResult.Success(
message = getString(
R.string.saved_to_original,
originalUri.filename(context).toString()
),
isOverwritten = true,
savingPath = savingPath,
savedBytes = data.size.toLong()
)
}
} else {
val documentFile: DocumentFile?
val treeUri = (oneTimeSaveLocationUri ?: settingsState.saveFolderUri).takeIf {
!it.isNullOrEmpty()
}
if (treeUri != null) {
documentFile = runCatching {
treeUri.toUri().let {
if (DocumentFile.isDocumentUri(context, it)) {
DocumentFile.fromSingleUri(context, it)
} else DocumentFile.fromTreeUri(context, it)
}
}.getOrNull()
if (documentFile == null || !documentFile.exists()) {
if (oneTimeSaveLocationUri == null) {
settingsManager.setSaveFolderUri(null)
} else {
settingsManager.setOneTimeSaveLocations(
settingsState.oneTimeSaveLocations.let { locations ->
(locations - locations.find { it.uri == oneTimeSaveLocationUri }).filterNotNull()
}
)
}
return@withContext SaveResult.Error.Exception(
Throwable(
getString(
R.string.no_such_directory,
treeUri.toUri().uiPath(treeUri, context)
)
)
)
}
} else {
documentFile = null
}
var initialExif: Metadata? = null
val newSaveTarget = if (saveTarget is ImageSaveTarget) {
initialExif = saveTarget.metadata.takeUnless { settingsState.isAlwaysClearExif }
saveTarget.copy(
filename = filenameCreator.constructImageFilename(
saveTarget = saveTarget,
forceNotAddSizeInFilename = saveTarget.imageInfo.height <= 0 || saveTarget.imageInfo.width <= 0
)
)
} else saveTarget
val filename = newSaveTarget.filename
?: throw IllegalArgumentException(getString(R.string.filename_is_not_set))
val savingFolder = SavingFolder.getInstance(
context = context,
treeUri = treeUri?.toUri(),
saveTarget = newSaveTarget,
saveToOriginalFolder = settingsState.saveToOriginalFolder
) ?: throw IllegalArgumentException(getString(R.string.error_while_saving))
savingFolder.use {
it.writeBytes(data)
}
copyMetadata(
initialExif = initialExif,
fileUri = savingFolder.fileUri,
keepOriginalMetadata = shouldKeepMetadata,
originalUri = saveTarget.originalUri.toUri()
)
if (settingsState.deleteOriginalsAfterSave && settingsState.filenameBehavior !is FilenameBehavior.Overwrite) {
originalFileDeletionHelper.deleteAfterSuccessfulSave(
originalUri = saveTarget.originalUri,
outputUri = savingFolder.fileUri
)
}
savingFolder.fileUri.path?.takeIf {
savingFolder.fileUri.scheme == "file"
}?.let { path ->
MediaScannerConnection.scanFile(
context,
arrayOf(path),
arrayOf(newSaveTarget.mimeType.entry),
null
)
}
val actualSavingPath = savingFolder.normalizedSavingPath ?: savingPath
oneTimeSaveLocationUri?.let {
if (documentFile?.isDirectory == true) {
val currentLocation =
settingsState.oneTimeSaveLocations.find { it.uri == oneTimeSaveLocationUri }
settingsManager.setOneTimeSaveLocations(
currentLocation?.let {
settingsState.oneTimeSaveLocations.toMutableList().apply {
remove(currentLocation)
add(
currentLocation.copy(
uri = oneTimeSaveLocationUri,
date = System.currentTimeMillis(),
count = currentLocation.count + 1
)
)
}
} ?: settingsState.oneTimeSaveLocations.plus(
OneTimeSaveLocation(
uri = oneTimeSaveLocationUri,
date = System.currentTimeMillis(),
count = 1
)
)
)
}
}
return@withContext SaveResult.Success(
message = if (actualSavingPath.isNotEmpty()) {
val isFile =
(documentFile?.isDirectory != true && oneTimeSaveLocationUri != null)
if (isFile) {
getString(R.string.saved_to_custom)
} else if (filename.isNotEmpty()) {
getString(
R.string.saved_to,
actualSavingPath,
filename
)
} else {
getString(
R.string.saved_to_without_filename,
actualSavingPath
)
}
} else null,
savingPath = actualSavingPath,
savedBytes = data.size.toLong()
)
}
}.onFailure {
return@withContext SaveResult.Error.Exception(it)
}
SaveResult.Error.Exception(
SaveException(
message = getString(R.string.something_went_wrong)
)
)
}
override fun clearCache(
onComplete: (Long) -> Unit,
) {
appScope.launch {
context.clearCache()
onComplete(getCacheSize())
"cache cleared".makeLog("AndroidFileController")
}
}
override fun getCacheSize(): Long = context.cacheSize()
override suspend fun readBytes(
uri: String,
): ByteArray = withContext(ioDispatcher) {
runSuspendCatching {
context.contentResolver.openInputStream(
UriReplacements.resolve(uri.toUri())
)?.use {
it.buffered().readBytes()
}
}.onFailure {
uri.makeLog("File Controller read")
it.makeLog("File Controller read")
}.getOrNull() ?: ByteArray(0)
}
override suspend fun writeBytes(
uri: String,
block: suspend (Writeable) -> Unit,
): SaveResult = withContext(ioDispatcher) {
runSuspendCatching {
block(
UriWriteable(
uri = uri.toUri(),
context = context
)
)
}.onSuccess {
val savedBytes = uri.toUri().fileSize() ?: 0
appHistoryRepository.registerSuccessfulSave(
savedBytes = savedBytes,
savedFormat = ""
)
return@withContext SaveResult.Success(
message = null,
savingPath = "",
savedBytes = savedBytes
)
}.onFailure {
uri.makeLog("File Controller write")
it.makeLog("File Controller write")
return@withContext SaveResult.Error.Exception(it)
}
return@withContext SaveResult.Error.Exception(IllegalStateException())
}
override suspend fun transferBytes(
fromUri: String,
toUri: String
): SaveResult = transferBytes(
fromUri = fromUri,
to = UriWriteable(
uri = toUri.toUri(),
context = context
)
)
override suspend fun transferBytes(
fromUri: String,
to: Writeable
): SaveResult = withContext(ioDispatcher) {
runSuspendCatching {
UriReadable(
uri = UriReplacements.resolve(fromUri.toUri()),
context = context
).copyTo(to)
}.onSuccess {
return@withContext SaveResult.Success(
message = null,
savingPath = ""
)
}.onFailure {
to.makeLog("File Controller write")
it.makeLog("File Controller write")
return@withContext SaveResult.Error.Exception(it)
}
return@withContext SaveResult.Error.Exception(IllegalStateException())
}
override suspend fun <O : Any> saveObject(
key: String,
value: O,
): Boolean = withContext(ioDispatcher) {
"saveObject value = $value".makeLog(key)
runCatching {
dataStore.edit {
it[stringPreferencesKey("fast_$key")] =
jsonParser.toJson(value, value::class.java)!!
}
}.onSuccess {
"saveObject success".makeLog(key)
return@withContext true
}.onFailure {
it.makeLog("saveObject $key")
return@withContext false
}
return@withContext false
}
override suspend fun <O : Any> restoreObject(
key: String,
kClass: KClass<O>,
): O? = withContext(ioDispatcher) {
runCatching {
"restoreObject".makeLog(key)
jsonParser.fromJson<O>(
json = dataStore.data.first()[stringPreferencesKey("fast_$key")].orEmpty(),
type = kClass.java
)
}.onFailure {
it.makeLog("restoreObject $key")
}.onSuccess {
"restoreObject success value = $it".makeLog(key)
}.getOrNull()
}
override suspend fun writeMetadata(
imageUri: String,
metadata: Metadata?
) {
UriReplacements.resolve(imageUri.toUri()).let { resolvedUri ->
copyMetadata(
initialExif = metadata,
fileUri = resolvedUri,
keepOriginalMetadata = false,
originalUri = resolvedUri
)
}
}
override suspend fun readMetadata(
imageUri: String
): Metadata? = runSuspendCatching {
context.contentResolver.openInputStream(
UriReplacements.resolve(imageUri.toUri())
)?.use {
ExifInterface(it).toMetadata().readOnly().makeLog("readMetadata")
}
}.getOrNull()
override suspend fun listFilesInDirectory(
treeUri: String
): List<String> = withContext(ioDispatcher) {
treeUri.toUri().listFilesInDirectory().map { it.toString() }
}
override fun listFilesInDirectoryAsFlow(
treeUri: String
): Flow<String> = treeUri.toUri().listFilesInDirectoryProgressive().map {
it.toString()
}.flowOn(ioDispatcher)
private suspend fun copyMetadata(
initialExif: Metadata?,
fileUri: Uri,
keepOriginalMetadata: Boolean,
originalUri: Uri
) = runSuspendCatching {
if (initialExif != null) {
openFileDescriptor(fileUri)?.use {
initialExif.makeLog("initialMetadata")
.copyTo(it.fileDescriptor.toMetadata().makeLog("dstMetadata"))
}
} else if (keepOriginalMetadata) {
if (fileUri != originalUri) {
openFileDescriptor(fileUri)?.use {
readMetadata(originalUri.toString()).makeLog("srcMetadata")?.copyTo(
it.fileDescriptor.toMetadata().makeLog("dstMetadata")
)
}
} else {
"Nothing, copying from self to self is pointless".makeLog("copyMetadata")
}
} else {
openFileDescriptor(fileUri)?.use {
it.fileDescriptor.toMetadata().apply {
clearAllAttributes()
if (settingsState.keepDateTime && !settingsState.isAlwaysClearExif) {
readMetadata(originalUri.toString()).makeLog("srcMetadata")
?.copyTo(
metadata = this,
tags = MetadataTag.dateEntries
)
} else {
saveAttributes()
}
}
}.makeLog("metadataCleared")
}
}
private fun openFileDescriptor(
imageUri: Uri
) = context.openFileDescriptor(
uri = imageUri.tryExtractOriginal(),
mode = FileMode.ReadWrite
)
}
private const val FILE_EXPLORER_PICKER_MODE = 3
@@ -0,0 +1,331 @@
/*
* 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.core.data.saving
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import com.t8rin.imagetoolbox.core.data.utils.computeFromByteArray
import com.t8rin.imagetoolbox.core.domain.coroutines.DispatchersHolder
import com.t8rin.imagetoolbox.core.domain.image.model.ImageScaleMode
import com.t8rin.imagetoolbox.core.domain.image.model.Preset
import com.t8rin.imagetoolbox.core.domain.image.model.title
import com.t8rin.imagetoolbox.core.domain.resource.ResourceManager
import com.t8rin.imagetoolbox.core.domain.saving.FilenameCreator
import com.t8rin.imagetoolbox.core.domain.saving.RandomStringGenerator
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Date
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.DateUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Extension
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.ExtensionUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Height
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.OriginalName
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.OriginalNameUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.ParentFolder
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.ParentFolderUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Prefix
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.PrefixUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.PresetInfo
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.PresetInfoUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Rand
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.ScaleMode
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.ScaleModeUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Sequence
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Suffix
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.SuffixUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Uuid
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.UuidUpper
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.Width
import com.t8rin.imagetoolbox.core.domain.saving.model.FilenamePattern.Companion.replace
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
import com.t8rin.imagetoolbox.core.domain.utils.slice
import com.t8rin.imagetoolbox.core.domain.utils.timestamp
import com.t8rin.imagetoolbox.core.domain.utils.transliterate
import com.t8rin.imagetoolbox.core.settings.domain.SettingsManager
import com.t8rin.imagetoolbox.core.settings.domain.model.FilenameBehavior
import com.t8rin.imagetoolbox.core.utils.filename
import com.t8rin.imagetoolbox.core.utils.makeLog
import com.t8rin.imagetoolbox.core.utils.path
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlin.random.Random
import kotlin.uuid.Uuid as UUID
internal class AndroidFilenameCreator @Inject constructor(
private val randomStringGenerator: RandomStringGenerator,
@ApplicationContext private val context: Context,
settingsManager: SettingsManager,
dispatchersHolder: DispatchersHolder,
resourceManager: ResourceManager
) : FilenameCreator,
DispatchersHolder by dispatchersHolder,
ResourceManager by resourceManager {
private val _settingsState = settingsManager.settingsState
private val settingsState get() = _settingsState.value
override fun constructImageFilename(
saveTarget: ImageSaveTarget,
oneTimePrefix: String?,
forceNotAddSizeInFilename: Boolean,
pattern: String?
): String {
val extension = saveTarget.extension
return when (val behavior = settingsState.filenameBehavior) {
is FilenameBehavior.Checksum -> "${behavior.hashingType.computeFromByteArray(saveTarget.data)}.$extension"
is FilenameBehavior.Random -> "${randomStringGenerator.generate(32)}.$extension"
is FilenameBehavior.Overwrite,
is FilenameBehavior.None -> constructImageFilename(
saveTarget = saveTarget,
oneTimePrefix = oneTimePrefix,
pattern = (pattern ?: settingsState.filenamePattern).orEmpty().ifBlank {
if (settingsState.addOriginalFilename) {
FilenamePattern.ForOriginal
} else {
FilenamePattern.Default
}
}
)
}.makeLog("Filename")
}
private fun constructImageFilename(
saveTarget: ImageSaveTarget,
oneTimePrefix: String?,
pattern: String
): String {
val random = Random(Date().time)
val extension = saveTarget.extension
val isOriginalEmpty = saveTarget.originalUri.toUri() == Uri.EMPTY
val widthString = if (settingsState.addSizeInFilename) {
if (isOriginalEmpty) "" else saveTarget.imageInfo.width.toString()
} else ""
val heightString = if (settingsState.addSizeInFilename) {
if (isOriginalEmpty) "" else saveTarget.imageInfo.height.toString()
} else ""
val prefix = oneTimePrefix ?: settingsState.filenamePrefix
val suffix = settingsState.filenameSuffix
val originalName = if (settingsState.addOriginalFilename && !isOriginalEmpty) {
saveTarget.originalUri.toUri().filename(context)
?.substringBeforeLast('.').orEmpty()
} else ""
val presetInfo =
if (settingsState.addPresetInfoToFilename && saveTarget.presetInfo != null && saveTarget.presetInfo != Preset.None) {
saveTarget.presetInfo?.asString().orEmpty()
} else ""
val scaleModeInfo =
if (settingsState.addImageScaleModeInfoToFilename && saveTarget.imageInfo.imageScaleMode != ImageScaleMode.NotPresent) {
getStringLocalized(
resId = saveTarget.imageInfo.imageScaleMode.title,
language = Locale.ENGLISH.language
).replace(" ", "-")
} else ""
val randomNumber: (count: Int) -> String = { count ->
buildString {
repeat(count.coerceAtMost(500)) {
append(random.nextInt(0, 10))
}
}.take(count)
}
val timestampString = if (settingsState.addTimestampToFilename) {
if (settingsState.useFormattedFilenameTimestamp) {
"${timestamp()}_${randomNumber(4)}"
} else Date().time.toString()
} else ""
val sequenceNumber = saveTarget.sequenceNumber?.toString() ?: ""
var result = pattern
runCatching {
result = result.replace("""\\d\{(.*?)\}""".toRegex()) { match ->
if (settingsState.addTimestampToFilename) {
timestamp(
format = match.groupValues[1]
)
} else {
""
}
}
result = result.replace("""\\D\{(.*?)\}""".toRegex()) { match ->
if (settingsState.addTimestampToFilename) {
timestamp(
format = match.groupValues[1]
).uppercase()
} else {
""
}
}
}.onFailure {
it.makeLog("pattern date")
}
runCatching {
result = result.replace("""\\r\{(\d+)\}""".toRegex()) { match ->
randomNumber(match.groupValues[1].toIntOrNull() ?: 4)
}
}.onFailure {
it.makeLog("pattern random")
}
runCatching {
result = result.replace("""\\c\{([^}]*)\}""".toRegex()) { match ->
val params = match.groupValues[1].split(":")
val start = params.getOrNull(0)?.toIntOrNull() ?: 1
val step = params.getOrNull(1)?.toIntOrNull() ?: 1
val padding = params.getOrNull(2)?.toIntOrNull()
?: if (params.size == 1) params[0].toIntOrNull() ?: 0 else 0
val current = start + ((saveTarget.sequenceNumber ?: 1) - 1) * step
current.toString().padStart(padding, '0')
}
}.onFailure {
it.makeLog("pattern sequence")
}
runCatching {
result = result.replace("""\\o\{(-?\d+)?:(-?\d+)?\}""".toRegex()) { match ->
if (settingsState.addOriginalFilename && !isOriginalEmpty) {
val start = match.groupValues[1].toIntOrNull()
val end = match.groupValues[2].toIntOrNull()
originalName.slice(start, end)
} else {
""
}
}
result = result.replace("""\\O\{(-?\d+)?:(-?\d+)?\}""".toRegex()) { match ->
if (settingsState.addOriginalFilename && !isOriginalEmpty) {
val start = match.groupValues[1].toIntOrNull()
val end = match.groupValues[2].toIntOrNull()
originalName.uppercase().slice(start, end)
} else {
""
}
}
result = result.replace("""\\o\{t\}""".toRegex()) {
if (settingsState.addOriginalFilename && !isOriginalEmpty) {
originalName.transliterate()
} else {
""
}
}
result = result.replace("""\\O\{t\}""".toRegex()) {
if (settingsState.addOriginalFilename && !isOriginalEmpty) {
originalName.uppercase().transliterate()
} else {
""
}
}
result = result.replace("""\\o\{s/(.*?)/(.*?)/\}""".toRegex()) { match ->
if (settingsState.addOriginalFilename && !isOriginalEmpty) {
val old = match.groupValues[1]
val new = match.groupValues[2]
originalName.replace(old, new)
} else {
""
}
}
result = result.replace("""\\O\{s/(.*?)/(.*?)/\}""".toRegex()) { match ->
if (settingsState.addOriginalFilename && !isOriginalEmpty) {
val old = match.groupValues[1]
val new = match.groupValues[2]
originalName.uppercase().replace(old, new)
} else {
""
}
}
}.onFailure {
it.makeLog("pattern original slice")
}
val parentFolder = if (isOriginalEmpty) ""
else saveTarget.originalUri.toUri().path()?.let { path ->
if ('/' in path) {
path.substringBeforeLast('/').substringAfterLast('/')
} else ""
}.orEmpty()
return result
.replace(Width, widthString)
.replace(Height, heightString)
.replace(Prefix, prefix)
.replace(Suffix, suffix)
.replace(OriginalName, originalName)
.replace(Sequence, sequenceNumber)
.replace(PresetInfo, presetInfo)
.replace(ScaleMode, scaleModeInfo)
.replace(Extension, extension)
.replace(Rand, randomNumber(4))
.replace(Date, timestampString)
.replace(ParentFolder, parentFolder)
.replace(Uuid, UUID.random().toString())
.replace(PrefixUpper, prefix.uppercase())
.replace(SuffixUpper, suffix.uppercase())
.replace(OriginalNameUpper, originalName.uppercase())
.replace(PresetInfoUpper, presetInfo.uppercase())
.replace(ScaleModeUpper, scaleModeInfo.uppercase())
.replace(ExtensionUpper, extension.uppercase())
.replace(DateUpper, timestampString.uppercase())
.replace(ParentFolderUpper, parentFolder.uppercase())
.replace(UuidUpper, UUID.random().toString().uppercase())
.clean()
.let { str ->
if (str.split(".").filter { it.isNotBlank() }.size < 2) {
"image${randomNumber(4)}.$extension"
} else {
str
}
}
.clean()
}
private fun String.clean(): String = this
.replace("[]", "")
.replace("{}", "")
.replace("()x()", "")
.replace("()", "")
.replace("___", "_")
.replace("__", "_")
.replace("_.", ".")
.removePrefix("_")
override fun constructRandomFilename(
extension: String,
length: Int
): String = "${randomStringGenerator.generate(length)}.${extension}"
override fun getFilename(uri: String): String = uri.toUri().filename(context) ?: ""
}
@@ -0,0 +1,71 @@
/*
* 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.core.data.saving
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.t8rin.imagetoolbox.core.data.saving.KeepAliveForegroundService.Companion.ACTION_STOP
import com.t8rin.imagetoolbox.core.data.saving.KeepAliveForegroundService.Companion.ACTION_UPDATE
import com.t8rin.imagetoolbox.core.data.saving.KeepAliveForegroundService.Companion.EXTRA_DESC
import com.t8rin.imagetoolbox.core.data.saving.KeepAliveForegroundService.Companion.EXTRA_PROGRESS
import com.t8rin.imagetoolbox.core.data.saving.KeepAliveForegroundService.Companion.EXTRA_REMOVE_NOTIFICATION
import com.t8rin.imagetoolbox.core.data.saving.KeepAliveForegroundService.Companion.EXTRA_TITLE
import com.t8rin.imagetoolbox.core.domain.saving.FailureNotifier
import com.t8rin.imagetoolbox.core.domain.saving.KeepAliveService
import com.t8rin.imagetoolbox.core.domain.utils.tryAll
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
internal class AndroidKeepAliveService @Inject constructor(
@ApplicationContext private val context: Context,
failureNotifier: FailureNotifier
) : KeepAliveService, FailureNotifier by failureNotifier {
private val baseIntent: Intent
get() = Intent(context, KeepAliveForegroundService::class.java)
override fun updateOrStart(
title: String,
description: String,
progress: Float
) {
val intent = baseIntent
.setAction(ACTION_UPDATE)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_DESC, description)
.putExtra(EXTRA_PROGRESS, progress)
tryAll(
{ ContextCompat.startForegroundService(context, intent) },
{ context.startService(intent) }
)
}
override fun stop(removeNotification: Boolean) {
val intent = baseIntent
.setAction(ACTION_STOP)
.putExtra(EXTRA_REMOVE_NOTIFICATION, removeNotification)
tryAll(
{ context.startService(intent) },
{ context.stopService(intent) }
)
}
}
@@ -0,0 +1,53 @@
/*
* 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.core.data.saving
import android.content.IntentSender
import kotlinx.coroutines.flow.Flow
interface FileControllerEventEmitter {
val events: Flow<FileControllerEvent>
suspend fun deleteFiles(
uris: List<String>,
onResult: (FileDeletionResult) -> Unit
)
fun onDeleteOriginalsPermissionResult(
requestId: Long,
granted: Boolean
)
}
data class FileDeletionResult(
val deletedUris: List<String>,
val failedUris: List<String>
)
sealed interface FileControllerEvent {
data class RequestDeleteOriginalsPermission(
val requestId: Long,
val intentSender: IntentSender,
val count: Int
) : FileControllerEvent
data class OriginalFilesDeleteResult(
val deleted: Int,
val failed: Int
) : FileControllerEvent
}
@@ -0,0 +1,221 @@
/*
* 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.core.data.saving
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.getSystemService
import com.t8rin.imagetoolbox.core.domain.saving.KeepAliveService
import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.utils.makeLog
import kotlin.math.roundToInt
internal class KeepAliveForegroundService : Service() {
private val notificationManager: NotificationManager by lazy {
getSystemService<NotificationManager>()!!
}
private var title = ""
private var description = ""
private var progress: Float = KeepAliveService.PROGRESS_NO_PROGRESS
private var removeNotification: Boolean = true
override fun onCreate() {
super.onCreate()
createChannel()
startForeground()
}
override fun onDestroy() {
super.onDestroy()
stopForegroundSafe()
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
if (intent == null) {
startForeground()
Handler(Looper.getMainLooper()).postDelayed(
{
runCatching {
stopForegroundSafe()
stopSelfResult(startId)
}.onFailure { it.makeLog("KeepAliveForegroundService") }
},
1000
)
return START_NOT_STICKY
}
handleIntent(intent)
return START_NOT_STICKY
}
private fun handleIntent(intent: Intent) {
when (intent.action.makeLog("KeepAliveForegroundService")) {
ACTION_UPDATE -> {
title = intent.getStringExtra(EXTRA_TITLE) ?: title
description = intent.getStringExtra(EXTRA_DESC) ?: description
progress = intent.getFloatExtra(
EXTRA_PROGRESS,
KeepAliveService.PROGRESS_NO_PROGRESS
)
"UPDATE: title = $title, description = $description, progress = $progress".makeLog("KeepAliveForegroundService")
startForeground()
}
ACTION_STOP -> {
startForeground()
removeNotification = intent.getBooleanExtra(
EXTRA_REMOVE_NOTIFICATION, true
).makeLog("KeepAliveForegroundService")
Handler(Looper.getMainLooper()).postDelayed(
::stopForegroundSafe,
1000
)
}
else -> startForeground()
}
}
private fun startForeground() {
val action = {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
)
}
runCatching {
action()
}.onFailure {
action()
it.makeLog()
"startForeground failed".makeLog("KeepAliveForegroundService")
}
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.processing_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
enableVibration(false)
}
notificationManager.createNotificationChannel(channel)
}
}
override fun onTimeout(startId: Int, fgsType: Int) {
"Timeout: startId = $startId, fgsType = $fgsType".makeLog("KeepAliveForegroundService")
removeNotification = true
stopForegroundSafe()
}
@Suppress("DEPRECATION")
private fun stopForegroundSafe() {
stopForeground(
if (removeNotification) {
STOP_FOREGROUND_REMOVE
} else {
STOP_FOREGROUND_DETACH
}
)
if (removeNotification) {
notificationManager.cancel(NOTIFICATION_ID)
}
stopSelf()
}
private fun buildNotification(): Notification {
val launchIntent = Intent(
this, Class.forName(
"com.t8rin.imagetoolbox.app.presentation.AppActivity"
)
).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentPendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (PendingIntent.FLAG_IMMUTABLE)
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title.ifEmpty { getString(R.string.processing) })
.setContentText(description.takeIf { it.isNotEmpty() })
.setSmallIcon(R.drawable.ic_notification_icon)
.run {
when (progress) {
KeepAliveService.PROGRESS_NO_PROGRESS -> this
KeepAliveService.PROGRESS_INDETERMINATE -> setProgress(0, 0, true)
else -> setProgress(100, (progress * 100).roundToInt(), false)
}
}
.setOngoing(true)
.setContentIntent(contentPendingIntent)
.build()
.also { notification ->
runCatching {
notificationManager.notify(NOTIFICATION_ID, notification)
}.onFailure { it.makeLog("KeepAliveForegroundService") }
}
}
override fun onBind(intent: Intent?) = null
companion object {
internal const val CHANNEL_ID = "keep_alive_channel"
internal const val NOTIFICATION_ID = 1
internal const val ACTION_STOP = "ACTION_STOP"
internal const val ACTION_UPDATE = "ACTION_UPDATE"
internal const val EXTRA_TITLE = "EXTRA_TITLE"
internal const val EXTRA_DESC = "EXTRA_DESC"
internal const val EXTRA_PROGRESS = "EXTRA_PROGRESS"
internal const val EXTRA_REMOVE_NOTIFICATION = "REMOVE_NOTIFICATION"
}
}
@@ -0,0 +1,521 @@
/*
* 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.core.data.saving
import android.app.RecoverableSecurityException
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.IntentSender
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.system.Os
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.t8rin.imagetoolbox.core.domain.coroutines.AppScope
import com.t8rin.imagetoolbox.core.utils.UriReplacements
import com.t8rin.imagetoolbox.core.utils.distinctUris
import com.t8rin.imagetoolbox.core.utils.tryExtractOriginal
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class OriginalFileDeletionHelper @Inject constructor(
@ApplicationContext private val context: Context,
private val appScope: AppScope
) : FileControllerEventEmitter {
private val eventChannel = Channel<FileControllerEvent>(Channel.UNLIMITED)
override val events: Flow<FileControllerEvent> = eventChannel.receiveAsFlow()
private val pendingDeleteUris = ConcurrentHashMap.newKeySet<Uri>()
private val pendingDeleteRequests = ConcurrentHashMap<Long, List<Uri>>()
private val pendingDeleteOperations = ConcurrentHashMap<Uri, DeleteOperation>()
private val nextDeleteRequestId = AtomicLong()
private val pendingDeleteLock = Any()
private var pendingDeleteJob: Job? = null
private val pendingDeletedCount = AtomicInteger()
private val pendingFailedCount = AtomicInteger()
private val deleteResultLock = Any()
private var deleteResultJob: Job? = null
override suspend fun deleteFiles(
uris: List<String>,
onResult: (FileDeletionResult) -> Unit
) {
val distinctUris = uris
.map { it.toUri().tryExtractOriginal() }
.distinctUris()
.map(Uri::toString)
if (distinctUris.isEmpty()) {
onResult(FileDeletionResult(emptyList(), emptyList()))
return
}
val batch = DeleteBatch(
size = distinctUris.size,
onResult = { result ->
emitDeleteResult(
deleted = result.deletedUris.size,
failed = result.failedUris.size
)
onResult(result)
}
)
appScope.launch {
distinctUris.forEach { source ->
val sourceUri = source.toUri()
val resolvedUri = UriReplacements.resolve(sourceUri)
deleteFile(
DeleteOperation(
originalUri = resolvedUri,
onResult = { deleted ->
batch.complete(source, deleted)
}
)
)
}
}
}
fun deleteAfterSuccessfulSave(
originalUri: String,
outputUri: Uri
) {
val sourceUri = originalUri
.takeIf(String::isNotBlank)
?.toUri()
?: return
val resolvedUri = UriReplacements.resolve(sourceUri)
if (resolvedUri.scheme !in DELETABLE_URI_SCHEMES) return
if (resolvedUri == outputUri) return
val mediaStoreUri = resolvedUri.toMediaStoreItemUri()
val outputMediaStoreUri = outputUri.toMediaStoreItemUri()
if (mediaStoreUri != null && outputMediaStoreUri != null) {
if (mediaStoreUri == outputMediaStoreUri) return
} else if (resolvedUri.refersToSameFileAs(outputUri)) {
return
}
val replacement = UriReplacement(
sourceUri = sourceUri,
originalUri = resolvedUri,
outputUri = outputUri
)
deleteFile(
DeleteOperation(
originalUri = resolvedUri,
onResult = { deleted ->
if (deleted) registerUriReplacement(replacement)
emitDeleteResult(
deleted = if (deleted) 1 else 0,
failed = if (deleted) 0 else 1
)
}
)
)
}
private fun deleteFile(operation: DeleteOperation) {
val resolvedUri = operation.originalUri
if (resolvedUri.scheme !in DELETABLE_URI_SCHEMES) {
operation.complete(deleted = false)
return
}
if (resolvedUri.scheme == ContentResolver.SCHEME_FILE) {
val file = resolvedUri.path?.let(::File)
if (file == null) {
operation.complete(deleted = false)
return
}
operation.complete(deleted = !file.exists() || file.delete() || !file.exists())
return
}
val mediaStoreUri = resolvedUri.toMediaStoreItemUri()
val documentDeleteResult = runCatching {
DocumentFile.fromSingleUri(context, resolvedUri)?.delete() == true
}
if (documentDeleteResult.getOrDefault(false)) {
operation.complete(deleted = true)
return
}
documentDeleteResult.exceptionOrNull()?.let { throwable ->
if (!resolvedUri.exists()) {
operation.complete(deleted = true)
return
}
if (throwable is SecurityException) {
handleDeleteSecurityException(
throwable = throwable,
mediaStoreUri = mediaStoreUri,
operation = operation
)
return
}
}
try {
val deleted = context.contentResolver.delete(
mediaStoreUri ?: resolvedUri,
null,
null
)
if (deleted > 0 || !resolvedUri.exists()) {
operation.complete(deleted = true)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && mediaStoreUri != null) {
enqueueDeletePermissionRequest(
uri = mediaStoreUri,
operation = operation
)
} else {
operation.complete(deleted = false)
}
} catch (throwable: SecurityException) {
if (!resolvedUri.exists()) {
operation.complete(deleted = true)
} else {
handleDeleteSecurityException(
throwable = throwable,
mediaStoreUri = mediaStoreUri,
operation = operation
)
}
} catch (_: Throwable) {
if (!resolvedUri.exists()) {
operation.complete(deleted = true)
} else {
operation.complete(deleted = false)
}
}
}
private fun handleDeleteSecurityException(
throwable: SecurityException,
mediaStoreUri: Uri?,
operation: DeleteOperation
) {
when {
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
throwable is RecoverableSecurityException -> {
emitRecoverableDeletePermissionRequest(
uri = mediaStoreUri ?: operation.originalUri,
throwable = throwable,
operation = operation
)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && mediaStoreUri != null -> {
enqueueDeletePermissionRequest(
uri = mediaStoreUri,
operation = operation
)
}
else -> operation.complete(deleted = false)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun enqueueDeletePermissionRequest(
uri: Uri,
operation: DeleteOperation
) {
pendingDeleteUris.add(uri)
pendingDeleteOperations[uri] = operation
scheduleDeletePermissionRequest()
}
@RequiresApi(Build.VERSION_CODES.R)
private fun scheduleDeletePermissionRequest() {
synchronized(pendingDeleteLock) {
if (pendingDeleteJob?.isActive == true) return
pendingDeleteJob = appScope.launch {
delay(500L)
val uris = pendingDeleteUris.toList().distinct()
pendingDeleteUris.removeAll(uris.toSet())
if (uris.isNotEmpty()) {
runCatching {
MediaStore.createDeleteRequest(
context.contentResolver,
uris
).intentSender
}.onSuccess { intentSender ->
emitDeletePermissionRequest(
uris = uris,
intentSender = intentSender
)
}.onFailure {
uris.forEach { uri ->
pendingDeleteOperations.remove(uri)?.complete(deleted = false)
}
}
}
synchronized(pendingDeleteLock) {
pendingDeleteJob = null
}
if (pendingDeleteUris.isNotEmpty()) {
scheduleDeletePermissionRequest()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun emitRecoverableDeletePermissionRequest(
uri: Uri,
throwable: RecoverableSecurityException,
operation: DeleteOperation
) {
pendingDeleteOperations[uri] = operation
emitDeletePermissionRequest(
uris = listOf(uri),
intentSender = throwable.userAction.actionIntent.intentSender
)
}
private fun emitDeletePermissionRequest(
uris: List<Uri>,
intentSender: IntentSender
) {
val requestId = nextDeleteRequestId.incrementAndGet()
pendingDeleteRequests[requestId] = uris
eventChannel.trySend(
FileControllerEvent.RequestDeleteOriginalsPermission(
requestId = requestId,
intentSender = intentSender,
count = uris.size
)
)
}
override fun onDeleteOriginalsPermissionResult(
requestId: Long,
granted: Boolean
) {
val uris = pendingDeleteRequests.remove(requestId) ?: return
uris.forEach { uri ->
pendingDeleteOperations.remove(uri)?.complete(deleted = granted)
}
}
private fun registerUriReplacement(replacement: UriReplacement) {
UriReplacements.register(
originalUri = replacement.sourceUri,
replacementUri = replacement.outputUri
)
UriReplacements.register(
originalUri = replacement.originalUri,
replacementUri = replacement.outputUri
)
}
private fun emitDeleteResult(
deleted: Int,
failed: Int
) {
pendingDeletedCount.addAndGet(deleted)
pendingFailedCount.addAndGet(failed)
synchronized(deleteResultLock) {
if (deleteResultJob?.isActive == true) return
deleteResultJob = appScope.launch {
delay(300L)
val deletedCount = pendingDeletedCount.getAndSet(0)
val failedCount = pendingFailedCount.getAndSet(0)
if (deletedCount + failedCount > 0) {
eventChannel.send(
FileControllerEvent.OriginalFilesDeleteResult(
deleted = deletedCount,
failed = failedCount
)
)
}
synchronized(deleteResultLock) {
deleteResultJob = null
}
if (pendingDeletedCount.get() + pendingFailedCount.get() > 0) {
emitDeleteResult(deleted = 0, failed = 0)
}
}
}
}
private fun Uri.exists(): Boolean {
if (scheme == "file") return path?.let(::File)?.exists() == true
val queryResult = runCatching {
context.contentResolver.query(
this,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null
)?.use { cursor -> cursor.moveToFirst() }
}.getOrNull()
if (queryResult == true) {
return runCatching {
context.contentResolver.openFileDescriptor(this, "r")?.use { true } ?: false
}.getOrElse { it is SecurityException }
}
return queryResult ?: runCatching {
DocumentFile.fromSingleUri(context, this)?.exists()
}.getOrNull() ?: true
}
private fun Uri.toMediaStoreItemUri(): Uri? {
if (authority == MediaStore.AUTHORITY) {
return takeIf { lastPathSegment?.toLongOrNull() != null }
}
if (authority != MEDIA_DOCUMENTS_AUTHORITY) return null
val documentId = runCatching {
DocumentsContract.getDocumentId(this)
}.getOrNull() ?: return null
val (type, idString) = documentId.split(':', limit = 2).takeIf {
it.size == 2
} ?: return null
val id = idString.toLongOrNull() ?: return null
val collection = when (type) {
"image" -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
"video" -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
"audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> return null
}
return ContentUris.withAppendedId(collection, id)
}
private fun Uri.refersToSameFileAs(other: Uri): Boolean {
if (this == other) return true
val firstFile = takeIf { scheme == ContentResolver.SCHEME_FILE }
?.path
?.let(::File)
val secondFile = other.takeIf { it.scheme == ContentResolver.SCHEME_FILE }
?.path
?.let(::File)
if (firstFile != null && secondFile != null) {
return runCatching {
firstFile.canonicalFile == secondFile.canonicalFile
}.getOrDefault(false)
}
return runCatching {
openFileDescriptor()?.use { firstDescriptor ->
other.openFileDescriptor()?.use { secondDescriptor ->
val firstStat = Os.fstat(firstDescriptor.fileDescriptor)
val secondStat = Os.fstat(secondDescriptor.fileDescriptor)
firstStat.st_ino != 0L &&
firstStat.st_dev == secondStat.st_dev &&
firstStat.st_ino == secondStat.st_ino
}
}
}.getOrNull() == true
}
private fun Uri.openFileDescriptor(): ParcelFileDescriptor? = when (scheme) {
ContentResolver.SCHEME_FILE -> path?.let(::File)?.let {
ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY)
}
ContentResolver.SCHEME_CONTENT -> context.contentResolver.openFileDescriptor(this, "r")
else -> null
}
}
private class DeleteBatch(
size: Int,
private val onResult: (FileDeletionResult) -> Unit
) {
private val remaining = AtomicInteger(size)
private val deletedUris = mutableListOf<String>()
private val failedUris = mutableListOf<String>()
fun complete(
uri: String,
deleted: Boolean
) {
val result = synchronized(this) {
if (deleted) deletedUris += uri else failedUris += uri
if (remaining.decrementAndGet() == 0) {
FileDeletionResult(
deletedUris = deletedUris.toList(),
failedUris = failedUris.toList()
)
} else null
}
result?.let(onResult)
}
}
private data class DeleteOperation(
val originalUri: Uri,
val onResult: (Boolean) -> Unit
) {
fun complete(deleted: Boolean) = onResult(deleted)
}
private const val MEDIA_DOCUMENTS_AUTHORITY = "com.android.providers.media.documents"
private val DELETABLE_URI_SCHEMES = setOf(
ContentResolver.SCHEME_CONTENT,
ContentResolver.SCHEME_FILE
)
private data class UriReplacement(
val sourceUri: Uri,
val originalUri: Uri,
val outputUri: Uri
)
@@ -0,0 +1,20 @@
/*
* 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.core.data.saving
class SaveException(override val message: String?) : Throwable(message)
@@ -0,0 +1,283 @@
/*
* 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.core.data.saving
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.t8rin.imagetoolbox.core.data.saving.io.StreamWriteable
import com.t8rin.imagetoolbox.core.domain.saving.model.ImageSaveTarget
import com.t8rin.imagetoolbox.core.domain.saving.model.SaveTarget
import com.t8rin.imagetoolbox.core.utils.makeLog
import com.t8rin.imagetoolbox.core.utils.path
import com.t8rin.imagetoolbox.core.utils.tryExtractOriginal
import kotlinx.coroutines.coroutineScope
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
@ConsistentCopyVisibility
internal data class SavingFolder private constructor(
val outputStream: OutputStream,
val fileUri: Uri,
private val savingPath: String? = null
) : StreamWriteable by StreamWriteable(outputStream) {
val normalizedSavingPath = savingPath?.removeSuffix("/")?.removePrefix("/storage/emulated/0/")
companion object {
suspend fun getInstance(
context: Context,
treeUri: Uri?,
saveTarget: SaveTarget,
saveToOriginalFolder: Boolean
): SavingFolder? = coroutineScope {
val originalFolder = if (saveToOriginalFolder) {
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) {
createLegacyFile(
saveTarget = saveTarget,
parent = saveTarget.originalParentFile(context)
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveTarget.originalRelativePath(context)?.let {
context.createViaMediaStore(
saveTarget = saveTarget,
relativePath = it
)
}
} else {
createLegacyFile(
saveTarget = saveTarget,
parent = saveTarget.originalParentFile(context)
)
}
}.onFailure { it.makeLog("saveToOriginalFolder") }.getOrNull()
} else null
originalFolder ?: if (treeUri == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.createViaMediaStore(
saveTarget = saveTarget,
relativePath = null
)
} else {
createDefaultFolderFile(saveTarget)
}
} else if (DocumentFile.isDocumentUri(context, treeUri)) {
SavingFolder(
outputStream = context.contentResolver.openOutputStream(treeUri)
?: return@coroutineScope null,
fileUri = treeUri,
savingPath = treeUri.path()
)
} else {
val documentFile = DocumentFile.fromTreeUri(context, treeUri)
if (documentFile == null || !documentFile.exists()) {
return@coroutineScope null
}
val filename = saveTarget.filename ?: return@coroutineScope null
val file = documentFile.createFile(
saveTarget.mimeType.entry,
filename
)
val imageUri = file?.uri ?: return@coroutineScope null
SavingFolder(
outputStream = context.contentResolver.openOutputStream(imageUri)
?: return@coroutineScope null,
fileUri = imageUri,
savingPath = documentFile.uri.path()
)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun Context.createViaMediaStore(
saveTarget: SaveTarget,
relativePath: String?
): SavingFolder? {
val filename = saveTarget.filename ?: return null
val mimeType = saveTarget.mimeType.entry
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
relativePath ?: "${Environment.DIRECTORY_DOCUMENTS}/ImageToolbox"
)
}
val primaryDirectory = relativePath
?.trimStart('/')
?.substringBefore('/')
val collectionUri = when {
relativePath == null -> MediaStore.Files.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
primaryDirectory.equals(Environment.DIRECTORY_DOWNLOADS, ignoreCase = true) -> {
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
primaryDirectory.equals(Environment.DIRECTORY_DOCUMENTS, ignoreCase = true) -> {
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
mimeType.startsWith("image/") -> MediaStore.Images.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
mimeType.startsWith("video/") -> MediaStore.Video.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
mimeType.startsWith("audio/") -> MediaStore.Audio.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
val uri = runCatching {
contentResolver.insert(collectionUri, contentValues)
}.getOrNull() ?: return null
return SavingFolder(
outputStream = contentResolver.openOutputStream(uri)
?: return null,
fileUri = uri,
savingPath = relativePath ?: "${Environment.DIRECTORY_DOCUMENTS}/ImageToolbox"
)
}
private fun createDefaultFolderFile(
saveTarget: SaveTarget
): SavingFolder? = createLegacyFile(
saveTarget = saveTarget,
parent = File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOCUMENTS
),
"ImageToolbox"
)
)
private fun createLegacyFile(
saveTarget: SaveTarget,
parent: File?
): SavingFolder? {
val filename = saveTarget.filename ?: return null
val dir = parent ?: return null
if (!dir.exists()) dir.mkdirs()
if (!dir.exists() || !dir.isDirectory) return null
val file = File(dir, filename)
return SavingFolder(
outputStream = FileOutputStream(file),
fileUri = file.toUri(),
savingPath = dir.absolutePath
)
}
private fun SaveTarget.originalParentFile(context: Context): File? {
val originalUri = (this as? ImageSaveTarget)
?.originalUri
?.toUri()
?: return null
val originalPath = originalUri.externalStoragePath(context) ?: originalUri.path()
?.takeIf { it.isNotBlank() }
?: return null
return File(originalPath)
.parentFile
?.takeIf { it.exists() && it.isDirectory }
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun SaveTarget.originalRelativePath(
context: Context
): String? {
val originalUri = (this as? ImageSaveTarget)
?.originalUri
?.toUri()
?.tryExtractOriginal()
?: return null
originalUri.externalStorageRelativePath(context)?.let { return it }
return context.contentResolver.query(
originalUri,
arrayOf(MediaStore.MediaColumns.RELATIVE_PATH),
null,
null,
null
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
cursor.getString(
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)
)
}?.takeIf {
it.isNotBlank() && ".transforms" !in it
}
}
private fun Uri.externalStorageRelativePath(context: Context): String? = runCatching {
if (!DocumentsContract.isDocumentUri(context, this)) return@runCatching null
val documentId = DocumentsContract.getDocumentId(this)
val type = documentId.substringBefore(':')
val path = documentId.substringAfter(':', "")
if (type.equals("primary", ignoreCase = true)) {
path.substringBeforeLast('/', "")
.takeIf { it.isNotBlank() }
?.plus('/')
} else null
}.onFailure { it.makeLog("externalStorageRelativePath") }.getOrNull()
private fun Uri.externalStoragePath(context: Context): String? = runCatching {
if (!DocumentsContract.isDocumentUri(context, this)) return@runCatching null
val documentId = DocumentsContract.getDocumentId(this)
val type = documentId.substringBefore(':')
val path = documentId.substringAfter(':', "")
if (type.equals("primary", ignoreCase = true) && path.isNotBlank()) {
File(Environment.getExternalStorageDirectory(), path).absolutePath
} else null
}.onFailure { it.makeLog("externalStoragePath") }.getOrNull()
}
}
@@ -0,0 +1,28 @@
/*
* 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.core.data.saving.io
import java.io.File
class FileWriteable(
private val file: File
) : StreamWriteable by StreamWriteable(file.outputStream())
class FileReadable(
private val file: File
) : StreamReadable by StreamReadable(file.inputStream())
@@ -0,0 +1,87 @@
/*
* 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.core.data.saving.io
import com.t8rin.imagetoolbox.core.domain.saving.io.Readable
import com.t8rin.imagetoolbox.core.domain.saving.io.Writeable
import com.t8rin.imagetoolbox.core.utils.makeLog
import java.io.InputStream
import java.io.OutputStream
private class StreamWriteableImpl(
outputStream: OutputStream
) : StreamWriteable {
override val stream = outputStream
override fun writeBytes(byteArray: ByteArray) = stream.write(byteArray)
override fun close() {
stream.flush()
stream.close()
}
}
private class StreamReadableImpl(
inputStream: InputStream
) : StreamReadable {
override val stream = inputStream
override fun readBytes(): ByteArray = stream.readBytes()
override fun copyTo(writeable: Writeable) {
if (writeable is StreamWriteable) {
stream.copyTo(writeable.stream)
} else {
writeable.writeBytes(readBytes())
}
}
override fun close() = stream.close()
}
interface StreamReadable : Readable {
val stream: InputStream
companion object {
operator fun invoke(
inputStream: InputStream
): StreamReadable = StreamReadableImpl(inputStream)
}
}
interface StreamWriteable : Writeable {
val stream: OutputStream
companion object {
operator fun invoke(
outputStream: OutputStream
): StreamWriteable = StreamWriteableImpl(outputStream)
}
}
fun StreamWriteable.shielded(): StreamWriteable = CloseShieldWriteable(this)
private class CloseShieldWriteable(wrapped: StreamWriteable) : StreamWriteable by wrapped {
override fun close() {
"can't be closed".makeLog("CloseShieldWriteable")
}
}
@@ -0,0 +1,60 @@
/*
* 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.core.data.saving.io
import android.content.Context
import android.net.Uri
import com.t8rin.imagetoolbox.core.data.utils.openWriteableStream
import com.t8rin.imagetoolbox.core.utils.makeLog
import io.ktor.utils.io.charsets.Charset
import java.io.ByteArrayOutputStream
class UriReadable(
private val uri: Uri,
private val context: Context
) : StreamReadable by StreamReadable(
inputStream = context.contentResolver.openInputStream(uri) ?: ByteArray(0).inputStream()
)
class UriWriteable(
private val uri: Uri,
private val context: Context
) : StreamWriteable by StreamWriteable(
outputStream = context.openWriteableStream(
uri = uri,
onFailure = {
uri.makeLog("UriWriteable write")
it.makeLog("UriWriteable write")
throw it
}
) ?: ByteArrayOutputStream(0)
)
class ByteArrayReadable(
private val byteArray: ByteArray
) : StreamReadable by StreamReadable(
inputStream = byteArray.inputStream()
)
class StringReadable(
private val string: String,
private val charset: Charset = Charsets.UTF_8
) : StreamReadable by ByteArrayReadable(
byteArray = string.toByteArray(charset)
)
@@ -0,0 +1,82 @@
/*
* 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.core.data.utils
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import androidx.core.graphics.drawable.toBitmap
import coil3.Image
import java.io.ByteArrayOutputStream
private val possibleConfigs = mutableListOf<Bitmap.Config>().apply {
if (SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Bitmap.Config.RGBA_1010102)
}
if (SDK_INT >= Build.VERSION_CODES.O) {
add(Bitmap.Config.RGBA_F16)
}
add(Bitmap.Config.ARGB_8888)
add(Bitmap.Config.RGB_565)
}
fun getSuitableConfig(
image: Bitmap? = null
): Bitmap.Config = image?.config?.takeIf {
it in possibleConfigs
} ?: Bitmap.Config.ARGB_8888
fun Bitmap.toSoftware(): Bitmap = copy(getSuitableConfig(this), false) ?: this
val Bitmap.aspectRatio: Float get() = width / height.toFloat()
val Image.aspectRatio: Float get() = width / height.toFloat()
val Drawable.aspectRatio: Float get() = intrinsicWidth / intrinsicHeight.toFloat()
val Bitmap.safeAspectRatio: Float
get() = aspectRatio
.coerceAtLeast(0.005f)
.coerceAtMost(1000f)
val Bitmap.safeConfig: Bitmap.Config
get() = config ?: getSuitableConfig(this)
val Drawable.safeAspectRatio: Float
get() = aspectRatio
.coerceAtLeast(0.005f)
.coerceAtMost(1000f)
fun Drawable.toBitmap(): Bitmap = toBitmap(config = getSuitableConfig())
fun Bitmap.compress(
format: Bitmap.CompressFormat,
quality: Int
): ByteArray = ByteArrayOutputStream().apply {
use { out ->
compress(format, quality, out)
}
}.toByteArray()
val Bitmap.Config.isHardware: Boolean
get() = SDK_INT >= 26 && this == Bitmap.Config.HARDWARE
fun Bitmap.Config?.toSoftware(): Bitmap.Config {
return if (this == null || isHardware) Bitmap.Config.ARGB_8888 else this
}
@@ -0,0 +1,149 @@
/*
* 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.core.data.utils
import com.t8rin.imagetoolbox.core.data.saving.io.StreamReadable
import com.t8rin.imagetoolbox.core.domain.model.HashingType
import com.t8rin.imagetoolbox.core.domain.saving.io.Readable
import java.io.InputStream
import java.security.MessageDigest
private const val STREAM_BUFFER_LENGTH = 1024
fun HashingType.computeBytesFromReadable(
readable: Readable
): ByteArray = if (readable is StreamReadable) {
computeBytesFromInputStream(readable.stream)
} else {
computeBytesFromByteArray(readable.readBytes())
}
fun HashingType.computeFromReadable(
readable: Readable
): String = if (readable is StreamReadable) {
computeFromInputStream(readable.stream)
} else {
computeFromByteArray(readable.readBytes())
}
internal fun HashingType.computeFromByteArray(
byteArray: ByteArray
): String = computeFromInputStream(byteArray.inputStream())
internal fun HashingType.computeFromInputStream(
inputStream: InputStream
): String = inputStream.buffered().use {
val byteArray = updateDigest(it).digest()
val hexCode = encodeHex(byteArray, true)
return@use String(hexCode)
}
internal fun HashingType.computeBytesFromByteArray(
byteArray: ByteArray
): ByteArray = computeBytesFromInputStream(byteArray.inputStream())
internal fun HashingType.computeBytesFromInputStream(
inputStream: InputStream
): ByteArray = inputStream.buffered().use {
return@use updateDigest(it).digest()
}
/**
* Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order.
* The returned array will be double the length of the passed array, as it takes two characters to represent any
* given byte.
*
* @param data a byte[] to convert to Hex characters
* @param toLowerCase `true` converts to lowercase, `false` to uppercase
* @return A char[] containing hexadecimal characters in the selected case
*/
internal fun encodeHex(
data: ByteArray,
toLowerCase: Boolean
): CharArray = encodeHex(
data = data,
toDigits = if (toLowerCase) {
DIGITS_LOWER
} else {
DIGITS_UPPER
}
)
/**
* Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order.
* The returned array will be double the length of the passed array, as it takes two characters to represent any
* given byte.
*
* @param data a byte[] to convert to Hex characters
* @param toDigits the output alphabet (must contain at least 16 chars)
* @return A char[] containing the appropriate characters from the alphabet
* For best results, this should be either upper- or lower-case hex.
*/
internal fun encodeHex(
data: ByteArray,
toDigits: CharArray
): CharArray {
val l = data.size
val out = CharArray(l shl 1)
// two characters form the hex value.
var i = 0
var j = 0
while (i < l) {
out[j++] = toDigits[0xF0 and data[i].toInt() ushr 4]
out[j++] = toDigits[0x0F and data[i].toInt()]
i++
}
return out
}
/**
* Reads through an InputStream and updates the digest for the data
*
* @param HashingType The ChecksumType to use (e.g. MD5)
* @param data Data to digest
* @return the digest
*/
private fun HashingType.updateDigest(
data: InputStream
): MessageDigest {
val digest = toDigest()
val buffer = ByteArray(STREAM_BUFFER_LENGTH)
var read = data.read(buffer, 0, STREAM_BUFFER_LENGTH)
while (read > -1) {
digest.update(buffer, 0, read)
read = data.read(buffer, 0, STREAM_BUFFER_LENGTH)
}
return digest
}
/**
* Used to build output as Hex
*/
private val DIGITS_LOWER =
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
/**
* Used to build output as Hex
*/
private val DIGITS_UPPER =
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F')
private fun HashingType.toDigest(): MessageDigest = MessageDigest.getInstance(digest)
@@ -0,0 +1,62 @@
/*
* 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.core.data.utils
import android.graphics.Bitmap
import coil3.size.Size
import coil3.size.pxOrElse
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.transformation.Transformation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import coil3.transform.Transformation as CoilTransformation
fun Size.asDomain(): IntegerSize = if (this == Size.ORIGINAL) IntegerSize.Undefined
else IntegerSize(width.pxOrElse { 1 }, height.pxOrElse { 1 })
fun IntegerSize.asCoil(): Size = if (this == IntegerSize.Undefined) Size.ORIGINAL
else Size(width, height)
fun Transformation<Bitmap>.toCoil(): CoilTransformation = object : CoilTransformation() {
override fun toString(): String = this@toCoil::class.simpleName.toString()
override val cacheKey: String
get() = this@toCoil.cacheKey
override suspend fun transform(
input: Bitmap,
size: Size
): Bitmap = coroutineScope {
ensureActive()
this@toCoil.transform(input, size.asDomain())
}
}
fun CoilTransformation.asDomain(): Transformation<Bitmap> = object : Transformation<Bitmap> {
override val cacheKey: String
get() = this@asDomain.cacheKey
override suspend fun transform(
input: Bitmap,
size: IntegerSize
): Bitmap = coroutineScope {
ensureActive()
this@asDomain.transform(input, size.asCoil())
}
}
@@ -0,0 +1,117 @@
/*
* 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.core.data.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.compose.ui.unit.Density
import androidx.core.content.ContextCompat
import com.t8rin.imagetoolbox.core.domain.utils.FileMode
import kotlinx.coroutines.coroutineScope
import java.io.OutputStream
fun Context.isExternalStorageWritable(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) true
else ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
}
fun Context.openWriteableStream(
uri: Uri?,
onFailure: (Throwable) -> Unit = {}
): OutputStream? = uri?.let {
runCatching {
openOutputStream(
uri = uri,
mode = FileMode.ReadWrite
)
}.getOrElse {
runCatching {
openOutputStream(
uri = uri,
mode = FileMode.Write
)
}.onFailure(onFailure).getOrNull()
}
}
fun Context.openFileDescriptor(uri: Uri, mode: FileMode = FileMode.Read) =
contentResolver.openFileDescriptor(uri, mode.mode)
fun Context.openOutputStream(uri: Uri, mode: FileMode) =
contentResolver.openOutputStream(uri, mode.mode)
internal suspend fun Context.clearCache() = coroutineScope {
runCatching {
cacheDir?.deleteRecursively()
codeCacheDir?.deleteRecursively()
externalCacheDir?.deleteRecursively()
externalCacheDirs?.forEach {
it.deleteRecursively()
}
}
}
internal fun Context.cacheSize(): Long = runCatching {
val cache =
cacheDir?.walkTopDown()?.sumOf { if (it.isFile) it.length() else 0 } ?: 0
val code =
codeCacheDir?.walkTopDown()?.sumOf { if (it.isFile) it.length() else 0 } ?: 0
var size = cache + code
externalCacheDirs?.forEach { file ->
size += file?.walkTopDown()?.sumOf { if (it.isFile) it.length() else 0 } ?: 0
}
size
}.getOrNull() ?: 0
fun Context.isInstalledFromPlayStore(): Boolean = verifyInstallerId(
listOf(
"com.android.vending",
"com.google.android.feedback"
)
)
private fun Context.verifyInstallerId(
validInstallers: List<String>,
): Boolean = validInstallers.contains(getInstallerPackageName(packageName))
private fun Context.getInstallerPackageName(
packageName: String
): String? = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
packageManager.getInstallSourceInfo(packageName).installingPackageName
} else {
@Suppress("DEPRECATION")
packageManager.getInstallerPackageName(packageName)
}
}.getOrNull()
val Context.density: Density
get() = object : Density {
override val density: Float
get() = resources.displayMetrics.density
override val fontScale: Float
get() = resources.configuration.fontScale
}
@@ -0,0 +1,26 @@
/*
* 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.core.data.utils
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.ExecutorService
import kotlin.coroutines.CoroutineContext
inline fun executorDispatcher(
executor: () -> ExecutorService
): CoroutineContext = executor().asCoroutineDispatcher()
@@ -0,0 +1,50 @@
/*
* 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.core.data.utils
import android.os.Build
import android.os.FileObserver
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.io.File
fun File.observeHasChanges(
flags: Int = DEFAULT_FLAGS
): Flow<Unit> = callbackFlow {
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(this@observeHasChanges, flags) {
override fun onEvent(event: Int, path: String?) {
trySend(Unit)
}
}
} else {
@Suppress("DEPRECATION")
object : FileObserver(absolutePath, flags) {
override fun onEvent(event: Int, path: String?) {
trySend(Unit)
}
}
}
send(Unit)
observer.startWatching()
awaitClose { observer.stopWatching() }
}
private const val DEFAULT_FLAGS =
FileObserver.CREATE or FileObserver.DELETE or FileObserver.DELETE_SELF or FileObserver.DELETE_SELF or FileObserver.MODIFY or FileObserver.MOVE_SELF or FileObserver.MOVED_TO or FileObserver.MOVED_FROM
@@ -0,0 +1,46 @@
/*
* 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.core.data.utils
import com.t8rin.imagetoolbox.core.domain.utils.runSuspendCatching
import io.ktor.client.HttpClient
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.ByteReadChannel
import kotlinx.coroutines.supervisorScope
suspend fun HttpClient.getWithProgress(
url: String,
onFailure: suspend (Throwable) -> Unit = {},
onProgress: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit,
onOpen: suspend (ByteReadChannel) -> Unit
) = supervisorScope {
runSuspendCatching {
prepareGet(
urlString = url,
block = {
onDownload(onProgress)
}
).execute { response ->
onOpen(response.bodyAsChannel())
}
}.onFailure {
onFailure(it)
}
}
@@ -0,0 +1,30 @@
/*
* 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.core.data.utils
import com.t8rin.imagetoolbox.core.data.saving.io.StreamWriteable
import com.t8rin.imagetoolbox.core.domain.saving.io.Writeable
import java.io.OutputStream
fun Writeable.outputStream(): OutputStream = if (this is StreamWriteable) {
stream
} else {
object : OutputStream() {
override fun write(b: Int) = writeBytes(byteArrayOf(b.toByte()))
}
}