chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
+211
@@ -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")
|
||||
+174
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
+135
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+189
@@ -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()
|
||||
+321
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
+155
@@ -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")
|
||||
+99
@@ -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()
|
||||
}
|
||||
}
|
||||
+106
@@ -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))
|
||||
+70
@@ -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
|
||||
}
|
||||
+34
@@ -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)
|
||||
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
+109
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -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()
|
||||
|
||||
}
|
||||
+46
@@ -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()
|
||||
|
||||
}
|
||||
+35
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
+59
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
+37
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
+42
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
+45
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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()
|
||||
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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
|
||||
}
|
||||
+50
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+176
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
+193
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -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)
|
||||
|
||||
}
|
||||
+75
@@ -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)
|
||||
}
|
||||
+621
@@ -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
|
||||
+331
@@ -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) ?: ""
|
||||
|
||||
}
|
||||
+71
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
+221
@@ -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"
|
||||
}
|
||||
}
|
||||
+521
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user