diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed1bced..c5b3ece 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,7 +22,7 @@ concurrency:
jobs:
build-and-test:
name: Build and test
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Check out repository
@@ -38,7 +38,7 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v4
with:
- packages: 'platforms;android-37 build-tools;36.0.0'
+ packages: 'platforms;android-37.0 build-tools;36.0.0'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
diff --git a/.idea/shelf/Default.xml b/.idea/shelf/Default.xml
new file mode 100644
index 0000000..469c9ad
--- /dev/null
+++ b/.idea/shelf/Default.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/shelf/Default/shelved.patch b/.idea/shelf/Default/shelved.patch
new file mode 100644
index 0000000..8f3c1d3
--- /dev/null
+++ b/.idea/shelf/Default/shelved.patch
@@ -0,0 +1,1112 @@
+Index: app/src/test/java/ru/svolf/convertx/data/entity/PaletteTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/data/entity/PaletteTest.kt b/app/src/test/java/ru/svolf/convertx/data/entity/PaletteTest.kt
+new file mode 100644
+--- /dev/null (date 1789822410874)
++++ b/app/src/test/java/ru/svolf/convertx/data/entity/PaletteTest.kt (date 1789822410874)
+@@ -0,0 +1,29 @@
++package ru.svolf.convertx.data.entity
++
++import kotlinx.serialization.json.Json
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Assertions.assertNotEquals
++import org.junit.jupiter.api.Test
++
++class PaletteTest {
++ @Test
++ fun `palette json is decoded into colors`() {
++ val squash = Json.decodeFromString(
++ """{"palettes":[{"name":"Ocean","colors":[{"accent":"blue","hex":"#0000ff"}]}]}"""
++ )
++
++ assertEquals("Ocean", squash.palettes.single().name)
++ assertEquals(Color("blue", "#0000ff"), squash.palettes.single().colors.single())
++ }
++
++ @Test
++ fun `palette equality compares array contents`() {
++ val first = Palette("One", arrayOf(Color("a", "#111111")))
++ val sameColors = Palette("Two", arrayOf(Color("a", "#111111")))
++ val differentColors = Palette("One", arrayOf(Color("b", "#222222")))
++
++ assertEquals(first, sameColors)
++ assertNotEquals(first, differentColors)
++ assertEquals(first.hashCode(), sameColors.hashCode())
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/TestDoubles.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/TestDoubles.kt b/app/src/test/java/ru/svolf/convertx/TestDoubles.kt
+new file mode 100644
+--- /dev/null (date 1789822460468)
++++ b/app/src/test/java/ru/svolf/convertx/TestDoubles.kt (date 1789822460468)
+@@ -0,0 +1,66 @@
++package ru.svolf.convertx
++
++import androidx.datastore.core.DataStore
++import androidx.datastore.preferences.core.Preferences
++import androidx.datastore.preferences.core.emptyPreferences
++import kotlinx.coroutines.Dispatchers
++import kotlinx.coroutines.flow.Flow
++import kotlinx.coroutines.flow.MutableStateFlow
++import kotlinx.coroutines.test.StandardTestDispatcher
++import kotlinx.coroutines.test.TestScope
++import kotlinx.coroutines.test.advanceUntilIdle
++import kotlinx.coroutines.test.runTest
++import kotlinx.coroutines.test.resetMain
++import kotlinx.coroutines.test.setMain
++import ru.svolf.convertx.data.dao.HistoryDao
++import ru.svolf.convertx.data.entity.HistoryEntity
++
++internal class InMemoryDataStore(
++ initial: Preferences = emptyPreferences()
++) : DataStore {
++ private val values = MutableStateFlow(initial)
++
++ override val data: Flow = values
++
++ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
++ val updated = transform(values.value)
++ values.value = updated
++ return updated
++ }
++}
++
++internal class InMemoryHistoryDao : HistoryDao {
++ private val rows = MutableStateFlow>(emptyList())
++
++ override fun observeAll(): Flow> = rows
++
++ override suspend fun findById(id: Long): HistoryEntity? = rows.value.firstOrNull { it.id == id }
++
++ override suspend fun upsert(entity: HistoryEntity) {
++ rows.value = (rows.value.filterNot { it.id == entity.id } + entity)
++ .sortedByDescending { it.id }
++ }
++
++ override suspend fun deleteById(id: Long) {
++ rows.value = rows.value.filterNot { it.id == id }
++ }
++
++ override suspend fun deleteAll() {
++ rows.value = emptyList()
++ }
++
++ suspend fun seed(vararg entities: HistoryEntity) {
++ entities.forEach { upsert(it) }
++ }
++}
++
++@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
++internal fun runViewModelTest(block: suspend TestScope.() -> Unit) = runTest {
++ Dispatchers.setMain(StandardTestDispatcher(testScheduler))
++ try {
++ block()
++ advanceUntilIdle()
++ } finally {
++ Dispatchers.resetMain()
++ }
++}
+Index: .idea/workspace.xml
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>\n\n \n \n \n android-25\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n {\n "lastFilter": {\n "state": "OPEN",\n "assignee": "SnowVolf"\n }\n}\n \n \n \n \n \n \n \n \n {\n "selectedUrlAndAccountId": {\n "url": "https://github.com/SnowVolf/ConvertX.git",\n "accountId": "dc895139-b57d-43cf-8daa-b64064e236d5"\n }\n}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n {\n "associatedIndex": 2\n}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Android > Lint > Security\n \n \n \n \n AndroidLintSetJavaScriptEnabled\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n {\n "keyToString": {\n "Android App.app.executor": "Run",\n "ApkExportedModule": "ConvertX.app",\n "ExportApk.ApkPathForConvertX.app": "D:\\\\GitHub\\\\ConvertX\\\\app",\n "PROJECT_TRUSTED_KEY": "true",\n "RunOnceActivity.OpenProjectViewOnStart": "true",\n "RunOnceActivity.ShowReadmeOnStart": "true",\n "RunOnceActivity.cidr.known.project.marker": "true",\n "RunOnceActivity.git.unshallow": "true",\n "RunOnceActivity.readMode.enableVisualFormatting": "true",\n "SHARE_PROJECT_CONFIGURATION_FILES": "true",\n "ScreenRecorder.SavePath": "E:\\\\UserData\\\\SVolf\\\\Desktop\\\\suka.webm",\n "ScreenshotViewer.SavePath": "D:/GitHub/ConvertX/screens",\n "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.appcompat/appcompat/1.6.1/ace9a78b961165396147e8691faa18c1b0e48e20/appcompat-1.6.1-sources.jar!/androidx/appcompat/widget/AppCompatEditText.java_SELECTED": "AppCompatEditText",\n "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.appcompat/appcompat/1.6.1/ace9a78b961165396147e8691faa18c1b0e48e20/appcompat-1.6.1-sources.jar!/androidx/appcompat/widget/AppCompatTextView.java_SELECTED": "AppCompatTextView",\n "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.recyclerview/recyclerview/1.2.1/f0f93e67af3f7417bdd560d5142f6dec4fe629c3/recyclerview-1.2.1-sources.jar!/androidx/recyclerview/widget/RecyclerView.java_SELECTED": "RecyclerView",\n "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/com.google.android.material/material/1.6.1/d4d703885d24c61bc0ec39a9a705969e41d773ef/material-1.6.1-sources.jar!/com/google/android/material/tabs/TabLayout.java_SELECTED": "TabLayout",\n "android-custom-viewD:/Android/SDK/sources/android-33/android/view/View.java_SELECTED": "View",\n "android-custom-viewD:/Android/SDK/sources/android-33/android/widget/LinearLayout.java_SELECTED": "LinearLayout",\n "android-custom-viewD:/Android/SDK/sources/android-33/android/widget/TextView.java_SELECTED": "TextView",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/presentation/screens/regex/ExtendedWebView.kt_SELECTED": "ExtendedWebView",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/presentation/screens/regex/NestedWebView.kt_SELECTED": "NestedWebView",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/regexdragon/ExtendedWebView.kt_SELECTED": "ExtendedWebView",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/ui/CircleImageView.java_SELECTED": "CircleImageView",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/BlurToolbar.java_SELECTED": "BlurToolbar",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/CircleImageView.java_SELECTED": "CircleImageView",\n "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/SwipeEditText.java_SELECTED": "SwipeEditText",\n "android-custom-viewD:/GitHub/ConvertX/backdrop/src/main/java/com/roacult/backdrop/BackdropLayout.kt_SELECTED": "BackdropLayout",\n "cf.first.check.clang-format": "false",\n "cidr.known.project.marker": "true",\n "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrary": "JUnit4",\n "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrarySuperClass.JUnit4": "",\n "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrarySuperClass.JUnit5": "",\n "git-widget-placeholder": "master",\n "kotlin-language-version-configured": "true",\n "last.edited.regexp": "\\\\u0000\\\\u009c",\n "last_directory_selection": "D:/GitHub/ConvertX/app/src/androidTest/java/ru/svolf/convertx/presentation",\n "last_opened_file_path": "/home/svolf/Projects/ConvertX",\n "project.structure.last.edited": "Modules",\n "project.structure.proportion": "0.17",\n "project.structure.side.proportion": "0.2",\n "settings.editor.selected.configurable": "preferences.customizations"\n },\n "keyToStringList": {\n "ExportApk.BuildVariants": [\n "release"\n ]\n }\n}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n \n \n \n false\n true\n false\n \n false\n false\n \n false\n true\n false\n false\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n \n 1485534024097\n \n \n 1485534024097\n \n \n 1664878074692\n \n \n \n 1664878074692\n \n \n 1674221814465\n \n \n \n 1674221814465\n \n \n 1674221844083\n \n \n \n 1674221844083\n \n \n 1674222023665\n \n \n \n 1674222023665\n \n \n 1674483161985\n \n \n \n 1674483161985\n \n \n 1674755379648\n \n \n \n 1674755379648\n \n \n 1674825652414\n \n \n \n 1674825652414\n \n \n 1674829972032\n \n \n \n 1674829972032\n \n \n 1674922667092\n \n \n \n 1674922667092\n \n \n 1674996790162\n \n \n \n 1674996790162\n \n \n 1677158450400\n \n \n \n 1677158450400\n \n \n 1677174004905\n \n \n \n 1677174004905\n \n \n 1677220149056\n \n \n \n 1677220149056\n \n \n 1677220632501\n \n \n \n 1677220632501\n \n \n 1677343798530\n \n \n \n 1677343798530\n \n \n 1677394225351\n \n \n \n 1677394225351\n \n \n \n 1789815336189\n \n \n \n 1789815336189\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/.idea/workspace.xml b/.idea/workspace.xml
+--- a/.idea/workspace.xml (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/.idea/workspace.xml (date 1789822885000)
+@@ -15,13 +15,25 @@
+
+
+
+-
++
++
++
++
++
++
++
++
++
++
++
++
+
+-
+-
+-
+-
+-
++
++
++
++
++
++
+
+
+
+@@ -3379,56 +3391,56 @@
+
+
+
+- {
+- "keyToString": {
+- "Android App.app.executor": "Run",
+- "ApkExportedModule": "ConvertX.app",
+- "ExportApk.ApkPathForConvertX.app": "D:\\GitHub\\ConvertX\\app",
+- "PROJECT_TRUSTED_KEY": "true",
+- "RunOnceActivity.OpenProjectViewOnStart": "true",
+- "RunOnceActivity.ShowReadmeOnStart": "true",
+- "RunOnceActivity.cidr.known.project.marker": "true",
+- "RunOnceActivity.git.unshallow": "true",
+- "RunOnceActivity.readMode.enableVisualFormatting": "true",
+- "SHARE_PROJECT_CONFIGURATION_FILES": "true",
+- "ScreenRecorder.SavePath": "E:\\UserData\\SVolf\\Desktop\\suka.webm",
+- "ScreenshotViewer.SavePath": "D:/GitHub/ConvertX/screens",
+- "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.appcompat/appcompat/1.6.1/ace9a78b961165396147e8691faa18c1b0e48e20/appcompat-1.6.1-sources.jar!/androidx/appcompat/widget/AppCompatEditText.java_SELECTED": "AppCompatEditText",
+- "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.appcompat/appcompat/1.6.1/ace9a78b961165396147e8691faa18c1b0e48e20/appcompat-1.6.1-sources.jar!/androidx/appcompat/widget/AppCompatTextView.java_SELECTED": "AppCompatTextView",
+- "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.recyclerview/recyclerview/1.2.1/f0f93e67af3f7417bdd560d5142f6dec4fe629c3/recyclerview-1.2.1-sources.jar!/androidx/recyclerview/widget/RecyclerView.java_SELECTED": "RecyclerView",
+- "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/com.google.android.material/material/1.6.1/d4d703885d24c61bc0ec39a9a705969e41d773ef/material-1.6.1-sources.jar!/com/google/android/material/tabs/TabLayout.java_SELECTED": "TabLayout",
+- "android-custom-viewD:/Android/SDK/sources/android-33/android/view/View.java_SELECTED": "View",
+- "android-custom-viewD:/Android/SDK/sources/android-33/android/widget/LinearLayout.java_SELECTED": "LinearLayout",
+- "android-custom-viewD:/Android/SDK/sources/android-33/android/widget/TextView.java_SELECTED": "TextView",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/presentation/screens/regex/ExtendedWebView.kt_SELECTED": "ExtendedWebView",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/presentation/screens/regex/NestedWebView.kt_SELECTED": "NestedWebView",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/regexdragon/ExtendedWebView.kt_SELECTED": "ExtendedWebView",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/ui/CircleImageView.java_SELECTED": "CircleImageView",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/BlurToolbar.java_SELECTED": "BlurToolbar",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/CircleImageView.java_SELECTED": "CircleImageView",
+- "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/SwipeEditText.java_SELECTED": "SwipeEditText",
+- "android-custom-viewD:/GitHub/ConvertX/backdrop/src/main/java/com/roacult/backdrop/BackdropLayout.kt_SELECTED": "BackdropLayout",
+- "cf.first.check.clang-format": "false",
+- "cidr.known.project.marker": "true",
+- "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrary": "JUnit4",
+- "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrarySuperClass.JUnit4": "",
+- "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrarySuperClass.JUnit5": "",
+- "git-widget-placeholder": "master",
+- "kotlin-language-version-configured": "true",
+- "last.edited.regexp": "\\u0000\\u009c",
+- "last_directory_selection": "D:/GitHub/ConvertX/app/src/androidTest/java/ru/svolf/convertx/presentation",
+- "last_opened_file_path": "/home/svolf/Projects/ConvertX",
+- "project.structure.last.edited": "Modules",
+- "project.structure.proportion": "0.17",
+- "project.structure.side.proportion": "0.2",
+- "settings.editor.selected.configurable": "preferences.customizations"
++
++}]]>
+
+
+
+@@ -3970,7 +3982,15 @@
+
+ 1789815336189
+
+-
++
++
++ 1789821293533
++
++
++
++ 1789821293533
++
++
+
+
+
+Index: app/src/test/java/ru/svolf/convertx/utils/algorhitms/DecoderTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/utils/algorhitms/DecoderTest.kt b/app/src/test/java/ru/svolf/convertx/utils/algorhitms/DecoderTest.kt
+new file mode 100644
+--- /dev/null (date 1789822978573)
++++ b/app/src/test/java/ru/svolf/convertx/utils/algorhitms/DecoderTest.kt (date 1789822978573)
+@@ -0,0 +1,67 @@
++package ru.svolf.convertx.utils.algorhitms
++
++import kotlinx.coroutines.test.runTest
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Assertions.assertFalse
++import org.junit.jupiter.api.Assertions.assertTrue
++import org.junit.jupiter.api.Test
++
++class DecoderTest {
++ @Test
++ fun `unicode escapes and java escapes are decoded`() = runTest {
++ assertEquals("AΩ\n\t", Decoder.decodeUnicode("\\u0041\\u03A9\\n\\t"))
++ assertEquals("plain text", Decoder.decodeUnicode("plain text"))
++ }
++
++ @Test
++ fun `malformed unicode digit is rejected`() = runTest {
++ val error = runCatching { Decoder.decodeUnicode("\\u12x4") }.exceptionOrNull()
++
++ assertTrue(error is IllegalArgumentException)
++ }
++
++ @Test
++ fun `xml entities are unescaped and unknown entities are preserved`() = runTest {
++ assertEquals(
++ "&\"x\" ✏ &unknown;",
++ Decoder.unescapeXml("<tag a='1'>&"x"</tag> ✏ &unknown;")
++ )
++ }
++
++ @Test
++ fun `hex string is encoded and decoded`() = runTest {
++ val encoded = Decoder.toHexString("Hi")
++
++ assertTrue(encoded.startsWith("0x"))
++ assertTrue(encoded.endsWith("4869"))
++ assertEquals(42, encoded.length)
++ assertEquals("Hi", Decoder.decodeHexString("4869"))
++ }
++
++ @Test
++ fun `integer conversion returns hexadecimal and handles overflow`() = runTest {
++ assertEquals("0xff", Decoder.intToHex("255"))
++ assertEquals("255", Decoder.hexToInt("ff"))
++ assertEquals("Error. Out of range", Decoder.intToHex("not-a-number"))
++ assertEquals("Error. Out of range", Decoder.hexToInt("fffffffff"))
++ }
++
++ @Test
++ fun `base64 round trip supports standard and url safe data`() = runTest {
++ val plain = "hello?"
++ val encoded = Decoder.encodeBase64(plain, android.util.Base64.NO_WRAP)
++
++ assertEquals(plain, Decoder.decodeBase64(encoded, android.util.Base64.NO_WRAP))
++
++ val urlEncoded = Decoder.encodeBase64("??", android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING)
++ assertFalse(urlEncoded.contains("="))
++ assertEquals("??", Decoder.decodeBase64(urlEncoded, android.util.Base64.URL_SAFE))
++ }
++
++ @Test
++ fun `timestamp conversion uses a stable english date format`() = runTest {
++ val result = Decoder.getNormalDate(0L)
++
++ assertTrue(result.matches(Regex("\\d{2}\\.\\d{2}\\.1970 \\d{2}:\\d{2}:\\d{2}")))
++ }
++}
+Index: gradle/libs.versions.toml
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>[versions]\nagp = \"9.4.0\"\nkotlin = \"2.2.10\"\nserialization = \"2.2.10\"\ncompileSdk = \"37\"\nminSdk = \"26\"\ntargetSdk = \"37\"\ncomposeBom = \"2026.09.00\"\nnavigation3 = \"1.1.7\"\nactivity = \"1.13.0\"\nlifecycle = \"2.11.0\"\nroom = \"2.8.5\"\ndatastore = \"1.2.1\"\ndagger = \"2.60.1\"\nksp = \"2.2.10-2.0.2\"\nserializationJson = \"1.9.0\"\nandroidxTest = \"1.7.0\"\nandroidxTestJunit = \"1.3.0\"\nespresso = \"3.7.0\"\njunit = \"4.13.2\"\n\n[libraries]\nandroidx-activity-compose = { module = \"androidx.activity:activity-compose\", version.ref = \"activity\" }\nandroidx-compose-bom = { module = \"androidx.compose:compose-bom\", version.ref = \"composeBom\" }\nandroidx-compose-foundation = { module = \"androidx.compose.foundation:foundation\" }\nandroidx-compose-material3 = { module = \"androidx.compose.material3:material3\" }\nandroidx-compose-material-icons-extended = { module = \"androidx.compose.material:material-icons-extended\" }\nandroidx-compose-runtime = { module = \"androidx.compose.runtime:runtime\" }\nandroidx-compose-ui = { module = \"androidx.compose.ui:ui\" }\nandroidx-compose-ui-tooling = { module = \"androidx.compose.ui:ui-tooling\" }\nandroidx-compose-ui-tooling-preview = { module = \"androidx.compose.ui:ui-tooling-preview\" }\nandroidx-compose-ui-test-junit4 = { module = \"androidx.compose.ui:ui-test-junit4\" }\nandroidx-compose-ui-test-manifest = { module = \"androidx.compose.ui:ui-test-manifest\" }\n\nandroidx-navigation3-runtime = { module = \"androidx.navigation3:navigation3-runtime\", version.ref = \"navigation3\" }\nandroidx-navigation3-ui = { module = \"androidx.navigation3:navigation3-ui\", version.ref = \"navigation3\" }\nandroidx-lifecycle-viewmodel-navigation3 = { module = \"androidx.lifecycle:lifecycle-viewmodel-navigation3\", version.ref = \"lifecycle\" }\nandroidx-lifecycle-viewmodel-compose = { module = \"androidx.lifecycle:lifecycle-viewmodel-compose\", version.ref = \"lifecycle\" }\nandroidx-lifecycle-runtime-compose = { module = \"androidx.lifecycle:lifecycle-runtime-compose\", version.ref = \"lifecycle\" }\nandroidx-lifecycle-runtime-ktx = { module = \"androidx.lifecycle:lifecycle-runtime-ktx\", version.ref = \"lifecycle\" }\n\nandroidx-room-runtime = { module = \"androidx.room:room-runtime\", version.ref = \"room\" }\nandroidx-room-ktx = { module = \"androidx.room:room-ktx\", version.ref = \"room\" }\nandroidx-room-compiler = { module = \"androidx.room:room-compiler\", version.ref = \"room\" }\nandroidx-datastore-preferences = { module = \"androidx.datastore:datastore-preferences\", version.ref = \"datastore\" }\n\ndagger = { module = \"com.google.dagger:dagger\", version.ref = \"dagger\" }\ndagger-compiler = { module = \"com.google.dagger:dagger-compiler\", version.ref = \"dagger\" }\nkotlinx-serialization-json = { module = \"org.jetbrains.kotlinx:kotlinx-serialization-json\", version.ref = \"serializationJson\" }\n\nandroidx-test-rules = { module = \"androidx.test:rules\", version.ref = \"androidxTest\" }\nandroidx-test-junit = { module = \"androidx.test.ext:junit\", version.ref = \"androidxTestJunit\" }\nandroidx-test-espresso = { module = \"androidx.test.espresso:espresso-core\", version.ref = \"espresso\" }\njunit = { module = \"junit:junit\", version.ref = \"junit\" }\n\n[plugins]\nandroid-application = { id = \"com.android.application\", version.ref = \"agp\" }\nkotlin-android = { id = \"org.jetbrains.kotlin.android\", version.ref = \"kotlin\" }\nkotlin-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"serialization\" }\nksp = { id = \"com.google.devtools.ksp\", version.ref = \"ksp\" }\nkotlin-compose = { id = \"org.jetbrains.kotlin.plugin.compose\", version.ref = \"kotlin\" }\n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
+--- a/gradle/libs.versions.toml (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/gradle/libs.versions.toml (date 1789821796036)
+@@ -17,7 +17,9 @@
+ androidxTest = "1.7.0"
+ androidxTestJunit = "1.3.0"
+ espresso = "3.7.0"
+-junit = "4.13.2"
++junitJupiter = "5.12.2"
++junitPlatform = "1.12.2"
++coroutines = "1.10.1"
+
+ [libraries]
+ androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
+@@ -51,7 +53,10 @@
+ androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTest" }
+ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" }
+ androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
+-junit = { module = "junit:junit", version.ref = "junit" }
++junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" }
++junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" }
++junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
++kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+
+ [plugins]
+ android-application = { id = "com.android.application", version.ref = "agp" }
+Index: app/src/test/java/android/util/Base64.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/android/util/Base64.kt b/app/src/test/java/android/util/Base64.kt
+new file mode 100644
+--- /dev/null (date 1789822056915)
++++ b/app/src/test/java/android/util/Base64.kt (date 1789822056915)
+@@ -0,0 +1,44 @@
++package android.util
++
++import java.util.Base64 as JavaBase64
++
++/**
++ * JVM-only stand-in for the Android SDK class, whose local-test implementation is a stub.
++ * The flags used by the application are preserved so conversion behavior remains testable.
++ */
++class Base64 private constructor() {
++ companion object {
++ @JvmField val DEFAULT: Int = 0
++ @JvmField val NO_PADDING: Int = 1
++ @JvmField val NO_WRAP: Int = 2
++ @JvmField val CRLF: Int = 4
++ @JvmField val URL_SAFE: Int = 8
++ @JvmField val NO_CLOSE: Int = 16
++
++ @JvmStatic
++ fun encode(input: ByteArray, flags: Int): ByteArray {
++ val encoder = if (flags and URL_SAFE != 0) {
++ JavaBase64.getUrlEncoder()
++ } else {
++ JavaBase64.getEncoder()
++ }.let { if (flags and NO_PADDING != 0) it.withoutPadding() else it }
++
++ var encoded = encoder.encodeToString(input)
++ if (flags and NO_WRAP == 0 && flags and CRLF != 0 && encoded.length > 76) {
++ encoded = encoded.chunked(76).joinToString("\r\n")
++ }
++ return encoded.toByteArray(Charsets.UTF_8)
++ }
++
++ @JvmStatic
++ fun decode(input: ByteArray, flags: Int): ByteArray {
++ val normalized = input.toString(Charsets.UTF_8).filterNot { it == '\n' || it == '\r' }
++ val decoder = if (flags and URL_SAFE != 0) {
++ JavaBase64.getUrlDecoder()
++ } else {
++ JavaBase64.getDecoder()
++ }
++ return decoder.decode(normalized)
++ }
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/presentation/viewmodel/HistoryViewModelTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/HistoryViewModelTest.kt b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/HistoryViewModelTest.kt
+new file mode 100644
+--- /dev/null (date 1789822382838)
++++ b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/HistoryViewModelTest.kt (date 1789822382838)
+@@ -0,0 +1,49 @@
++package ru.svolf.convertx.presentation.viewmodel
++
++import androidx.lifecycle.viewModelScope
++import kotlinx.coroutines.cancel
++import kotlinx.coroutines.flow.first
++import kotlinx.coroutines.test.advanceUntilIdle
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.InMemoryHistoryDao
++import ru.svolf.convertx.data.HistoryRecord
++import ru.svolf.convertx.data.HistoryRepository
++import ru.svolf.convertx.runViewModelTest
++
++@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
++class HistoryViewModelTest {
++ @Test
++ fun `records flow exposes repository updates`() = runViewModelTest {
++ val dao = InMemoryHistoryDao()
++ val repository = HistoryRepository(dao)
++ val viewModel = HistoryViewModel(repository)
++ val record = HistoryRecord(1L, 0, "input", "output", 0)
++
++ val firstRecord = viewModel.records.first()
++ assertEquals(emptyList(), firstRecord)
++
++ repository.save(record)
++ assertEquals(listOf(record), viewModel.records.first { it == listOf(record) })
++ viewModel.viewModelScope.cancel()
++ }
++
++ @Test
++ fun `delete and clearAll delegate to repository`() = runViewModelTest {
++ val repository = HistoryRepository(InMemoryHistoryDao())
++ val viewModel = HistoryViewModel(repository)
++ val first = HistoryRecord(1L, 0, "a", "b", 0)
++ val second = HistoryRecord(2L, 1, "c", "d", 0)
++ repository.save(first)
++ repository.save(second)
++
++ viewModel.delete(first)
++ advanceUntilIdle()
++ assertEquals(listOf(second), repository.records.first())
++
++ viewModel.clearAll()
++ advanceUntilIdle()
++ assertEquals(emptyList(), repository.records.first())
++ viewModel.viewModelScope.cancel()
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/presentation/navigation/HistoryRouteMapperTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/presentation/navigation/HistoryRouteMapperTest.kt b/app/src/test/java/ru/svolf/convertx/presentation/navigation/HistoryRouteMapperTest.kt
+new file mode 100644
+--- /dev/null (date 1789821656413)
++++ b/app/src/test/java/ru/svolf/convertx/presentation/navigation/HistoryRouteMapperTest.kt (date 1789821656413)
+@@ -0,0 +1,21 @@
++package ru.svolf.convertx.presentation.navigation
++
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Assertions.assertNull
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.data.HistoryRecord
++
++class HistoryRouteMapperTest {
++ @Test
++ fun `known decoder ids map to converter routes`() {
++ assertEquals(UnicodeRoute, HistoryRecord(1, 0, "i", "o", 0).toRoute())
++ assertEquals(Base64Route("i", "o", 4), HistoryRecord(2, 1, "i", "o", 4).toRoute())
++ assertEquals(HexRoute("i", "o", 1), HistoryRecord(3, 2, "i", "o", 1).toRoute())
++ }
++
++ @Test
++ fun `unknown decoder ids are not navigable`() {
++ assertNull(HistoryRecord(1, -1, "", "", 0).toRoute())
++ assertNull(HistoryRecord(1, 99, "", "", 0).toRoute())
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/presentation/viewmodel/SettingsViewModelTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/SettingsViewModelTest.kt
+new file mode 100644
+--- /dev/null (date 1789822383004)
++++ b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/SettingsViewModelTest.kt (date 1789822383004)
+@@ -0,0 +1,35 @@
++package ru.svolf.convertx.presentation.viewmodel
++
++import androidx.lifecycle.viewModelScope
++import kotlinx.coroutines.cancel
++import kotlinx.coroutines.flow.first
++import kotlinx.coroutines.test.advanceUntilIdle
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.InMemoryDataStore
++import ru.svolf.convertx.data.SettingsRepository
++import ru.svolf.convertx.data.ThemeMode
++import ru.svolf.convertx.runViewModelTest
++
++@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
++class SettingsViewModelTest {
++ @Test
++ fun `settings actions update the exposed state`() = runViewModelTest {
++ val viewModel = SettingsViewModel(SettingsRepository(InMemoryDataStore()))
++
++ viewModel.setTheme(ThemeMode.DARK)
++ viewModel.setFontSize(40)
++ viewModel.setTwiceBack(false)
++ advanceUntilIdle()
++
++ val state = viewModel.state.first {
++ it.theme == ThemeMode.DARK && it.fontSize == 32 && !it.twiceBackToExit
++ }
++ assertEquals(ThemeMode.DARK, state.theme)
++ assertEquals(32, state.fontSize)
++ assertEquals(false, state.twiceBackToExit)
++ viewModel.setTwiceBack(true)
++ advanceUntilIdle()
++ viewModel.viewModelScope.cancel()
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/data/HistoryRecordTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>package ru.svolf.convertx.data\n\nimport org.junit.Assert.assertEquals\nimport org.junit.Test\nimport ru.svolf.convertx.data.entity.HistoryEntity\n\nclass HistoryRecordTest {\n @Test\n fun `legacy nullable row is mapped to a safe domain record`() {\n val record = HistoryEntity(\n id = 42L,\n decoder = null,\n input = null,\n output = null,\n spinnerPosition = null\n ).toRecord()\n\n assertEquals(42L, record.id)\n assertEquals(-1, record.decoder)\n assertEquals(\"\", record.input)\n assertEquals(\"\", record.output)\n assertEquals(0, record.spinnerPosition)\n }\n}\n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/data/HistoryRecordTest.kt b/app/src/test/java/ru/svolf/convertx/data/HistoryRecordTest.kt
+--- a/app/src/test/java/ru/svolf/convertx/data/HistoryRecordTest.kt (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/app/src/test/java/ru/svolf/convertx/data/HistoryRecordTest.kt (date 1789821518940)
+@@ -1,7 +1,7 @@
+ package ru.svolf.convertx.data
+
+-import org.junit.Assert.assertEquals
+-import org.junit.Test
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Test
+ import ru.svolf.convertx.data.entity.HistoryEntity
+
+ class HistoryRecordTest {
+Index: app/src/test/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModelTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModelTest.kt b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModelTest.kt
+new file mode 100644
+--- /dev/null (date 1789822382687)
++++ b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModelTest.kt (date 1789822382687)
+@@ -0,0 +1,141 @@
++package ru.svolf.convertx.presentation.viewmodel
++
++import androidx.lifecycle.viewModelScope
++import kotlinx.coroutines.cancel
++import kotlinx.coroutines.flow.first
++import kotlinx.coroutines.test.advanceUntilIdle
++import kotlinx.coroutines.test.runCurrent
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Assertions.assertFalse
++import org.junit.jupiter.api.Assertions.assertNotNull
++import org.junit.jupiter.api.Assertions.assertTrue
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.InMemoryDataStore
++import ru.svolf.convertx.InMemoryHistoryDao
++import ru.svolf.convertx.data.ConverterEngine
++import ru.svolf.convertx.data.HistoryRepository
++import ru.svolf.convertx.data.SettingsRepository
++import ru.svolf.convertx.presentation.navigation.Base64Route
++import ru.svolf.convertx.presentation.navigation.UnicodeRoute
++import ru.svolf.convertx.runViewModelTest
++
++@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
++class ConverterViewModelTest {
++ @Test
++ fun `route arguments initialize input output and mode`() = runViewModelTest {
++ val settings = SettingsRepository(InMemoryDataStore())
++ val viewModel = ConverterViewModel(
++ Base64Route(input = "in", output = "out", mode = 3),
++ HistoryRepository(InMemoryHistoryDao()),
++ settings,
++ ConverterEngine()
++ )
++
++ assertEquals("in", viewModel.state.value.input)
++ assertEquals("out", viewModel.state.value.output)
++ assertEquals(3, viewModel.state.value.mode)
++ viewModel.viewModelScope.cancel()
++ }
++
++ @Test
++ fun `input conversion updates output and saves history`() = runViewModelTest {
++ val history = HistoryRepository(InMemoryHistoryDao())
++ val viewModel = ConverterViewModel(
++ UnicodeRoute,
++ history,
++ SettingsRepository(InMemoryDataStore()),
++ ConverterEngine()
++ )
++
++ viewModel.onInputChanged("A")
++ finishConversion()
++
++ assertEquals("A", viewModel.state.value.input)
++ assertEquals("\\u0041", viewModel.state.value.output)
++ assertFalse(viewModel.state.value.busy)
++ assertEquals(1L, viewModel.state.value.conversionPulse)
++ assertEquals(0, history.records.first().first().decoder)
++ viewModel.viewModelScope.cancel()
++ }
++
++ @Test
++ fun `output conversion highlights input`() = runViewModelTest {
++ val viewModel = ConverterViewModel(
++ UnicodeRoute,
++ HistoryRepository(InMemoryHistoryDao()),
++ SettingsRepository(InMemoryDataStore()),
++ ConverterEngine()
++ )
++
++ viewModel.onOutputChanged("\\u0041")
++ finishConversion()
++
++ assertEquals("A", viewModel.state.value.input)
++ assertEquals("\\u0041", viewModel.state.value.output)
++ assertTrue(viewModel.state.value.highlightInput)
++ viewModel.viewModelScope.cancel()
++ }
++
++ @Test
++ fun `invalid conversion exposes an error and does not throw`() = runViewModelTest {
++ val viewModel = ConverterViewModel(
++ UnicodeRoute,
++ HistoryRepository(InMemoryHistoryDao()),
++ SettingsRepository(InMemoryDataStore()),
++ ConverterEngine()
++ )
++
++ viewModel.onOutputChanged("\\u12x4")
++ finishConversion()
++
++ assertNotNull(viewModel.state.value.error)
++ assertFalse(viewModel.state.value.busy)
++ viewModel.viewModelScope.cancel()
++ }
++
++ @Test
++ fun `clear cancels a pending conversion`() = runViewModelTest {
++ val history = HistoryRepository(InMemoryHistoryDao())
++ val viewModel = ConverterViewModel(
++ UnicodeRoute,
++ history,
++ SettingsRepository(InMemoryDataStore()),
++ ConverterEngine()
++ )
++
++ viewModel.onInputChanged("A")
++ viewModel.clear()
++ advanceUntilIdle()
++
++ assertEquals("", viewModel.state.value.input)
++ assertEquals("", viewModel.state.value.output)
++ assertTrue(history.records.first().isEmpty())
++ viewModel.viewModelScope.cancel()
++ }
++
++ @Test
++ fun `mode change is persisted for base64 routes`() = runViewModelTest {
++ val dataStore = InMemoryDataStore()
++ val viewModel = ConverterViewModel(
++ Base64Route(),
++ HistoryRepository(InMemoryHistoryDao()),
++ SettingsRepository(dataStore),
++ ConverterEngine()
++ )
++
++ viewModel.onModeChanged(4)
++ advanceUntilIdle()
++
++ assertEquals(4, viewModel.state.value.mode)
++ assertEquals(4, SettingsRepository(dataStore).state.first().base64Mode)
++ viewModel.viewModelScope.cancel()
++ }
++
++ private suspend fun kotlinx.coroutines.test.TestScope.finishConversion() {
++ runCurrent()
++ advanceUntilIdle()
++ Thread.sleep(100)
++ runCurrent()
++ advanceUntilIdle()
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt b/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt
+new file mode 100644
+--- /dev/null (date 1789822974884)
++++ b/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt (date 1789822974884)
+@@ -0,0 +1,77 @@
++package ru.svolf.convertx.data
++
++import androidx.datastore.preferences.core.booleanPreferencesKey
++import androidx.datastore.preferences.core.intPreferencesKey
++import androidx.datastore.preferences.core.preferencesOf
++import androidx.datastore.preferences.core.stringPreferencesKey
++import kotlinx.coroutines.flow.Flow
++import kotlinx.coroutines.flow.first
++import kotlinx.coroutines.test.runTest
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.InMemoryDataStore
++
++class SettingsRepositoryTest {
++ @Test
++ fun `empty datastore exposes documented defaults`() = runTest {
++ val state = SettingsRepository(InMemoryDataStore()).state.first()
++
++ assertEquals(SettingsState(), state)
++ }
++
++ @Test
++ fun `setters clamp values and persist all settings`() = runTest {
++ val repository = SettingsRepository(InMemoryDataStore())
++
++ repository.setTheme(ThemeMode.DARK)
++ repository.setFontSize(99)
++ repository.setTwiceBackToExit(false)
++ repository.setBase64Mode(-1)
++ repository.setHexMode(99)
++
++ assertEquals(
++ SettingsState(
++ theme = ThemeMode.DARK,
++ fontSize = 32,
++ twiceBackToExit = false,
++ base64Mode = 0,
++ hexMode = 1
++ ),
++ repository.state.first()
++ )
++ }
++
++ @Test
++ fun `legacy font key is used when the new key is absent`() = runTest {
++ val dataStore = InMemoryDataStore(
++ preferencesOf(
++ stringPreferencesKey("ITheme.Theme") to "1",
++ intPreferencesKey("Font.Size") to 20,
++ booleanPreferencesKey("Interaction.Back") to false,
++ intPreferencesKey("spinner.base64") to 4,
++ intPreferencesKey("spinner.hex") to 1
++ )
++ )
++
++ assertEquals(
++ SettingsState(ThemeMode.DARK, 20, false, 4, 1),
++ SettingsRepository(dataStore).state.first()
++ )
++ }
++
++ @Test
++ fun `io failure is converted to empty preferences`() = runTest {
++ val repository = SettingsRepository(
++ object : androidx.datastore.core.DataStore {
++ override val data: Flow = kotlinx.coroutines.flow.flow {
++ throw java.io.IOException("read failed")
++ }
++
++ override suspend fun updateData(transform: suspend (androidx.datastore.preferences.core.Preferences) -> androidx.datastore.preferences.core.Preferences) =
++ error("not used")
++ }
++ )
++
++ assertEquals(SettingsState(), repository.state.first())
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/data/ConverterEngineTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/data/ConverterEngineTest.kt b/app/src/test/java/ru/svolf/convertx/data/ConverterEngineTest.kt
+new file mode 100644
+--- /dev/null (date 1789822974539)
++++ b/app/src/test/java/ru/svolf/convertx/data/ConverterEngineTest.kt (date 1789822974539)
+@@ -0,0 +1,65 @@
++package ru.svolf.convertx.data
++
++import kotlinx.coroutines.test.runTest
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.presentation.navigation.AboutRoute
++import ru.svolf.convertx.presentation.navigation.Base64Route
++import ru.svolf.convertx.presentation.navigation.HexRoute
++import ru.svolf.convertx.presentation.navigation.RegexRoute
++import ru.svolf.convertx.presentation.navigation.TextTool
++import ru.svolf.convertx.presentation.navigation.TextToolRoute
++import ru.svolf.convertx.presentation.navigation.UnicodeRoute
++import ru.svolf.convertx.utils.DecoderConst
++import java.util.zip.Adler32
++import java.util.zip.CRC32
++
++class ConverterEngineTest {
++ private val engine = ConverterEngine()
++
++ @Test
++ fun `decoder is selected by route`() {
++ assertEquals(DecoderConst.UNICODE, engine.decoderFor(UnicodeRoute))
++ assertEquals(DecoderConst.BASE64, engine.decoderFor(Base64Route()))
++ assertEquals(DecoderConst.HEX, engine.decoderFor(HexRoute()))
++ assertEquals(-1, engine.decoderFor(RegexRoute))
++ }
++
++ @Test
++ fun `unicode route converts in both directions`() = runTest {
++ assertEquals("\\u0041\\u03a9", engine.convert(UnicodeRoute, "AΩ", fromInput = true, mode = 0))
++ assertEquals("AΩ", engine.convert(UnicodeRoute, "\\u0041\\u03a9", fromInput = false, mode = 0))
++ }
++
++ @Test
++ fun `base64 route clamps mode and converts in both directions`() = runTest {
++ val encoded = engine.convert(Base64Route(), "hello", fromInput = true, mode = 99)
++
++ assertEquals("hello", engine.convert(Base64Route(), encoded, fromInput = false, mode = 99))
++ assertEquals("aGVsbG8=", encoded)
++ }
++
++ @Test
++ fun `hex route supports string and integer modes`() = runTest {
++ assertEquals("Hi", engine.convert(HexRoute(), "4869", fromInput = false, mode = 0))
++ assertEquals("0x0000000000000000000000000000000000004869", engine.convert(HexRoute(), "Hi", fromInput = true, mode = 0))
++ assertEquals("0xff", engine.convert(HexRoute(), "255", fromInput = true, mode = 1))
++ assertEquals("255", engine.convert(HexRoute(), "0xff", fromInput = false, mode = 1))
++ }
++
++ @Test
++ fun `text tools calculate checksums and decode xml`() = runTest {
++ val input = "hello"
++ val adler = Adler32().apply { update(input.toByteArray()) }.value.toString()
++ val crc = CRC32().apply { update(input.toByteArray()) }.value.toString()
++
++ assertEquals(adler, engine.convert(TextToolRoute(TextTool.ADLER32), input, true, 0))
++ assertEquals(crc, engine.convert(TextToolRoute(TextTool.CRC32), input, true, 0))
++ assertEquals("ok", engine.convert(TextToolRoute(TextTool.XML), "<b>ok</b>", true, 0))
++ }
++
++ @Test
++ fun `unsupported routes pass the input through`() = runTest {
++ assertEquals("same", engine.convert(AboutRoute, "same", true, 0))
++ }
++}
+Index: app/src/test/java/ru/svolf/convertx/data/HistoryRepositoryTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/data/HistoryRepositoryTest.kt b/app/src/test/java/ru/svolf/convertx/data/HistoryRepositoryTest.kt
+new file mode 100644
+--- /dev/null (date 1789822974712)
++++ b/app/src/test/java/ru/svolf/convertx/data/HistoryRepositoryTest.kt (date 1789822974712)
+@@ -0,0 +1,56 @@
++package ru.svolf.convertx.data
++
++import kotlinx.coroutines.flow.first
++import kotlinx.coroutines.test.runTest
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Assertions.assertNull
++import org.junit.jupiter.api.Test
++import ru.svolf.convertx.InMemoryHistoryDao
++
++class HistoryRepositoryTest {
++ @Test
++ fun `records are mapped from dao rows`() = runTest {
++ val dao = InMemoryHistoryDao()
++ dao.seed(
++ HistoryRecord(2L, 1, "a", "b", 0).toEntity(),
++ HistoryRecord(1L, 0, "c", "d", 0).toEntity()
++ )
++ val repository = HistoryRepository(dao)
++
++ assertEquals(listOf(2L, 1L), repository.records.first().map { it.id })
++ assertEquals("b", repository.records.first().first().output)
++ }
++
++ @Test
++ fun `save and find preserve every history field`() = runTest {
++ val repository = HistoryRepository(InMemoryHistoryDao())
++ val expected = HistoryRecord(10L, 2, "input", "output", 1)
++
++ repository.save(expected)
++
++ assertEquals(expected, repository.find(10L))
++ }
++
++ @Test
++ fun `delete removes one record and deleteAll clears history`() = runTest {
++ val dao = InMemoryHistoryDao()
++ val repository = HistoryRepository(dao)
++ repository.save(HistoryRecord(1L, 0, "a", "b", 0))
++ repository.save(HistoryRecord(2L, 1, "c", "d", 2))
++
++ repository.delete(1L)
++ assertNull(repository.find(1L))
++ assertEquals(listOf(2L), repository.records.first().map { it.id })
++
++ repository.deleteAll()
++ assertEquals(emptyList(), repository.records.first())
++ }
++
++ private fun HistoryRecord.toEntity() = ru.svolf.convertx.data.entity.HistoryEntity(
++ id = id,
++ decoder = decoder,
++ input = input,
++ output = output,
++ spinnerPosition = spinnerPosition
++ )
++}
+Index: app/src/test/java/ru/svolf/convertx/presentation/viewmodel/RegexViewModelTest.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>package ru.svolf.convertx.presentation.viewmodel\n\nimport org.junit.Assert.assertEquals\nimport org.junit.Assert.assertNotNull\nimport org.junit.Assert.assertTrue\nimport org.junit.Test\n\nclass RegexViewModelTest {\n @Test\n fun `valid expression highlights all matches`() {\n val viewModel = RegexViewModel()\n\n viewModel.setExpression(\"cat\")\n viewModel.setSample(\"cat scatter\")\n\n assertEquals(listOf(0 until 3, 5 until 8), viewModel.state.value.matches)\n assertEquals(\"/g\", viewModel.state.value.flagLabel)\n }\n\n @Test\n fun `invalid expression is exposed as validation error`() {\n val viewModel = RegexViewModel()\n\n viewModel.setExpression(\"[\")\n viewModel.setSample(\"text\")\n\n assertNotNull(viewModel.state.value.error)\n assertTrue(viewModel.state.value.matches.isEmpty())\n }\n}\n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/RegexViewModelTest.kt b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/RegexViewModelTest.kt
+--- a/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/RegexViewModelTest.kt (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/app/src/test/java/ru/svolf/convertx/presentation/viewmodel/RegexViewModelTest.kt (date 1789822437822)
+@@ -1,9 +1,9 @@
+ package ru.svolf.convertx.presentation.viewmodel
+
+-import org.junit.Assert.assertEquals
+-import org.junit.Assert.assertNotNull
+-import org.junit.Assert.assertTrue
+-import org.junit.Test
++import org.junit.jupiter.api.Assertions.assertEquals
++import org.junit.jupiter.api.Assertions.assertNotNull
++import org.junit.jupiter.api.Assertions.assertTrue
++import org.junit.jupiter.api.Test
+
+ class RegexViewModelTest {
+ @Test
+@@ -27,4 +27,37 @@
+ assertNotNull(viewModel.state.value.error)
+ assertTrue(viewModel.state.value.matches.isEmpty())
+ }
++
++ @Test
++ fun `flags change matching behavior and selected state`() {
++ val viewModel = RegexViewModel()
++
++ viewModel.setExpression("cat")
++ viewModel.setSample("CAT cat")
++ viewModel.toggleFlag(0)
++
++ assertEquals(listOf(0 until 3, 4 until 7), viewModel.state.value.matches)
++ assertTrue(viewModel.flagSelected(0))
++ assertEquals("/gi", viewModel.state.value.flagLabel)
++
++ viewModel.toggleFlag(0)
++ assertEquals(listOf(4 until 7), viewModel.state.value.matches)
++ assertEquals("/g", viewModel.state.value.flagLabel)
++ }
++
++ @Test
++ fun `empty expression and clear reset derived values but preserve flags`() {
++ val viewModel = RegexViewModel()
++
++ viewModel.toggleFlag(1)
++ viewModel.setExpression("cat")
++ viewModel.setSample("cat")
++ viewModel.clear()
++
++ assertEquals("", viewModel.state.value.expression)
++ assertEquals("", viewModel.state.value.sample)
++ assertEquals("", viewModel.state.value.result)
++ assertEquals("/gm", viewModel.state.value.flagLabel)
++ assertTrue(viewModel.flagSelected(1))
++ }
+ }
+Index: app/build.gradle
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>plugins {\n alias(libs.plugins.android.application)\n alias(libs.plugins.kotlin.android)\n alias(libs.plugins.kotlin.serialization)\n alias(libs.plugins.ksp)\n alias(libs.plugins.kotlin.compose)\n}\n\ndef Properties versionProps = new Properties()\ndef versionPropsFile = file('version.properties')\nif (!versionPropsFile.canRead()) {\n throw new GradleException('Could not read version.properties!')\n}\nversionProps.load(new FileInputStream(versionPropsFile))\n\nandroid {\n namespace 'ru.svolf.convertx'\n compileSdk libs.versions.compileSdk.get().toInteger()\n\n defaultConfig {\n applicationId 'ru.svolf.convertx'\n minSdk libs.versions.minSdk.get().toInteger()\n targetSdk libs.versions.targetSdk.get().toInteger()\n versionCode versionProps['CACHED_VERSION'].toInteger()\n versionName \"${versionProps['VERSION_MAJOR']}.${versionProps['VERSION_MINOR']}.${versionProps['VERSION_PATCH']}\"\n testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'\n }\n\n buildTypes {\n release {\n minifyEnabled true\n shrinkResources true\n zipAlignEnabled true\n proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'\n }\n debug {\n debuggable true\n minifyEnabled false\n zipAlignEnabled true\n signingConfig signingConfigs.debug\n }\n }\n\n compileOptions {\n sourceCompatibility JavaVersion.VERSION_11\n targetCompatibility JavaVersion.VERSION_11\n }\n\n buildFeatures {\n compose true\n viewBinding false\n }\n}\n\nkotlin {\n compilerOptions {\n jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11\n }\n}\n\ndependencies {\n implementation platform(libs.androidx.compose.bom)\n androidTestImplementation platform(libs.androidx.compose.bom)\n\n implementation libs.androidx.activity.compose\n implementation libs.androidx.compose.foundation\n implementation libs.androidx.compose.material3\n implementation libs.androidx.compose.material.icons.extended\n implementation libs.androidx.compose.runtime\n implementation libs.androidx.compose.ui\n implementation libs.androidx.compose.ui.tooling.preview\n debugImplementation libs.androidx.compose.ui.tooling\n androidTestImplementation libs.androidx.compose.ui.test.junit4\n debugImplementation libs.androidx.compose.ui.test.manifest\n\n implementation libs.androidx.navigation3.runtime\n implementation libs.androidx.navigation3.ui\n implementation libs.androidx.lifecycle.viewmodel.navigation3\n implementation libs.androidx.lifecycle.viewmodel.compose\n implementation libs.androidx.lifecycle.runtime.compose\n implementation libs.androidx.lifecycle.runtime.ktx\n\n implementation libs.androidx.room.runtime\n implementation libs.androidx.room.ktx\n ksp libs.androidx.room.compiler\n\n implementation libs.androidx.datastore.preferences\n implementation libs.dagger\n ksp libs.dagger.compiler\n implementation libs.kotlinx.serialization.json\n\n testImplementation libs.junit\n androidTestImplementation libs.androidx.test.rules\n androidTestImplementation libs.androidx.test.junit\n androidTestImplementation libs.androidx.test.espresso\n}\n\nksp {\n arg('room.schemaLocation', \"$projectDir/schemas\")\n}\n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/build.gradle b/app/build.gradle
+--- a/app/build.gradle (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/app/build.gradle (date 1789821796195)
+@@ -50,6 +50,12 @@
+ compose true
+ viewBinding false
+ }
++
++ testOptions {
++ unitTests.all {
++ useJUnitPlatform()
++ }
++ }
+ }
+
+ kotlin {
+@@ -89,7 +95,10 @@
+ ksp libs.dagger.compiler
+ implementation libs.kotlinx.serialization.json
+
+- testImplementation libs.junit
++ testImplementation libs.junit.jupiter.api
++ testRuntimeOnly libs.junit.jupiter.engine
++ testRuntimeOnly libs.junit.platform.launcher
++ testImplementation libs.kotlinx.coroutines.test
+ androidTestImplementation libs.androidx.test.rules
+ androidTestImplementation libs.androidx.test.junit
+ androidTestImplementation libs.androidx.test.espresso
+Index: app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>package ru.svolf.convertx.utils.algorhitms\n\nimport android.util.Base64\nimport kotlinx.coroutines.coroutineScope\nimport java.math.BigInteger\nimport java.text.Format\nimport java.text.SimpleDateFormat\nimport java.util.Date\nimport java.util.Locale\nimport java.util.regex.Pattern\nimport kotlin.coroutines.resume\nimport kotlin.coroutines.suspendCoroutine\n\n/**\n * Created by Snow Volf on 21.02.2017, 8:42\n */\nobject Decoder {\n\t/**\n\t * Расшифровка Unicode\n\t */\n\tsuspend fun decodeUnicode(tracer: String): String {\n\t\treturn coroutineScope {\n\t\t\tvar aChar: Char\n\t\t\tval len = tracer.length\n\t\t\tval outBuffer = StringBuilder(len)\n\t\t\tvar x = 0\n\t\t\twhile (x < len) {\n\t\t\t\taChar = tracer[x++]\n\t\t\t\tif (aChar == '\\\\') {\n\t\t\t\t\taChar = tracer[x++]\n\t\t\t\t\tif (aChar == 'u') {\n\t\t\t\t\t\t// Читаем массив\n\t\t\t\t\t\tvar value = 0\n\t\t\t\t\t\tfor (i in 0..3) {\n\t\t\t\t\t\t\taChar = tracer[x++]\n\t\t\t\t\t\t\tvalue = when (aChar) {\n\t\t\t\t\t\t\t\t'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> (value shl 4) + aChar.code - '0'.code\n\t\t\t\t\t\t\t\t'a', 'b', 'c', 'd', 'e', 'f' -> (value shl 4) + 10 + aChar.code - 'a'.code\n\t\t\t\t\t\t\t\t'A', 'B', 'C', 'D', 'E', 'F' -> (value shl 4) + 10 + aChar.code - 'A'.code\n\t\t\t\t\t\t\t\telse -> throw IllegalArgumentException(\n\t\t\t\t\t\t\t\t\t\"Malformed \\\\uxxxx encoding.\"\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\toutBuffer.append(value.toChar())\n\t\t\t\t\t} else {\n\t\t\t\t\t\twhen (aChar) {\n\t\t\t\t\t\t\t't' -> aChar = '\\t'\n\t\t\t\t\t\t\t'r' -> aChar = '\\r'\n\t\t\t\t\t\t\t'n' -> aChar =\n\t\t\t\t\t\t\t\t'\\n'\n\t\t\t\t\t\t\t'f' -> aChar = '\\u000c'\n\t\t\t\t\t\t}\n\t\t\t\t\t\toutBuffer.append(aChar)\n\t\t\t\t\t}\n\t\t\t\t} else outBuffer.append(aChar)\n\t\t\t}\n\t\t\toutBuffer.toString()\n\t\t}\n\n\t}\n\n\t/**\n\t * Расшифровка XML\n\t */\n\tsuspend fun unescapeXml(xml: String): String {\n\t\treturn suspendCoroutine {\n\t\t\t//Отыскиваем escape-символы с помощью регулярки\n\t\t\tval xmlEntityRegex = Pattern.compile(\"&(#?)([^;]+);\")\n\t\t\t//Matcher требует StringBuffer вместо StringBuilder\n\t\t\tval unescapedOutput = StringBuffer(xml.length)\n\t\t\tval m = xmlEntityRegex.matcher(xml)\n\t\t\tvar builtinEntities: Map? = null\n\t\t\t//Переменные\n\t\t\tvar entity: String?\n\t\t\tvar hashmark: String?\n\t\t\tvar ent: String\n\t\t\tvar code: Int\n\t\t\t//Пока найдено\n\t\t\twhile (m.find()) {\n ent = m.group(2) ?: \"\"\n\t\t\t\thashmark = m.group(1)\n\t\t\t\tif (hashmark != null && hashmark.isNotEmpty()) {\n\t\t\t\t\tcode = ent.toInt()\n\t\t\t\t\tentity = Character.toString(code.toChar())\n\t\t\t\t} else {\n\t\t\t\t\t//must be a non-numerical entity\n\t\t\t\t\tif (builtinEntities == null) {\n\t\t\t\t\t\tbuiltinEntities = buildBuiltinXMLEntityMap()\n\t\t\t\t\t}\n\t\t\t\t\tentity = builtinEntities[ent]\n\t\t\t\t\tif (entity == null) {\n\t\t\t\t\t\t//Не знаем что за символ -- бросаем его нахрен\n\t\t\t\t\t\tentity = \"&$ent;\"\n\t\t\t\t\t}\n\t\t\t\t}\n m.appendReplacement(unescapedOutput, entity ?: \"\")\n\t\t\t}\n\t\t\tm.appendTail(unescapedOutput)\n\t\t\tunescapedOutput.toString()\n\t\t}\n\t}\n\n private fun buildBuiltinXMLEntityMap(): Map {\n //По HashMap сопоставяем escape и заменяем на нормальные символы\n val entities: MutableMap = HashMap(10)\n entities[\"lt\"] = \"<\"\n entities[\"gt\"] = \">\"\n entities[\"amp\"] = \"&\"\n entities[\"apos\"] = \"'\"\n entities[\"quot\"] = \"\\\"\"\n return entities\n }\n\n\t/**\n\t * Применяем буржуйскую локаль, чтоб с локальной датой не ебаться\n\t * Из-за нее бывают ошибки на некоторых телефонах\n\t * Например на моём Wileyfox Swift с кастомом RR N v.5.8.2 (Lineage OS 14.1) всё нормально,\n\t * но на HTC One Mini 2 с кастомом CyanogenMod 13,\n\t * при попытке получить дату из Timestamp идёт вылет. Такая вот она, локальная дата\n\t */\n\tsuspend fun getNormalDate(time: Long): String {\n\t\treturn coroutineScope {\n\t\t\tval date = Date(time)\n\t\t\tval format: Format = SimpleDateFormat(\"dd.MM.yyyy HH:mm:ss\", Locale.ENGLISH)\n\t\t\tformat.format(date)\n\t\t}\n\t}\n\n\t/*\n\t* Декодирование hex текста\n\t*/\n\tsuspend fun decodeHexString(hexString: String): String {\n\t\treturn coroutineScope {\n\t\t\tval str = StringBuilder()\n\t\t\tvar i = 0\n\t\t\twhile (i < hexString.length) {\n\t\t\t\tval s = hexString.substring(i, i + 2)\n\t\t\t\tval decimal = s.toInt(16)\n\t\t\t\tstr.append(decimal.toChar())\n\t\t\t\ti += 2\n\t\t\t}\n\t\t\tstr.toString()\n\t\t}\n\t}\n\n\t/*\n\t* Кодирование текста в hex\n\t*/\n\tsuspend fun toHexString(text: String): String {\n\t\treturn coroutineScope {\n\t\t\t\"0x\".plus(String.format(\"%040X\", BigInteger(1, text.toByteArray())))\n\t\t}\n\n\t}\n\n\t/*\n\t* Декодирование hex в число (int)\n\t*/\n\tsuspend fun hexToInt(hexadecimal: String): String {\n\t\treturn suspendCoroutine {\n\t\t\ttry {\n\t\t\t\tval test = hexadecimal.toInt(16).toString().replaceFirst(\"0x\", \"\")\n\t\t\t\tit.resume(test)\n\t\t\t} catch (ex: Throwable) {\n\t\t\t\tit.resume(\"Error. Out of range\")\n\t\t\t}\n\t\t}\n\t}\n\n\t/*\n\t * Декодирование int в hex\n\t */\n\tsuspend fun intToHex(decimal: String): String {\n\t\treturn suspendCoroutine {\n\t\t\ttry {\n\t\t\t\tval xdigit = Integer.toHexString(decimal.toInt())\n\t\t\t\tit.resume(\"0x$xdigit\")\n\t\t\t} catch (ex: Throwable) {\n\t\t\t\tit.resume(\"Error. Out of range\")\n\t\t\t}\n\t\t}\n\t}\n\n\tsuspend fun decodeBase64(text: String, mode: Int): String {\n\t\treturn coroutineScope {\n\t\t\tval dBytes = Base64.decode(text.toByteArray(), mode)\n\t\t\tString(dBytes, Charsets.UTF_8)\n\t\t}\n\t}\n\n\tsuspend fun encodeBase64(text: String, mode: Int): String {\n\t\treturn coroutineScope {\n\t\t\tval eBytes = Base64.encode(text.toByteArray(), mode)\n\t\t\tString(eBytes, Charsets.UTF_8)\n\t\t}\n\t}\n\n}\n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt b/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt
+--- a/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt (date 1789822706049)
+@@ -97,7 +97,7 @@
+ m.appendReplacement(unescapedOutput, entity ?: "")
+ }
+ m.appendTail(unescapedOutput)
+- unescapedOutput.toString()
++ it.resume(unescapedOutput.toString())
+ }
+ }
+
+Index: app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt
+IDEA additional info:
+Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
+<+>package ru.svolf.convertx.data\n\nimport android.util.Base64\nimport ru.svolf.convertx.presentation.navigation.AppRoute\nimport ru.svolf.convertx.presentation.navigation.Base64Route\nimport ru.svolf.convertx.presentation.navigation.HexRoute\nimport ru.svolf.convertx.presentation.navigation.TextTool\nimport ru.svolf.convertx.presentation.navigation.TextToolRoute\nimport ru.svolf.convertx.presentation.navigation.UnicodeRoute\nimport ru.svolf.convertx.utils.DecoderConst\nimport ru.svolf.convertx.utils.algorhitms.Decoder\nimport java.util.zip.Adler32\nimport java.util.zip.CRC32\nimport javax.inject.Inject\n\nclass ConverterEngine @Inject constructor() {\n fun decoderFor(route: AppRoute): Int = when (route) {\n UnicodeRoute -> DecoderConst.UNICODE\n is Base64Route -> DecoderConst.BASE64\n is HexRoute -> DecoderConst.HEX\n else -> -1\n }\n\n suspend fun convert(route: AppRoute, value: String, fromInput: Boolean, mode: Int): String =\n when (route) {\n UnicodeRoute -> if (fromInput) {\n value.map { \"\\\\u${it.code.toString(16).padStart(4, '0')}\" }.joinToString(\"\")\n } else Decoder.decodeUnicode(value)\n\n is Base64Route -> {\n val flags = base64Flags[mode.coerceIn(base64Flags.indices)]\n if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64(\n value,\n flags\n )\n }\n\n is HexRoute -> if (mode == 1) {\n if (fromInput) Decoder.intToHex(value) else Decoder.hexToInt(value)\n } else if (fromInput) {\n Decoder.toHexString(value)\n } else Decoder.decodeHexString(value.removePrefix(\"0x\"))\n\n is TextToolRoute -> when (route.tool) {\n TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString()\n TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString()\n TextTool.XML -> Decoder.unescapeXml(value)\n TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong())\n }\n\n else -> value\n }\n\n private companion object {\n val base64Flags = intArrayOf(\n Base64.NO_WRAP, Base64.NO_CLOSE, Base64.NO_PADDING,\n Base64.URL_SAFE, Base64.CRLF, Base64.DEFAULT\n )\n }\n}\n
+Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
+<+>UTF-8
+===================================================================
+diff --git a/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt b/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt
+--- a/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt (revision 26602e0e9728ec2ee094fa07eb44812ffb8d6e90)
++++ b/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt (date 1789822094167)
+@@ -36,7 +36,8 @@
+ }
+
+ is HexRoute -> if (mode == 1) {
+- if (fromInput) Decoder.intToHex(value) else Decoder.hexToInt(value)
++ if (fromInput) Decoder.intToHex(value)
++ else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X"))
+ } else if (fromInput) {
+ Decoder.toHexString(value)
+ } else Decoder.decodeHexString(value.removePrefix("0x"))
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index e7d260c..29ba7be 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -14,25 +14,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
@@ -3395,56 +3378,56 @@
- {
+ "keyToString": {
+ "Android App.app.executor": "Run",
+ "ApkExportedModule": "ConvertX.app",
+ "ExportApk.ApkPathForConvertX.app": "D:\\GitHub\\ConvertX\\app",
+ "PROJECT_TRUSTED_KEY": "true",
+ "RunOnceActivity.OpenProjectViewOnStart": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.cidr.known.project.marker": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "RunOnceActivity.readMode.enableVisualFormatting": "true",
+ "SHARE_PROJECT_CONFIGURATION_FILES": "true",
+ "ScreenRecorder.SavePath": "E:\\UserData\\SVolf\\Desktop\\suka.webm",
+ "ScreenshotViewer.SavePath": "D:/GitHub/ConvertX/screens",
+ "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.appcompat/appcompat/1.6.1/ace9a78b961165396147e8691faa18c1b0e48e20/appcompat-1.6.1-sources.jar!/androidx/appcompat/widget/AppCompatEditText.java_SELECTED": "AppCompatEditText",
+ "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.appcompat/appcompat/1.6.1/ace9a78b961165396147e8691faa18c1b0e48e20/appcompat-1.6.1-sources.jar!/androidx/appcompat/widget/AppCompatTextView.java_SELECTED": "AppCompatTextView",
+ "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/androidx.recyclerview/recyclerview/1.2.1/f0f93e67af3f7417bdd560d5142f6dec4fe629c3/recyclerview-1.2.1-sources.jar!/androidx/recyclerview/widget/RecyclerView.java_SELECTED": "RecyclerView",
+ "android-custom-viewC:/Users/SVolf/.gradle/caches/modules-2/files-2.1/com.google.android.material/material/1.6.1/d4d703885d24c61bc0ec39a9a705969e41d773ef/material-1.6.1-sources.jar!/com/google/android/material/tabs/TabLayout.java_SELECTED": "TabLayout",
+ "android-custom-viewD:/Android/SDK/sources/android-33/android/view/View.java_SELECTED": "View",
+ "android-custom-viewD:/Android/SDK/sources/android-33/android/widget/LinearLayout.java_SELECTED": "LinearLayout",
+ "android-custom-viewD:/Android/SDK/sources/android-33/android/widget/TextView.java_SELECTED": "TextView",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/presentation/screens/regex/ExtendedWebView.kt_SELECTED": "ExtendedWebView",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/presentation/screens/regex/NestedWebView.kt_SELECTED": "NestedWebView",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/regexdragon/ExtendedWebView.kt_SELECTED": "ExtendedWebView",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/ui/CircleImageView.java_SELECTED": "CircleImageView",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/BlurToolbar.java_SELECTED": "BlurToolbar",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/CircleImageView.java_SELECTED": "CircleImageView",
+ "android-custom-viewD:/GitHub/ConvertX/app/src/main/java/ru/svolf/convertx/utils/SwipeEditText.java_SELECTED": "SwipeEditText",
+ "android-custom-viewD:/GitHub/ConvertX/backdrop/src/main/java/com/roacult/backdrop/BackdropLayout.kt_SELECTED": "BackdropLayout",
+ "cf.first.check.clang-format": "false",
+ "cidr.known.project.marker": "true",
+ "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrary": "JUnit4",
+ "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrarySuperClass.JUnit4": "",
+ "com.intellij.testIntegration.createTest.CreateTestDialog.defaultLibrarySuperClass.JUnit5": "",
+ "git-widget-placeholder": "codex/r8-release-optimization-audit",
+ "kotlin-language-version-configured": "true",
+ "last.edited.regexp": "\\u0000\\u009c",
+ "last_directory_selection": "D:/GitHub/ConvertX/app/src/androidTest/java/ru/svolf/convertx/presentation",
+ "last_opened_file_path": "/home/svolf/Projects/ConvertX",
+ "project.structure.last.edited": "Modules",
+ "project.structure.proportion": "0.17",
+ "project.structure.side.proportion": "0.2",
+ "settings.editor.selected.configurable": "preferences.customizations"
},
- "keyToStringList": {
- "ExportApk.BuildVariants": [
- "release"
+ "keyToStringList": {
+ "ExportApk.BuildVariants": [
+ "release"
]
}
-}]]>
+}
@@ -3994,7 +3977,15 @@
1789821293533
-
+
+
+ 1789827478561
+
+
+
+ 1789827478561
+
+
@@ -4122,7 +4113,9 @@
-
+
+
+
diff --git a/README.md b/README.md
index 06b8048..5634875 100644
--- a/README.md
+++ b/README.md
@@ -26,4 +26,35 @@ This app converts strings with commonly used algorithms. API 26+
You can check [releases page](https://github.com/SnowVolf/ConvertX/releases) for latest version
You can also build this app from sources in Android Studio or Intellij IDEA (`Build -> Build Bundles -> Build APK`)
+## Build and upload to Google Drive
+
+The helper script can build an APK and upload it to a Google Drive folder through
+[`rclone`](https://rclone.org/drive/). The uploaded filename contains the app
+version from `app/version.properties`, the upload date, and the build variant:
+`Convert X 2.1.3 (19.09.2026) release.apk`.
+
+Configure an authenticated Google Drive remote once:
+
+```bash
+rclone config
+```
+
+Create a remote named `gdrive` with the Google Drive storage type. Then run:
+
+```bash
+./scripts/build-and-upload-drive.sh
+```
+
+Useful variants:
+
+```bash
+./scripts/build-and-upload-drive.sh --variant debug
+./scripts/build-and-upload-drive.sh --variant both
+./scripts/build-and-upload-drive.sh --folder "Convert X" --remote gdrive
+./scripts/build-and-upload-drive.sh --variant release --no-upload
+```
+
+The default destination is the `Convert X` folder in the configured Drive
+remote. Override it with `--folder` or the `DRIVE_FOLDER` environment variable.
+The script does not store Google credentials in the repository.
diff --git a/app/build.gradle b/app/build.gradle
index 70825dd..32be2d3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -84,7 +84,6 @@ dependencies {
implementation libs.androidx.lifecycle.viewmodel.navigation3
implementation libs.androidx.lifecycle.viewmodel.compose
implementation libs.androidx.lifecycle.runtime.compose
- implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 7b1ad61..3823045 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -11,30 +11,3 @@
-dontwarn com.google.auto.**
-dontwarn com.google.common.**
-dontwarn com.google.j2objc.**
-
-# Keep `Companion` object fields of serializable classes.
-# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
--if @kotlinx.serialization.Serializable class **
--keepclassmembers class <1> {
- static <1>$Companion Companion;
-}
-
-# Keep `serializer()` on companion objects (both default and named) of serializable classes.
--if @kotlinx.serialization.Serializable class ** {
- static **$* *;
-}
--keepclassmembers class <2>$<3> {
- kotlinx.serialization.KSerializer serializer(...);
-}
-
-# Keep `INSTANCE.serializer()` of serializable objects.
--if @kotlinx.serialization.Serializable class ** {
- public static ** INSTANCE;
-}
--keepclassmembers class <1> {
- public static <1> INSTANCE;
- kotlinx.serialization.KSerializer serializer(...);
-}
-
-# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
--keepattributes RuntimeVisibleAnnotations,AnnotationDefault
\ No newline at end of file
diff --git a/scripts/build-and-upload-drive.sh b/scripts/build-and-upload-drive.sh
new file mode 100755
index 0000000..9284982
--- /dev/null
+++ b/scripts/build-and-upload-drive.sh
@@ -0,0 +1,189 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+GRADLEW="$ROOT_DIR/gradlew"
+VERSION_FILE="$ROOT_DIR/app/version.properties"
+
+DRIVE_REMOTE="${DRIVE_REMOTE:-gdrive}"
+DRIVE_FOLDER="${DRIVE_FOLDER:-Convert X}"
+APP_LABEL="${APP_LABEL:-Convert X}"
+BUILD_VARIANT="release"
+DATE_VALUE="$(date '+%d.%m.%Y')"
+DRY_RUN=0
+UPLOAD=1
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/build-and-upload-drive.sh [options]
+
+Build an Android APK and upload it to Google Drive through rclone.
+
+Options:
+ --variant Build variant (default: release)
+ --date Override the date in the uploaded filename
+ --remote rclone remote name (default: gdrive)
+ --folder Google Drive folder path (default: Convert X)
+ --no-upload Build only; do not upload
+ --dry-run Build and print upload commands without uploading
+ -h, --help Show this help
+
+Environment variables with the same names are also supported:
+ DRIVE_REMOTE, DRIVE_FOLDER, APP_LABEL
+EOF
+}
+
+die() {
+ printf 'Error: %s\n' "$*" >&2
+ exit 1
+}
+
+read_version_property() {
+ local key="$1"
+ awk -F= -v key="$key" '$1 == key { sub(/\r$/, "", $2); print $2; exit }' "$VERSION_FILE"
+}
+
+parse_args() {
+ while (($# > 0)); do
+ case "$1" in
+ --variant)
+ (($# >= 2)) || die "--variant requires a value"
+ BUILD_VARIANT="$2"
+ shift 2
+ ;;
+ --date)
+ (($# >= 2)) || die "--date requires a value"
+ DATE_VALUE="$2"
+ shift 2
+ ;;
+ --remote)
+ (($# >= 2)) || die "--remote requires a value"
+ DRIVE_REMOTE="$2"
+ shift 2
+ ;;
+ --folder)
+ (($# >= 2)) || die "--folder requires a value"
+ DRIVE_FOLDER="$2"
+ shift 2
+ ;;
+ --no-upload)
+ UPLOAD=0
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ die "Unknown option: $1"
+ ;;
+ esac
+ done
+}
+
+validate_configuration() {
+ [[ -x "$GRADLEW" ]] || die "Gradle wrapper is not executable: $GRADLEW"
+ [[ -r "$VERSION_FILE" ]] || die "Version file not found: $VERSION_FILE"
+
+ case "$BUILD_VARIANT" in
+ release|debug|both) ;;
+ *) die "Unsupported variant '$BUILD_VARIANT'; use release, debug or both" ;;
+ esac
+
+ [[ "$DATE_VALUE" =~ ^[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]] || \
+ die "Date must have DD.MM.YYYY format: $DATE_VALUE"
+
+ if ((UPLOAD == 1 && DRY_RUN == 0)); then
+ command -v rclone >/dev/null 2>&1 || die \
+ "rclone is required for upload; run 'rclone config' first"
+ rclone listremotes | awk -v expected="${DRIVE_REMOTE}:" '$0 == expected { found = 1 } END { exit !found }' || \
+ die "rclone remote '${DRIVE_REMOTE}:' was not found; run 'rclone config' or use --remote"
+ fi
+}
+
+build_version() {
+ local major minor patch
+ major="$(read_version_property VERSION_MAJOR)"
+ minor="$(read_version_property VERSION_MINOR)"
+ patch="$(read_version_property VERSION_PATCH)"
+ [[ -n "$major" && -n "$minor" && -n "$patch" ]] || \
+ die "Could not read VERSION_MAJOR, VERSION_MINOR and VERSION_PATCH from $VERSION_FILE"
+ printf '%s.%s.%s' "$major" "$minor" "$patch"
+}
+
+build_variants() {
+ local tasks=()
+ case "$BUILD_VARIANT" in
+ release) tasks+=(":app:assembleRelease") ;;
+ debug) tasks+=(":app:assembleDebug") ;;
+ both) tasks+=(":app:assembleDebug" ":app:assembleRelease") ;;
+ esac
+
+ printf 'Building: %s\n' "${tasks[*]}"
+ (cd "$ROOT_DIR" && "$GRADLEW" "${tasks[@]}")
+}
+
+apk_for_variant() {
+ local variant="$1"
+ local output_dir="$ROOT_DIR/app/build/outputs/apk/$variant"
+ local apk
+
+ if [[ "$variant" == release ]]; then
+ for apk in "$output_dir/app-release-unsigned.apk" "$output_dir/app-release.apk"; do
+ if [[ -f "$apk" ]]; then
+ printf '%s' "$apk"
+ return 0
+ fi
+ done
+ else
+ apk="$output_dir/app-$variant.apk"
+ if [[ -f "$apk" ]]; then
+ printf '%s' "$apk"
+ return 0
+ fi
+ fi
+
+ return 1
+}
+
+upload_variant() {
+ local variant="$1"
+ local apk_path output_name destination
+
+ apk_path="$(apk_for_variant "$variant")" || die "APK for $variant was not found"
+ output_name="${APP_LABEL} ${VERSION} (${DATE_VALUE}) ${variant}.apk"
+ destination="${DRIVE_REMOTE}:${DRIVE_FOLDER}/${output_name}"
+
+ printf 'Prepared: %s (%s bytes)\n' "$apk_path" "$(stat -c '%s' "$apk_path")"
+ if ((UPLOAD == 0 || DRY_RUN == 1)); then
+ printf 'Would upload to: %s\n' "$destination"
+ return 0
+ fi
+
+ printf 'Uploading: %s\n' "$output_name"
+ rclone copyto "$apk_path" "$destination" --no-traverse
+ printf 'Uploaded: %s\n' "$destination"
+}
+
+main() {
+ parse_args "$@"
+ validate_configuration
+ VERSION="$(build_version)"
+ build_variants
+
+ case "$BUILD_VARIANT" in
+ release) upload_variant release ;;
+ debug) upload_variant debug ;;
+ both)
+ upload_variant debug
+ upload_variant release
+ ;;
+ esac
+}
+
+main "$@"