chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user