chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+34
View File
@@ -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>.
*/
plugins {
alias(libs.plugins.image.toolbox.library)
alias(libs.plugins.image.toolbox.compose)
alias(libs.plugins.image.toolbox.hilt)
}
android.namespace = "com.t8rin.imagetoolbox.core.utils"
dependencies {
implementation(projects.core.domain)
implementation(projects.core.resources)
implementation(projects.core.settings)
implementation(libs.androidx.documentfile)
"marketImplementation"(libs.quickie.bundled)
"fossImplementation"(libs.quickie.foss)
implementation(libs.zxing.core)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,67 @@
/*
* 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.utils
import android.content.Context
import android.content.ContextWrapper
import androidx.annotation.StringRes
import com.t8rin.imagetoolbox.core.resources.R
class AppContext private constructor(
application: Context
) : ContextWrapper(application) {
companion object {
internal var appContext: AppContext? = null
internal fun init(application: Context) {
if (appContext == null) {
appContext = AppContext(application)
} else {
"AppContext is already initialized".makeLog("AppContext")
}
}
}
}
fun Context.initAppContext() = AppContext.init(applicationContext)
val appContext: AppContext
get() = checkNotNull(AppContext.appContext) {
"AppContext not initialized"
}
fun getString(@StringRes resId: Int): String = appContext.getString(resId)
fun getString(
@StringRes resId: Int,
vararg formatArgs: Any?
): String = appContext.getString(resId, *formatArgs)
fun Throwable.extractMessage(): String = if (this is OutOfMemoryError) {
getString(R.string.oom_description)
} else {
getString(
R.string.smth_went_wrong,
(localizedMessage?.takeIf { it.isNotBlank() } ?: message)?.decodeEscaped().orEmpty()
.ifEmpty {
this::class.java.simpleName
}
)
}
@@ -0,0 +1,32 @@
/*
* 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.utils
data class LogLineReference(
val number: Int,
val start: Long,
val end: Long
) {
val key: String = "$number:$start:$end"
}
data class LogLinesSnapshot(
val lines: List<LogLineReference> = emptyList(),
val endOffset: Long = 0L,
val lastLineNumber: Int = 0
)
@@ -0,0 +1,203 @@
/*
* 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.utils
import android.app.Application
import android.net.Uri
import androidx.core.net.toUri
import com.t8rin.imagetoolbox.core.utils.Logger.Level
import com.t8rin.imagetoolbox.core.utils.Logger.reportError
import com.t8rin.imagetoolbox.core.utils.LogsWriter.Companion.MAX_SIZE
import com.t8rin.imagetoolbox.core.utils.LogsWriter.Companion.STARTUP_LOG
import android.util.Log as RealLog
/**
[Logger] is a wrapper around [android.util.Log]
**/
data object Logger {
data object Flags {
var useCallerClassAsTag = false
}
internal var logWriter: LogsWriter? = null
inline fun <reified T> makeLog(
tag: String = createTag<T>(),
level: Level = Level.Debug,
dataBlock: () -> T
) {
val data = dataBlock()
val message = if (data is Throwable) {
reportError(data)
RealLog.getStackTraceString(data)
} else {
data.toString()
}
makeLog(
Log(
tag = tag,
message = message,
level = if (data is Throwable) Level.Error else level
)
)
}
fun reportError(throwable: Throwable) {
logWriter?.errorHandler(throwable)
}
fun makeLog(log: Log) {
val (tag, message, level) = log
when (level) {
is Level.Assert -> RealLog.println(level.priority, tag, message)
Level.Debug -> RealLog.d(tag, message)
Level.Error -> RealLog.e(tag, message)
Level.Info -> RealLog.i(tag, message)
Level.Verbose -> RealLog.v(tag, message)
Level.Warn -> RealLog.w(tag, message)
}
logWriter?.writeLog(log)
}
fun shareLogs() = logWriter?.shareLogs() ?: throw LogsWriterNotInitialized()
fun shareLogsViaEmail(
email: String
) = logWriter?.shareLogsViaEmail(email) ?: throw LogsWriterNotInitialized()
fun getLogsFile(): Uri = logWriter?.logsFile?.toUri() ?: throw LogsWriterNotInitialized()
fun readLogs(maxBytes: Int = LogsWriter.PREVIEW_SIZE): String =
logWriter?.readLogs(maxBytes).orEmpty()
fun readLogLineReferences(
startOffset: Long = 0L,
startLineNumber: Int = 0,
query: String = ""
): LogLinesSnapshot = logWriter?.readLogLineReferences(
startOffset = startOffset,
startLineNumber = startLineNumber,
query = query
) ?: LogLinesSnapshot()
fun readLogLine(line: LogLineReference): String = logWriter?.readLogLine(line).orEmpty()
fun logsFileLength(): Long = logWriter?.logsFile?.takeIf { it.exists() }?.length() ?: 0L
sealed interface Level {
data class Assert(
val priority: Int
) : Level {
override fun toString(): String = "Assert"
}
data object Error : Level
data object Warn : Level
data object Info : Level
data object Debug : Level
data object Verbose : Level
}
data class Log(
val tag: String,
val message: String,
val level: Level
)
}
inline fun <reified T> makeLog(
data: T,
tag: String = createTag<T>(),
level: Level = Level.Debug,
) = Logger.makeLog(
tag = tag,
level = level,
dataBlock = { data }
)
fun makeLog(
level: Level = Level.Debug,
separator: String = " - ",
vararg data: Any
) = Logger.makeLog(
level = level,
dataBlock = {
data.toList().joinToString(separator)
}
)
inline fun <reified T> T.makeLog(
tag: String = createTag<T>(),
level: Level = Level.Debug,
dataBlock: (T) -> Any? = { it }
): T = also {
if (it is Throwable) {
RealLog.e(tag, it.localizedMessage, it)
reportError(it)
Logger.makeLog(
tag = tag,
level = Level.Error,
dataBlock = { dataBlock(it) }
)
} else {
Logger.makeLog(
tag = tag,
level = level,
dataBlock = { dataBlock(it) }
)
}
}
inline infix fun <reified T> T.makeLog(
tag: String
): T = makeLog(tag) { this }
inline fun <reified T> createTag(): String = if (Logger.Flags.useCallerClassAsTag) {
Throwable().stackTrace[1].className
} else {
"Logger" + (T::class.simpleName?.let { "_$it" } ?: "")
}
fun Logger.attachLogWriter(
context: Application,
fileProvider: String,
logsFilename: String,
isSyncCreate: Boolean = false,
startupLog: Logger.Log = STARTUP_LOG,
logMapper: LogMapper = LogMapper.Default,
errorHandler: (Throwable) -> Unit = {},
maxFileSize: Int? = MAX_SIZE
) {
logWriter = LogsWriter(
context = context,
fileProvider = fileProvider,
logsFilename = logsFilename,
maxFileSize = maxFileSize,
isSyncCreate = isSyncCreate,
startupLog = startupLog,
errorHandler = errorHandler,
logMapper = logMapper
)
}
@@ -0,0 +1,298 @@
/*
* 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.utils
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import com.t8rin.imagetoolbox.core.domain.utils.timestamp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.RandomAccessFile
internal class LogsWriter(
private val context: Application,
private val fileProvider: String,
private val logsFilename: String,
isSyncCreate: Boolean,
startupLog: Logger.Log = STARTUP_LOG,
internal val errorHandler: (Throwable) -> Unit = {},
private val logMapper: LogMapper = LogMapper.Default,
private val maxFileSize: Int? = MAX_SIZE,
) {
internal var logsFile: File? = null
init {
val create = suspend {
if (logsFile != null) throw IllegalStateException("LogWriter must be initialized only once")
logsFile = File(context.filesDir, logsFilename).apply {
if (maxFileSize != null && length() > maxFileSize) {
var lineCount = 0
val lines = mutableListOf<String>()
withContext(Dispatchers.IO) {
coroutineScope {
bufferedReader().use { reader ->
while (reader.readLine() != null) {
lineCount++
}
}
}
coroutineScope {
bufferedReader().use { reader ->
var tempLineCount = 0
repeat(lineCount) {
tempLineCount++
val line = reader.readLine()
if (tempLineCount >= lineCount - 1000 && line != null) {
lines.add(line)
}
}
}
delete()
createNewFile()
writeData(this@apply) { writer ->
lines.forEach {
writer.write(it)
writer.newLine()
}
}
}
}
}
if (!exists()) createNewFile()
}
writeData { writer ->
writer.write(
logMapper.asMessage(startupLog)
)
writer.newLine()
}
}
if (isSyncCreate) {
runBlocking { create() }
} else {
CoroutineScope(Dispatchers.Main).launch {
create()
}
}
}
private fun File.getUri(): Uri = FileProvider.getUriForFile(context, fileProvider, this)
private fun writeData(
file: File? = logsFile,
use: (BufferedWriter) -> Unit
) {
runCatching {
FileOutputStream(file, true)
.bufferedWriter()
.use(use)
}
}
fun shareLogs() {
val sendIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(logsFile!!.getUri()))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
type = "text/plain"
}
val shareIntent =
Intent.createChooser(sendIntent, "Share Logs")
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(shareIntent)
}
fun shareLogsViaEmail(email: String) {
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "vnd.android.cursor.dir/email"
putExtra(Intent.EXTRA_EMAIL, arrayOf(email))
putExtra(Intent.EXTRA_STREAM, logsFile!!.getUri())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(sendIntent)
}
fun writeLog(log: Logger.Log) {
writeData { writer ->
writer.write(
logMapper.asMessage(log)
)
writer.newLine()
}
}
fun readLogs(maxBytes: Int = PREVIEW_SIZE): String {
val file = logsFile?.takeIf { it.exists() } ?: return ""
if (maxBytes <= 0 || file.length() <= maxBytes) return file.readText()
val bytes = ByteArray(maxBytes)
RandomAccessFile(file, "r").use { randomAccessFile ->
randomAccessFile.seek(file.length() - maxBytes)
randomAccessFile.readFully(bytes)
}
return bytes.toString(Charsets.UTF_8)
}
fun readLogLineReferences(
startOffset: Long = 0L,
startLineNumber: Int = 0,
query: String = ""
): LogLinesSnapshot {
val file = logsFile?.takeIf { it.exists() } ?: return LogLinesSnapshot()
val normalizedStartOffset = startOffset.coerceIn(0L, file.length())
val searchQuery = query.takeIf(String::isNotEmpty)
val lines = mutableListOf<LogLineReference>()
val lineBytes = searchQuery?.let { ByteArrayOutputStream() }
var position = normalizedStartOffset
var lineStart = normalizedStartOffset
var lineNumber = startLineNumber
var previousWasCarriageReturn = false
fun addLine(lineEnd: Long) {
lineNumber++
val end = lineEnd.coerceAtLeast(lineStart)
val matches = searchQuery?.let { query ->
lineBytes
?.toByteArray()
?.toString(Charsets.UTF_8)
?.contains(query, ignoreCase = true) == true
} ?: true
if (matches) {
lines.add(
LogLineReference(
number = lineNumber,
start = lineStart,
end = end
)
)
}
lineBytes?.reset()
}
file.inputStream().buffered().use { inputStream ->
inputStream.skipFully(normalizedStartOffset)
while (true) {
val value = inputStream.read()
if (value == -1) break
if (value == NEW_LINE) {
addLine(
lineEnd = if (previousWasCarriageReturn) position - 1 else position
)
lineStart = position + 1
previousWasCarriageReturn = false
} else {
lineBytes?.write(value)
previousWasCarriageReturn = value == CARRIAGE_RETURN
}
position++
}
}
if (position > lineStart) {
addLine(
lineEnd = if (previousWasCarriageReturn) position - 1 else position
)
}
return LogLinesSnapshot(
lines = lines,
endOffset = position,
lastLineNumber = lineNumber
)
}
fun readLogLine(line: LogLineReference): String {
val file = logsFile?.takeIf { it.exists() } ?: return ""
val availableBytes = (file.length() - line.start).coerceAtLeast(0L)
val bytesCount = (line.end - line.start)
.coerceAtLeast(0L)
.coerceAtMost(availableBytes)
.toInt()
if (bytesCount == 0) return ""
val bytes = ByteArray(bytesCount)
RandomAccessFile(file, "r").use { randomAccessFile ->
randomAccessFile.seek(line.start)
randomAccessFile.readFully(bytes)
}
return bytes.toString(Charsets.UTF_8)
}
companion object {
internal val STARTUP_LOG = Logger.Log(
tag = "Logger_Launch",
message = "* Application Launched *",
level = Logger.Level.Info
)
internal const val MAX_SIZE = 40 * 1024 * 1024 * 8
internal const val PREVIEW_SIZE = 512 * 1024
private const val NEW_LINE = '\n'.code
private const val CARRIAGE_RETURN = '\r'.code
}
}
private fun InputStream.skipFully(bytesToSkip: Long) {
var remaining = bytesToSkip
while (remaining > 0) {
val skipped = skip(remaining)
if (skipped > 0) {
remaining -= skipped
} else if (read() == -1) {
return
} else {
remaining--
}
}
}
internal class LogsWriterNotInitialized : Throwable()
fun interface LogMapper {
fun asMessage(log: Logger.Log): String
companion object {
val Default by lazy {
LogMapper { (tag, message, level) ->
"${timestamp("dd-MM-yyyy HH:mm:ss")} ($tag) [$level]: $message"
}
}
}
}
@@ -0,0 +1,168 @@
/*
* 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.utils
import com.t8rin.imagetoolbox.core.domain.model.QrType
import com.t8rin.imagetoolbox.core.domain.model.QrType.Wifi.EncryptionType
import io.github.g00fy2.quickie.content.QRContent
import io.github.g00fy2.quickie.content.QRContent.CalendarEvent.CalendarDateTime
import io.github.g00fy2.quickie.content.QRContent.ContactInfo.Address.AddressType
import io.github.g00fy2.quickie.content.QRContent.Email.EmailType
import io.github.g00fy2.quickie.content.QRContent.Phone.PhoneType
import io.github.g00fy2.quickie.extensions.DataType
import java.util.Calendar
import java.util.Date
fun QRContent.toQrType(): QrType {
val raw = rawValue ?: rawBytes?.toString(Charsets.UTF_8).orEmpty()
if (raw.startsWith("geo:", true)) {
val data = raw.drop(4).split(";")
return QrType.Geo(
raw = raw,
latitude = data.getOrNull(0)?.toDoubleOrNull(),
longitude = data.getOrNull(1)?.toDoubleOrNull()
)
}
return when (this) {
is QRContent.Plain -> QrType.Plain(raw)
is QRContent.Wifi -> QrType.Wifi(
raw = raw,
ssid = ssid,
password = password,
encryptionType = EncryptionType.entries.getOrNull(encryptionType - 1)
?: EncryptionType.OPEN
)
is QRContent.Url -> QrType.Url(
raw = raw,
title = title,
url = url
)
is QRContent.Sms -> QrType.Sms(
raw = raw,
message = message,
phoneNumber = phoneNumber
)
is QRContent.GeoPoint -> QrType.Geo(
raw = raw,
latitude = lat,
longitude = lng
)
is QRContent.Email -> QrType.Email(
raw = raw,
address = address,
body = body,
subject = subject,
type = type.toData()
)
is QRContent.Phone -> QrType.Phone(
raw = raw,
number = number,
type = type.toData()
)
is QRContent.ContactInfo -> QrType.Contact(
raw = raw,
addresses = addresses.map {
QrType.Contact.Address(
addressLines = it.addressLines,
type = it.type.toData()
)
},
emails = emails.map {
QrType.Email(
raw = raw,
address = it.address,
body = it.body,
subject = it.subject,
type = it.type.toData()
)
},
name = QrType.Contact.PersonName(
first = name.first,
formattedName = name.formattedName,
last = name.last,
middle = name.middle,
prefix = name.prefix,
pronunciation = name.pronunciation,
suffix = name.suffix
),
organization = organization,
phones = phones.map {
QrType.Phone(
raw = raw,
number = it.number,
type = it.type.toData()
)
},
title = title,
urls = urls
)
is QRContent.CalendarEvent -> QrType.Calendar(
raw = raw,
description = description,
end = end.toDate(),
location = location,
organizer = organizer,
start = start.toDate(),
status = status,
summary = summary
)
}
}
private fun PhoneType.toData(): Int = when (this) {
PhoneType.WORK -> DataType.TYPE_WORK
PhoneType.HOME -> DataType.TYPE_HOME
PhoneType.FAX -> DataType.TYPE_FAX
PhoneType.MOBILE -> DataType.TYPE_MOBILE
else -> DataType.TYPE_UNKNOWN
}
private fun AddressType.toData(): Int = when (this) {
AddressType.WORK -> DataType.TYPE_WORK
AddressType.HOME -> DataType.TYPE_HOME
else -> DataType.TYPE_UNKNOWN
}
private fun EmailType.toData(): Int = when (this) {
EmailType.WORK -> DataType.TYPE_WORK
EmailType.HOME -> DataType.TYPE_HOME
else -> DataType.TYPE_UNKNOWN
}
private fun CalendarDateTime.toDate(): Date {
val cal = Calendar.getInstance().apply {
set(Calendar.YEAR, year)
set(Calendar.MONTH, month - 1)
set(Calendar.DAY_OF_MONTH, day)
set(Calendar.HOUR_OF_DAY, hours)
set(Calendar.MINUTE, minutes)
set(Calendar.SECOND, seconds)
set(Calendar.MILLISECOND, 0)
}
return cal.time
}
@@ -0,0 +1,31 @@
/*
* 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.utils
import android.graphics.Typeface
import androidx.core.content.res.ResourcesCompat
import com.t8rin.imagetoolbox.core.settings.domain.model.FontType
fun FontType?.toTypeface(): Typeface? = when (this) {
null -> null
is FontType.File -> Typeface.createFromFile(this.path)
is FontType.Resource -> ResourcesCompat.getFont(
appContext,
this.resId
)
}
@@ -0,0 +1,100 @@
/*
* 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.utils
import com.t8rin.imagetoolbox.core.domain.utils.cast
import com.t8rin.imagetoolbox.core.resources.BuildConfig
import org.w3c.dom.Element
import java.io.InputStream
import javax.xml.parsers.DocumentBuilderFactory
fun isNeedUpdate(
updateName: String,
allowBetas: Boolean
): Boolean {
val currentName = BuildConfig.VERSION_NAME
val betaList = listOf(
"alpha", "beta", "rc"
)
val currentVersionCodeString = currentName.toVersionCodeString(betaList)
val updateVersionCodeString = updateName.toVersionCodeString(betaList)
val maxLength = maxOf(currentVersionCodeString.length, updateVersionCodeString.length)
val currentVersionCode = currentVersionCodeString.padEnd(maxLength, '0').toIntOrNull() ?: -1
val updateVersionCode = updateVersionCodeString.padEnd(maxLength, '0').toIntOrNull() ?: -1
return if (!updateName.startsWith(currentName)) {
if (betaList.all { it !in updateName }) {
updateVersionCode > currentVersionCode
} else {
if (allowBetas || betaList.any { it in currentName }) {
updateVersionCode > currentVersionCode
} else false
}
} else false
}
fun InputStream.parseChangelog(): Changelog {
var tag = ""
var changelog = ""
val tree = DocumentBuilderFactory.newInstance()
.newDocumentBuilder().parse(this)
.getElementsByTagName("feed")
repeat(tree.length) {
val line = tree.item(it).cast<Element>()
.getElementsByTagName("entry").item(0)
.cast<Element>()
tag = line.getElementsByTagName("title").item(0).textContent
changelog = line.getElementsByTagName("content").item(0).textContent
}
return Changelog(
tag = tag,
changelog = changelog
)
}
data class Changelog(
val tag: String,
val changelog: String
)
private fun String.toVersionCodeString(betaList: List<String>): String {
return replace(
regex = Regex("0\\d"),
transform = {
it.value.replace("0", "")
}
).replace("-", "")
.replace(".", "")
.replace("_", "")
.let { version ->
if (betaList.any { it in version }) version
else version + "4"
}
.replace("alpha", "1")
.replace("beta", "2")
.replace("rc", "3")
.replace("foss", "")
.replace("jxl", "")
}
@@ -0,0 +1,590 @@
/*
* 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.utils
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.graphics.BitmapFactory
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 android.webkit.MimeTypeMap
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.t8rin.imagetoolbox.core.domain.model.FileModel
import com.t8rin.imagetoolbox.core.domain.model.ImageModel
import com.t8rin.imagetoolbox.core.domain.model.IntegerSize
import com.t8rin.imagetoolbox.core.domain.model.SortType
import com.t8rin.imagetoolbox.core.domain.utils.ListUtils.sortedByKey
import com.t8rin.imagetoolbox.core.resources.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.LinkedList
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
fun Uri?.uiPath(
default: String,
context: Context = appContext
): String = this?.let { uri ->
runCatching {
if (DocumentFile.isDocumentUri(context, uri)) {
DocumentFile.fromSingleUri(context, uri)
} else {
DocumentFile.fromTreeUri(context, uri)
}?.uri?.path?.split(":")
?.lastOrNull()?.let { p ->
val endPath = p.takeIf {
it.isNotEmpty()
}?.let { "/$it" } ?: ""
val startPath = if (
uri.toString()
.split("%")[0]
.contains("primary")
) context.getString(R.string.device_storage)
else context.getString(R.string.external_storage)
startPath + endPath
}?.decodeEscaped()
}.getOrNull()
} ?: default
fun Uri.lastModified(): Long? = tryExtractOriginal().run {
runCatching {
if (this.toString().startsWith("file:///")) {
return toFile().lastModified()
}
with(appContext.contentResolver) {
val query = query(this@lastModified, null, null, null, null)
query?.use { cursor ->
if (cursor.moveToFirst()) {
val columnNames = listOf(
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
"datetaken", // When sharing an Image from Google Photos into the app.
)
val millis = columnNames.firstNotNullOfOrNull {
val index = cursor.getColumnIndex(it)
if (!cursor.isNull(index)) {
cursor.getLong(index)
} else {
null
}
}
return millis
}
}
}
return DocumentFile.fromSingleUri(appContext, this)?.lastModified()
}.onFailure { it.printStackTrace() }
return null
}
fun Uri.path(): String? = tryExtractOriginal().run {
getStringColumn(MediaStore.MediaColumns.DATA)?.takeIf {
".transforms" !in it
}.orEmpty().ifEmpty {
runCatching {
val path = DocumentFile.fromSingleUri(appContext, this)?.uri?.path?.split(":")
?.lastOrNull() ?: return@run null
if ("cloud" in path) {
"/cloud/${path.substringAfterLast('/')}"
} else {
path
}
}.getOrNull()
}
}
fun Uri.dateAdded(): Long? = tryExtractOriginal().run {
getLongColumn(MediaStore.MediaColumns.DATE_ADDED)?.times(1000)
}
fun Uri.filename(
context: Context = appContext
): String? = tryExtractOriginal().run {
if (this.toString().startsWith("file:///")) {
this.toString().takeLastWhile { it != '/' }
} else {
DocumentFile.fromSingleUri(context, this)?.name
}?.decodeEscaped()
}
fun Uri.extension(
context: Context = appContext
): String? {
val filename = filename(context) ?: ""
if (filename.endsWith(".qoi")) return "qoi"
if (filename.endsWith(".jxl")) return "jxl"
return if (ContentResolver.SCHEME_CONTENT == scheme) {
MimeTypeMap.getSingleton()
.getExtensionFromMimeType(
context.contentResolver.getType(this)
)
} else {
MimeTypeMap.getFileExtensionFromUrl(this.toString()).lowercase(Locale.getDefault())
}?.replace(".", "")
}
fun Uri.fileSize(): Long? = tryExtractOriginal().run {
if (this.scheme == "content") {
runCatching {
appContext.contentResolver
.query(this, null, null, null, null, null)
.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
val sizeIndex: Int = cursor.getColumnIndex(OpenableColumns.SIZE)
if (!cursor.isNull(sizeIndex)) {
return cursor.getLong(sizeIndex)
}
}
}
}
} else {
runCatching {
return this.toFile().length()
}
}
return null
}
fun Uri.imageSize(): IntegerSize? = tryExtractOriginal().run {
val mediaStoreSize = if (scheme == ContentResolver.SCHEME_CONTENT) {
runCatching {
appContext.contentResolver.query(
this,
arrayOf(MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.HEIGHT),
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val widthIndex = cursor.getColumnIndex(MediaStore.MediaColumns.WIDTH)
val heightIndex = cursor.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
val width = widthIndex
.takeIf { it != -1 && !cursor.isNull(it) }
?.let(cursor::getInt)
val height = heightIndex
.takeIf { it != -1 && !cursor.isNull(it) }
?.let(cursor::getInt)
IntegerSize(width ?: 0, height ?: 0).takeIf { it.isValidImageSize() }
} else null
}
}.getOrNull()
} else null
mediaStoreSize ?: runCatching {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
if (scheme == ContentResolver.SCHEME_CONTENT) {
appContext.contentResolver.openInputStream(this)?.use {
BitmapFactory.decodeStream(it, null, options)
}
} else {
BitmapFactory.decodeFile(toFile().absolutePath, options)
}
IntegerSize(options.outWidth, options.outHeight).takeIf { it.isValidImageSize() }
}.getOrNull()
}
private fun IntegerSize.isValidImageSize(): Boolean = width > 0 && height > 0
fun Uri.tryExtractOriginal(): Uri = UriReplacements.resolve(this).run {
try {
if ("com.android.externalstorage.documents" in this.toString()) {
return this.makeLog("tryExtractOriginal") { "already ok - $it" }
}
val mimeType = getStringColumn(MediaStore.MediaColumns.MIME_TYPE).orEmpty()
val contentUri = when {
"image" in mimeType -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
"video" in mimeType -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
"audio" in mimeType -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> return this
}
UriReplacements.resolve(
ContentUris.withAppendedId(
contentUri,
this.toString().decodeEscaped().substringAfterLast('/').filter { it.isDigit() }
.toLong()
)
)
} catch (e: Throwable) {
e.makeLog("tryExtractOriginal")
this.makeLog("tryExtractOriginal") { "failed - $it" }
}
}
suspend fun List<Uri>.distinctUris(): List<Uri> = withContext(Dispatchers.IO) {
val identities = HashSet<UriIdentity>(size)
return@withContext filter { uri ->
identities.add(uri.uriIdentity())
}
}
private fun Uri.uriIdentity(): UriIdentity {
val normalizedUri = tryExtractOriginal()
normalizedUri.fileDescriptorIdentity()?.let {
return it
}
if (normalizedUri.scheme == ContentResolver.SCHEME_FILE) {
normalizedUri.path?.let(::File)?.let { file ->
return UriIdentity.FilePath(
runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
)
}
}
return UriIdentity.NormalizedUri(normalizedUri.normalizeScheme().toString())
}
private fun Uri.fileDescriptorIdentity(): UriIdentity.FileDescriptor? = runCatching {
openFileDescriptor()?.use { descriptor ->
Os.fstat(descriptor.fileDescriptor).let { stat ->
UriIdentity.FileDescriptor(
device = stat.st_dev,
inode = stat.st_ino
).takeIf { stat.st_ino != 0L }
}
}
}.getOrNull()
private fun Uri.openFileDescriptor(): ParcelFileDescriptor? = when (scheme) {
ContentResolver.SCHEME_FILE -> path?.let(::File)?.let { file ->
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
ContentResolver.SCHEME_CONTENT -> appContext.contentResolver.openFileDescriptor(this, "r")
else -> null
}
private sealed interface UriIdentity {
data class FileDescriptor(
val device: Long,
val inode: Long
) : UriIdentity
data class FilePath(val path: String) : UriIdentity
data class NormalizedUri(val uri: String) : UriIdentity
}
suspend fun List<Uri>.sortedByType(
sortType: SortType,
): List<Uri> = coroutineScope {
when (sortType) {
SortType.DateModified -> sortedByDateModified()
SortType.DateModifiedReversed -> sortedByDateModified(descending = true)
SortType.Name -> sortedByName()
SortType.NameReversed -> sortedByName(descending = true)
SortType.Size -> sortedBySize()
SortType.SizeReversed -> sortedBySize(descending = true)
SortType.MimeType -> sortedByMimeType()
SortType.MimeTypeReversed -> sortedByMimeType(descending = true)
SortType.Extension -> sortedByExtension()
SortType.ExtensionReversed -> sortedByExtension(descending = true)
SortType.DateAdded -> sortedByDateAdded()
SortType.DateAddedReversed -> sortedByDateAdded(descending = true)
SortType.Reverse -> reversed()
SortType.Shuffle -> shuffled()
}
}
fun ImageModel.toUri(): Uri? = when (data) {
is Uri -> data as Uri
is String -> (data as String).toUri()
else -> null
}
fun Any.toImageModel() = ImageModel(this)
fun String.toFileModel() = FileModel(this)
fun String.decodeEscaped(): String = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
URLDecoder.decode(URLDecoder.decode(this, Charsets.UTF_8), Charsets.UTF_8)
} else {
@Suppress("DEPRECATION")
URLDecoder.decode(URLDecoder.decode(this))
}
}.getOrDefault(this)
fun String.encodeEscaped(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
URLEncoder.encode(this, Charsets.UTF_8)
} else {
@Suppress("DEPRECATION")
URLEncoder.encode(this)
}
}
fun Uri.isApng(): Boolean {
return filename().toString().endsWith(".png")
.or(appContext.contentResolver.getType(this)?.contains("png") == true)
.or(appContext.contentResolver.getType(this)?.contains("apng") == true)
}
fun Uri.isWebp(): Boolean {
return filename().toString().endsWith(".webp")
.or(appContext.contentResolver.getType(this)?.contains("webp") == true)
}
fun Uri.isJxl(): Boolean {
return filename().toString().endsWith(".jxl")
.or(appContext.contentResolver.getType(this)?.contains("jxl") == true)
}
fun Uri.isGif(): Boolean {
return filename().toString().endsWith(".gif")
.or(appContext.contentResolver.getType(this)?.contains("gif") == true)
}
suspend fun Uri.listFilesInDirectory(): List<Uri> =
listFilesInDirectoryAsFlowImpl().filterIsInstance<DirUri.All>().first().uris
fun Uri.listFilesInDirectoryProgressive(): Flow<Uri> = listFilesInDirectoryAsFlowImpl()
.filterIsInstance<DirUri.Entry>()
.map { it.uri }
fun String?.getPath(
context: Context = appContext
) = this?.takeIf { it.isNotEmpty() }?.toUri().uiPath(
default = context.getString(R.string.default_folder)
)
private fun Uri.listFilesInDirectoryAsFlowImpl(): Flow<DirUri> = channelFlow {
val rootUri = this@listFilesInDirectoryAsFlowImpl
var childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
rootUri,
DocumentsContract.getTreeDocumentId(rootUri)
)
val files: MutableList<Pair<Uri, Long>> = LinkedList()
val dirNodes: MutableList<Uri> = LinkedList()
dirNodes.add(childrenUri)
while (dirNodes.isNotEmpty()) {
childrenUri = dirNodes.removeAt(0)
appContext.contentResolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_MIME_TYPE,
),
null,
null,
null
).use {
while (it!!.moveToNext()) {
val docId = it.getString(0)
val lastModified = it.getLong(1)
val mime = it.getString(2)
if (isDirectory(mime)) {
val newNode = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, docId)
dirNodes.add(newNode)
} else {
val uri = DocumentsContract.buildDocumentUriUsingTree(rootUri, docId)
channel.send(DirUri.Entry(uri))
files.add(
uri to lastModified
)
}
}
}
}
files.sortedByDescending { it.second }.map { it.first }.also {
channel.send(DirUri.All(it))
channel.close()
}
}.flowOn(Dispatchers.IO)
private sealed interface DirUri {
data class Entry(val uri: Uri) : DirUri
data class All(val uris: List<Uri>) : DirUri
}
private fun isDirectory(mimeType: String): Boolean {
return DocumentsContract.Document.MIME_TYPE_DIR == mimeType
}
private fun List<Uri>.sortedByExtension(
descending: Boolean = false
) = sortedByKey(descending) {
it.filename()?.substringAfterLast(
delimiter = '.',
missingDelimiterValue = ""
)?.lowercase()
}
private fun List<Uri>.sortedByDateModified(
descending: Boolean = false
) = sortedByKey(descending) { it.lastModified() }
private fun List<Uri>.sortedByName(
descending: Boolean = false
): List<Uri> {
val comparator = Comparator<Pair<Uri, String?>> { first, second ->
first.second.orEmpty().naturalCompareTo(second.second.orEmpty())
}.let { if (descending) it.reversed() else it }
return map { it to it.filename() }
.sortedWith(comparator)
.map { it.first }
}
private fun String.naturalCompareTo(other: String): Int {
var firstIndex = 0
var secondIndex = 0
while (firstIndex < length && secondIndex < other.length) {
val firstChar = this[firstIndex]
val secondChar = other[secondIndex]
if (firstChar.isDigit() && secondChar.isDigit()) {
val firstEnd = indexOfFirstNonDigit(firstIndex)
val secondEnd = other.indexOfFirstNonDigit(secondIndex)
val firstNumber = substring(firstIndex, firstEnd)
val secondNumber = other.substring(secondIndex, secondEnd)
val firstSignificant = firstNumber.trimStart('0').ifEmpty { "0" }
val secondSignificant = secondNumber.trimStart('0').ifEmpty { "0" }
val lengthComparison = firstSignificant.length.compareTo(secondSignificant.length)
if (lengthComparison != 0) return lengthComparison
val numberComparison = firstSignificant.compareTo(secondSignificant)
if (numberComparison != 0) return numberComparison
val leadingZeroComparison = firstNumber.length.compareTo(secondNumber.length)
if (leadingZeroComparison != 0) return leadingZeroComparison
firstIndex = firstEnd
secondIndex = secondEnd
} else {
val characterComparison =
firstChar.lowercaseChar().compareTo(secondChar.lowercaseChar())
if (characterComparison != 0) return characterComparison
firstIndex++
secondIndex++
}
}
return length.compareTo(other.length)
}
private fun String.indexOfFirstNonDigit(startIndex: Int): Int {
var index = startIndex
while (index < length && this[index].isDigit()) index++
return index
}
private fun List<Uri>.sortedBySize(
descending: Boolean = false
) = sortedByKey(descending) {
it.getLongColumn(OpenableColumns.SIZE)
}
private fun List<Uri>.sortedByMimeType(
descending: Boolean = false
) = sortedByKey(descending) {
it.getStringColumn(
column = DocumentsContract.Document.COLUMN_MIME_TYPE
)
}
private fun List<Uri>.sortedByDateAdded(
descending: Boolean = false
) = sortedByKey(descending) {
it.dateAdded()
}
private fun Uri.getLongColumn(column: String): Long? =
appContext.contentResolver.query(this, arrayOf(column), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val index = cursor.getColumnIndex(column)
if (index != -1 && !cursor.isNull(index)) cursor.getLong(index) else null
} else null
}
private fun Uri.getStringColumn(column: String): String? =
appContext.contentResolver.query(this, arrayOf(column), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val index = cursor.getColumnIndex(column)
if (index != -1 && !cursor.isNull(index)) cursor.getString(index) else null
} else null
}
object UriReplacements {
private val replacements = ConcurrentHashMap<String, String>()
fun register(
originalUri: Uri,
replacementUri: Uri
) {
if (originalUri == Uri.EMPTY || originalUri == replacementUri) return
replacements[originalUri.toString()] = resolve(replacementUri.toString())
}
fun resolve(uri: String): String {
var current = uri
val visited = mutableSetOf<String>()
while (visited.add(current)) {
current = replacements[current] ?: break
}
return current
}
fun resolve(uri: Uri): Uri = resolve(uri.toString()).toUri()
}
@@ -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.utils
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
inline fun <T> OutputStream.createZip(
block: (ZipOutputStream) -> T
): T = ZipOutputStream(this).use(block)
fun ZipOutputStream.putEntry(
name: String,
input: InputStream
) {
putNextEntry(ZipEntry(name))
input.use { it.copyTo(this) }
closeEntry()
}
fun ZipOutputStream.putEntry(
name: String,
write: (ZipOutputStream) -> Unit
) {
putNextEntry(ZipEntry(name))
write(this)
closeEntry()
}