From 7696d26fd3c1ec6f584265088cff43cae54bc108 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Dec 2025 23:03:29 +0300 Subject: [PATCH] Set up multiplatform structure --- .idea/artifacts/utils.xml | 8 + .idea/gradle.xml | 3 +- app/build.gradle.kts | 91 -- .../ru/fromchat/ExampleInstrumentedTest.kt | 24 - .../main/java/ru/fromchat/utils/HashUtils.kt | 69 -- app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 2898 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 1772 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 3918 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 5914 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 7778 -> 0 bytes build.gradle.kts | 14 +- {app => composeApp}/.gitignore | 0 composeApp/build.gradle.kts | 146 +++ {app => composeApp}/proguard-rules.pro | 0 .../src/androidMain}/AndroidManifest.xml | 0 .../kotlin}/ru/fromchat/MainActivity.kt | 2 +- .../ru/fromchat/api/AndroidKtorLogger.kt | 16 + .../ru/fromchat/api/ApiClient.android.kt | 13 + .../kotlin/ru/fromchat/core/Logger.android.kt | 21 + .../res/drawable/ic_launcher_background.xml | 0 .../res/drawable/ic_launcher_foreground.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../res/mipmap-hdpi/ic_launcher.webp | 0 .../res/mipmap-hdpi/ic_launcher_round.webp | 0 .../res/mipmap-mdpi/ic_launcher.webp | 0 .../res/mipmap-mdpi/ic_launcher_round.webp | 0 .../res/mipmap-xhdpi/ic_launcher.webp | 0 .../res/mipmap-xhdpi/ic_launcher_round.webp | 0 .../res/mipmap-xxhdpi/ic_launcher.webp | 0 .../res/mipmap-xxhdpi/ic_launcher_round.webp | 0 .../res/mipmap-xxxhdpi/ic_launcher.webp | 0 .../res/mipmap-xxxhdpi/ic_launcher_round.webp | 0 .../src/androidMain}/res/values/colors.xml | 0 .../src/androidMain}/res/values/strings.xml | 0 .../src/androidMain}/res/values/themes.xml | 0 .../src/androidMain}/res/xml/backup_rules.xml | 0 .../res/xml/data_extraction_rules.xml | 0 .../kotlin}/ru/fromchat/Constants.kt | 0 .../kotlin}/ru/fromchat/api/ApiClient.kt | 22 +- .../kotlin}/ru/fromchat/api/Models.kt | 0 .../kotlin}/ru/fromchat/api/Utils.kt | 6 +- .../ru/fromchat/api/WebSocketManager.kt | 26 +- .../kotlin/ru/fromchat/core/Logger.kt | 8 + .../commonMain/kotlin}/ru/fromchat/ui/App.kt | 0 .../kotlin}/ru/fromchat/ui/Components.kt | 0 .../kotlin}/ru/fromchat/ui/Theme.kt | 16 +- .../ru/fromchat/ui/auth/LoginScreen.kt | 4 +- .../ru/fromchat/ui/auth/RegisterScreen.kt | 4 +- .../kotlin}/ru/fromchat/ui/main/ChatsTab.kt | 0 .../kotlin}/ru/fromchat/ui/main/MainScreen.kt | 0 .../kotlin}/ru/fromchat/utils/Generic.kt | 0 .../kotlin}/ru/fromchat/utils/Ktor.kt | 9 +- .../kotlin/ru/fromchat/MainViewController.kt | 6 + .../kotlin/ru/fromchat/api/ApiClient.ios.kt | 13 + .../kotlin/ru/fromchat/core/Logger.ios.kt | 21 + .../test/java/ru/fromchat/ExampleUnitTest.kt | 0 gradle/libs.versions.toml | 89 +- settings.gradle.kts | 26 +- utils/.gitignore | 1 + utils/build.gradle.kts | 66 + utils/src/androidMain/AndroidManifest.xml | 2 + .../components/Components.android.kt | 42 + .../pr0gramm3r101/utils/Adaptive.android.kt | 9 + .../utils/BetterActivityResult.java | 99 ++ .../com/pr0gramm3r101/utils/JobIdManager.kt | 43 + .../pr0gramm3r101/utils/NoParallelExecutor.kt | 18 + .../pr0gramm3r101/utils/SerializableIntent.kt | 489 ++++++++ .../pr0gramm3r101/utils/SimpleAsyncTask.kt | 13 + .../com/pr0gramm3r101/utils/Utils.android.kt | 832 +++++++++++++ .../com/pr0gramm3r101/utils/UtilsLibrary.kt | 14 + .../utils/crypto/Base64.android.kt | 13 + .../utils/crypto/Hmac.android.kt | 15 + .../utils/settings/AndroidSettings.kt | 115 ++ .../utils/settings/AsyncSettings.android.kt | 129 ++ .../utils/settings/DataStoreSingleton.kt | 17 + .../utils/settings/SettingsFactory.android.kt | 5 + .../src/androidMain/res/values-ru/strings.xml | 6 + utils/src/androidMain/res/values/strings.xml | 6 + utils/src/androidMain/res/values/themes.xml | 14 + .../composeResources/drawable/license.xml | 9 + .../drawable/siren_filled.xml | 9 + .../drawable/siren_outlined.xml | 9 + .../pr0gramm3r101/components/Components.kt | 1086 +++++++++++++++++ .../kotlin/com/pr0gramm3r101/ui/Icons.kt | 29 + .../com/pr0gramm3r101/utils/Adaptive.kt | 290 +++++ .../com/pr0gramm3r101/utils/Components.kt | 76 ++ .../kotlin/com/pr0gramm3r101/utils/Utils.kt | 183 +++ .../com/pr0gramm3r101/utils/UtilsLibrary.kt | 5 + .../com/pr0gramm3r101/utils/crypto/Base64.kt | 19 + .../com/pr0gramm3r101/utils/crypto/Hmac.kt | 8 + .../utils/crypto/PasswordHash.kt | 59 + .../utils/settings/AsyncSettings.kt | 37 + .../pr0gramm3r101/utils/settings/Settings.kt | 33 + .../utils/settings/SettingsFactory.kt | 3 + .../utils/storage/AuthStorage.kt | 29 + .../utils/storage/ServerConfigStorage.kt | 55 + .../pr0gramm3r101/utils/storage/Storage.kt | 59 + .../com/pr0gramm3r101/utils/Adaptive.ios.kt | 9 + .../com/pr0gramm3r101/utils/Utils.ios.kt | 25 + .../com/pr0gramm3r101/utils/UtilsLibrary.kt | 5 + .../pr0gramm3r101/utils/crypto/Base64.ios.kt | 23 + .../pr0gramm3r101/utils/crypto/Hmac.ios.kt | 29 + .../utils/settings/AsyncSettings.ios.kt | 99 ++ .../utils/settings/IosSettings.kt | 70 ++ .../utils/settings/SettingsFactory.ios.kt | 3 + 111 files changed, 4586 insertions(+), 280 deletions(-) create mode 100644 .idea/artifacts/utils.xml delete mode 100644 app/build.gradle.kts delete mode 100644 app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt delete mode 100644 app/src/main/java/ru/fromchat/utils/HashUtils.kt delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename {app => composeApp}/.gitignore (100%) create mode 100644 composeApp/build.gradle.kts rename {app => composeApp}/proguard-rules.pro (100%) rename {app/src/main => composeApp/src/androidMain}/AndroidManifest.xml (100%) rename {app/src/main/java => composeApp/src/androidMain/kotlin}/ru/fromchat/MainActivity.kt (99%) create mode 100644 composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt create mode 100644 composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt create mode 100644 composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt rename {app/src/main => composeApp/src/androidMain}/res/drawable/ic_launcher_background.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/drawable/ic_launcher_foreground.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp rename {app/src/main => composeApp/src/androidMain}/res/values/colors.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/values/strings.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/values/themes.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/xml/backup_rules.xml (100%) rename {app/src/main => composeApp/src/androidMain}/res/xml/data_extraction_rules.xml (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/Constants.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/api/ApiClient.kt (85%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/api/Models.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/api/Utils.kt (84%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/api/WebSocketManager.kt (82%) create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/App.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/Components.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/Theme.kt (85%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/auth/LoginScreen.kt (96%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/auth/RegisterScreen.kt (98%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/main/ChatsTab.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/ui/main/MainScreen.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/utils/Generic.kt (100%) rename {app/src/main/java => composeApp/src/commonMain/kotlin}/ru/fromchat/utils/Ktor.kt (91%) create mode 100644 composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt create mode 100644 composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt rename {app => composeApp}/src/test/java/ru/fromchat/ExampleUnitTest.kt (100%) create mode 100644 utils/.gitignore create mode 100644 utils/build.gradle.kts create mode 100644 utils/src/androidMain/AndroidManifest.xml create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt create mode 100644 utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt create mode 100644 utils/src/androidMain/res/values-ru/strings.xml create mode 100644 utils/src/androidMain/res/values/strings.xml create mode 100644 utils/src/androidMain/res/values/themes.xml create mode 100644 utils/src/commonMain/composeResources/drawable/license.xml create mode 100644 utils/src/commonMain/composeResources/drawable/siren_filled.xml create mode 100644 utils/src/commonMain/composeResources/drawable/siren_outlined.xml create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt create mode 100644 utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt create mode 100644 utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt diff --git a/.idea/artifacts/utils.xml b/.idea/artifacts/utils.xml new file mode 100644 index 0000000..4b6a6a6 --- /dev/null +++ b/.idea/artifacts/utils.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/utils/build/libs + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 639c779..031d3d1 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -10,7 +10,8 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index 1d96489..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,91 +0,0 @@ - -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.io.FileInputStream -import java.util.Properties - -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - freeCompilerArgs.add("-Xwarning-level=NOTHING_TO_INLINE:disabled") - } -} - -android { - namespace = "ru.fromchat" - compileSdk = 36 - - defaultConfig { - applicationId = "ru.fromchat" - minSdk = 24 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" - } - - signingConfigs { - create("release") { - val keystoreProperties = Properties().apply { - load(FileInputStream(rootProject.file("keys/keystore.properties"))) - } - storeFile = rootProject.file("keys/release.jks") - keyAlias = "release" - storePassword = keystoreProperties["storePassword"].toString() - keyPassword = keystoreProperties["keyPassword"].toString() - enableV3Signing = true - } - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - signingConfig = signingConfigs.getByName("release") - - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - buildFeatures { - compose = true - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.material.icons.extended) - implementation(libs.kotlinx.datetime) - - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.okhttp) - implementation(libs.ktor.client.websockets) - implementation(libs.ktor.client.logging) - implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.serialization.json) - implementation(libs.kotlinx.serialization.json) - implementation(libs.haze) - - debugImplementation(libs.androidx.ui.tooling) -} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt deleted file mode 100644 index 1bc1fbd..0000000 --- a/app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ru.fromchat - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("ru.fromchat", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/utils/HashUtils.kt b/app/src/main/java/ru/fromchat/utils/HashUtils.kt deleted file mode 100644 index 5e4d378..0000000 --- a/app/src/main/java/ru/fromchat/utils/HashUtils.kt +++ /dev/null @@ -1,69 +0,0 @@ -package ru.fromchat.utils - -import android.util.Base64 -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -object HashUtils { - /** - * Derive authentication secret using HKDF (HMAC-based Key Derivation Function) - * Matches the frontend implementation in deriveAuthSecret - * - * @param username The username - * @param password The plain password - * @return Base64-encoded 32-byte derived secret - */ - fun deriveAuthSecret(username: String, password: String): String { - val salt = "fromchat.user:$username".toByteArray(Charsets.UTF_8) - val ikm = password.toByteArray(Charsets.UTF_8) - val info = "auth-secret".toByteArray(Charsets.UTF_8) - val length = 32 - - val prk = hkdfExtract(salt, ikm) - val okm = hkdfExpand(prk, info, length) - - return Base64.encodeToString(okm, Base64.NO_WRAP) - } - - /** - * HKDF Extract step: PRK = HMAC-Hash(salt, IKM) - */ - private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray { - val mac = Mac.getInstance("HmacSHA256") - val keySpec = SecretKeySpec(salt, "HmacSHA256") - mac.init(keySpec) - return mac.doFinal(ikm) - } - - /** - * HKDF Expand step: OKM = HKDF-Expand(PRK, info, L) - */ - private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { - val blocks = mutableListOf() - var previous = ByteArray(0) - var counter = 1 - - while (blocks.sumOf { it.size } < length) { - val mac = Mac.getInstance("HmacSHA256") - val keySpec = SecretKeySpec(prk, "HmacSHA256") - mac.init(keySpec) - - val input = previous + info + byteArrayOf(counter.toByte()) - previous = mac.doFinal(input) - blocks.add(previous) - counter++ - } - - val result = ByteArray(length) - var offset = 0 - for (block in blocks) { - val toCopy = minOf(block.size, length - offset) - System.arraycopy(block, 0, result, offset, toCopy) - offset += toCopy - if (offset >= length) break - } - - return result - } -} - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2898 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 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070fe34c611c42c0d3ad3013a0dce358be0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s diff --git a/build.gradle.kts b/build.gradle.kts index 5ba8ae0..2a710f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,11 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.kotlin.serialization) apply false + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false } \ No newline at end of file diff --git a/app/.gitignore b/composeApp/.gitignore similarity index 100% rename from app/.gitignore rename to composeApp/.gitignore diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..9976c03 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,146 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + kotlin("plugin.serialization") version "1.9.22" +} + +kotlin { + androidTarget() + + compilerOptions { + freeCompilerArgs.addAll("-Xexpect-actual-classes") + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + androidMain.dependencies { + implementation(compose.preview) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.adaptive.android) + implementation(libs.ktor.client.okhttp) + implementation(libs.slf4j.android) + } + + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.constraintlayout) + implementation(libs.navigation.compose) + implementation(compose.materialIconsExtended) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + implementation(libs.kotlinx.io.core) + + // Ktor + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.serialization.kotlinx.json) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.client.logging) + + // Datetime + implementation(libs.kotlinx.datetime) + + implementation(project(":utils")) + } + + iosMain.dependencies { + implementation(libs.jetbrains.kotlinx.io.bytestring) + implementation(libs.jetbrains.kotlinx.coroutines.core) + implementation(libs.ktor.client.darwin) + } + } +} + +android { + namespace = "ru.fromchat" + compileSdk = 36 + + defaultConfig { + applicationId = "ru.fromchat" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + signingConfigs { + create("release") { + val keystoreProperties = Properties().apply { + load(FileInputStream(rootProject.file("keys/keystore.properties"))) + } + storeFile = rootProject.file("keys/release.jks") + keyAlias = "release" + storePassword = keystoreProperties["storePassword"].toString() + keyPassword = keystoreProperties["keyPassword"].toString() + enableV3Signing = true + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + signingConfig = signingConfigs.getByName("release") + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + debugImplementation(compose.uiTooling) +} + +compose.resources { + publicResClass = false + packageOfResClass = "ru.fromchat" + generateResClass = auto +} + +tasks.register("generateResourceAccessors") { + dependsOn( + *( + tasks.filter { + it.name.startsWith("generateResourceAccessors") && + !it.name.matches("^(:${project.name})?generateResourceAccessors$".toRegex()) + }.toTypedArray() + ) + ) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/composeApp/proguard-rules.pro similarity index 100% rename from app/proguard-rules.pro rename to composeApp/proguard-rules.pro diff --git a/app/src/main/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml similarity index 100% rename from app/src/main/AndroidManifest.xml rename to composeApp/src/androidMain/AndroidManifest.xml diff --git a/app/src/main/java/ru/fromchat/MainActivity.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/MainActivity.kt similarity index 99% rename from app/src/main/java/ru/fromchat/MainActivity.kt rename to composeApp/src/androidMain/kotlin/ru/fromchat/MainActivity.kt index 1506a1c..dd3140b 100644 --- a/app/src/main/java/ru/fromchat/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/ru/fromchat/MainActivity.kt @@ -14,4 +14,4 @@ class MainActivity : ComponentActivity() { App() } } -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt new file mode 100644 index 0000000..0c4aa07 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt @@ -0,0 +1,16 @@ +package ru.fromchat.api + +import android.util.Log +import io.ktor.client.plugins.logging.Logger + +/** + * Custom Android logger for Ktor that uses Android's Log system + */ +object AndroidKtorLogger : Logger { + private const val TAG = "Ktor" + + override fun log(message: String) { + Log.d(TAG, message) + } +} + diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt new file mode 100644 index 0000000..dbc758c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt @@ -0,0 +1,13 @@ +package ru.fromchat.api + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.engine.okhttp.OkHttpConfig + +actual fun createPlatformHttpClient( + block: HttpClientConfig<*>.() -> Unit +): HttpClient { + @Suppress("UNCHECKED_CAST") + return HttpClient(OkHttp, block as HttpClientConfig.() -> Unit) +} diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt new file mode 100644 index 0000000..9f132a3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt @@ -0,0 +1,21 @@ +package ru.fromchat.core + +import android.util.Log + +actual object Logger { + actual fun d(tag: String, message: String, throwable: Throwable?) { + Log.d(tag, message, throwable) + } + + actual fun i(tag: String, message: String, throwable: Throwable?) { + Log.i(tag, message, throwable) + } + + actual fun w(tag: String, message: String, throwable: Throwable?) { + Log.w(tag, message, throwable) + } + + actual fun e(tag: String, message: String, throwable: Throwable?) { + Log.e(tag, message, throwable) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to composeApp/src/androidMain/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_foreground.xml rename to composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml similarity index 100% rename from app/src/main/res/values/colors.xml rename to composeApp/src/androidMain/res/values/colors.xml diff --git a/app/src/main/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml similarity index 100% rename from app/src/main/res/values/strings.xml rename to composeApp/src/androidMain/res/values/strings.xml diff --git a/app/src/main/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml similarity index 100% rename from app/src/main/res/values/themes.xml rename to composeApp/src/androidMain/res/values/themes.xml diff --git a/app/src/main/res/xml/backup_rules.xml b/composeApp/src/androidMain/res/xml/backup_rules.xml similarity index 100% rename from app/src/main/res/xml/backup_rules.xml rename to composeApp/src/androidMain/res/xml/backup_rules.xml diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/composeApp/src/androidMain/res/xml/data_extraction_rules.xml similarity index 100% rename from app/src/main/res/xml/data_extraction_rules.xml rename to composeApp/src/androidMain/res/xml/data_extraction_rules.xml diff --git a/app/src/main/java/ru/fromchat/Constants.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/Constants.kt similarity index 100% rename from app/src/main/java/ru/fromchat/Constants.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/Constants.kt diff --git a/app/src/main/java/ru/fromchat/api/ApiClient.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt similarity index 85% rename from app/src/main/java/ru/fromchat/api/ApiClient.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 0edf560..66dc638 100644 --- a/app/src/main/java/ru/fromchat/api/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -1,12 +1,12 @@ package ru.fromchat.api -import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.SIMPLE import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.bearerAuth import io.ktor.client.request.delete @@ -20,6 +20,15 @@ import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import ru.fromchat.API_HOST import ru.fromchat.utils.failOnError +import kotlin.concurrent.Volatile + +/** + * Creates a platform-specific HTTP client that supports WebSockets + * The config block is applied to configure plugins like WebSockets, JSON, etc. + */ +expect fun createPlatformHttpClient( + block: io.ktor.client.HttpClientConfig<*>.() -> Unit = {} +): HttpClient object ApiClient { val json = Json { @@ -28,19 +37,14 @@ object ApiClient { encodeDefaults = true } - val http = HttpClient { + val http = createPlatformHttpClient { install(ContentNegotiation) { json(json) } install(Logging) { + logger = Logger.SIMPLE level = LogLevel.INFO - logger = object : Logger { - override fun log(message: String) { - // Replace with Logcat if needed - println(message) - } - } } install(WebSockets) @@ -58,7 +62,7 @@ object ApiClient { http .post("$API_HOST/api/login") { contentType(ContentType.Application.Json) - setBody(request.also { Log.d("ApiClient", "Login request: $it") }) + setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") }) } .failOnError() .body() diff --git a/app/src/main/java/ru/fromchat/api/Models.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt similarity index 100% rename from app/src/main/java/ru/fromchat/api/Models.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt diff --git a/app/src/main/java/ru/fromchat/api/Utils.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Utils.kt similarity index 84% rename from app/src/main/java/ru/fromchat/api/Utils.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/Utils.kt index 34948c0..48b5ec2 100644 --- a/app/src/main/java/ru/fromchat/api/Utils.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Utils.kt @@ -1,6 +1,6 @@ package ru.fromchat.api -import android.util.Log +import ru.fromchat.core.Logger import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException @@ -20,12 +20,12 @@ suspend inline fun apiRequest( "Unexpected error" } - Log.e("API", "API request failed: $message, exception:", e) + Logger.e("API", "API request failed: $message", e) onError(message, e) return Result.failure(e) } catch (e: Exception) { - Log.e("API", "API request failed:", e) + Logger.e("API", "API request failed: ${e.message}", e) onError("Unexpected error", e) return Result.failure(e) } diff --git a/app/src/main/java/ru/fromchat/api/WebSocketManager.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt similarity index 82% rename from app/src/main/java/ru/fromchat/api/WebSocketManager.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt index 6a1503e..e0916be 100644 --- a/app/src/main/java/ru/fromchat/api/WebSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt @@ -1,6 +1,6 @@ package ru.fromchat.api -import android.util.Log +import ru.fromchat.core.Logger import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.request.url @@ -45,7 +45,7 @@ object WebSocketManager { @Volatile private var session: DefaultClientWebSocketSession? = null fun connect() { - Log.d("WebSocketManager", "Connecting to WebSocket") + Logger.d("WebSocketManager", "Connecting to WebSocket") if (connecting) return connecting = true @@ -61,27 +61,27 @@ object WebSocketManager { session = this connecting = false - Log.d("WebSocketManager", "WebSocket connected") + Logger.d("WebSocketManager", "WebSocket connected") for (frame in incoming) { val text = (frame as? Frame.Text)?.readText() ?: continue - Log.d("WebSocketManager", "Received payload: $text") + Logger.d("WebSocketManager", "Received payload: $text") try { val msg = json.decodeFromString(text) globalHandlers.forEach { it(msg) } _messages.emit(msg) } catch (e: Throwable) { - Log.w("WebSocketManager", "Received malformed payload:", e) + Logger.w("WebSocketManager", "Received malformed payload: ${e.message}", e) // ignore malformed } } } } catch (e: Throwable) { - Log.w("WebSocketManager", "An error occurred:", e) + Logger.w("WebSocketManager", "An error occurred: ${e.message}", e) connecting = false session = null delay(3000) } finally { - Log.w("WebSocketManager", "WebSocket disconnected") + Logger.w("WebSocketManager", "WebSocket disconnected") session = null connecting = false } @@ -95,24 +95,24 @@ object WebSocketManager { try { session.send(Frame.Text(json.encodeToString(message))) } catch (e: Exception) { - Log.e("WebSocketManager", "Failed to send message", e) + Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e) throw e } } else { - Log.w("WebSocketManager", "Cannot send message: no active session") + Logger.w("WebSocketManager", "Cannot send message: no active session") throw IllegalStateException("No active WebSocket session") } } @OptIn(DelicateCoroutinesApi::class) suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? { - Log.d("WebSocketManager", "WebSocket request: $message") + Logger.d("WebSocketManager", "WebSocket request: $message") var handler: ((WebSocketMessage) -> Unit)? = null return try { // Check if we have a valid session before sending if (session == null) { - Log.w("WebSocketManager", "No active WebSocket session") + Logger.w("WebSocketManager", "No active WebSocket session") return null } @@ -130,10 +130,10 @@ object WebSocketManager { } } } catch (_: TimeoutCancellationException) { - Log.w("WebSocketManager", "Request timed out") + Logger.w("WebSocketManager", "Request timed out") null } catch (e: Exception) { - Log.e("WebSocketManager", "Request failed", e) + Logger.e("WebSocketManager", "Request failed: ${e.message}", e) null } finally { handler?.let { removeGlobalMessageHandler(it) } diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt new file mode 100644 index 0000000..35a0c07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt @@ -0,0 +1,8 @@ +package ru.fromchat.core + +expect object Logger { + fun d(tag: String, message: String, throwable: Throwable? = null) + fun i(tag: String, message: String, throwable: Throwable? = null) + fun w(tag: String, message: String, throwable: Throwable? = null) + fun e(tag: String, message: String, throwable: Throwable? = null) +} diff --git a/app/src/main/java/ru/fromchat/ui/App.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt similarity index 100% rename from app/src/main/java/ru/fromchat/ui/App.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt diff --git a/app/src/main/java/ru/fromchat/ui/Components.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Components.kt similarity index 100% rename from app/src/main/java/ru/fromchat/ui/Components.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/Components.kt diff --git a/app/src/main/java/ru/fromchat/ui/Theme.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt similarity index 85% rename from app/src/main/java/ru/fromchat/ui/Theme.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt index 940808d..898789b 100644 --- a/app/src/main/java/ru/fromchat/ui/Theme.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt @@ -1,15 +1,11 @@ package ru.fromchat.ui -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( primary = Color(0xFFDBB9F9), @@ -90,18 +86,10 @@ private val LightColorScheme = lightColorScheme( @Composable fun FromChatTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, // Disabled for multiplatform compatibility content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, diff --git a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt similarity index 96% rename from app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt index 639a1dd..b0c1b54 100644 --- a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt @@ -33,7 +33,7 @@ import ru.fromchat.api.ApiClient import ru.fromchat.api.LoginRequest import ru.fromchat.api.apiRequest import ru.fromchat.ui.RowHeader -import ru.fromchat.utils.HashUtils +import com.pr0gramm3r101.utils.crypto.deriveAuthSecret @Composable fun LoginScreen( @@ -98,7 +98,7 @@ fun LoginScreen( // Derive auth secret before sending (matches frontend implementation) scope.launch { - val derived = HashUtils.deriveAuthSecret(username.trim(), password.trim()) + val derived = deriveAuthSecret(username.trim(), password.trim()) apiRequest( onError = { message, _ -> diff --git a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt similarity index 98% rename from app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt index 9f2cef4..4d33f9e 100644 --- a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt @@ -37,7 +37,7 @@ import ru.fromchat.api.RegisterRequest import ru.fromchat.api.apiRequest import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.RowHeader -import ru.fromchat.utils.HashUtils +import com.pr0gramm3r101.utils.crypto.deriveAuthSecret @Composable fun RegisterScreen( @@ -148,7 +148,7 @@ fun RegisterScreen( // Derive auth secret before sending (matches frontend implementation) scope.launch { - val derived = HashUtils.deriveAuthSecret(username.trim(), password) + val derived = deriveAuthSecret(username.trim(), password) apiRequest( onError = { message, _ -> diff --git a/app/src/main/java/ru/fromchat/ui/main/ChatsTab.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt similarity index 100% rename from app/src/main/java/ru/fromchat/ui/main/ChatsTab.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt diff --git a/app/src/main/java/ru/fromchat/ui/main/MainScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt similarity index 100% rename from app/src/main/java/ru/fromchat/ui/main/MainScreen.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt diff --git a/app/src/main/java/ru/fromchat/utils/Generic.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt similarity index 100% rename from app/src/main/java/ru/fromchat/utils/Generic.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt diff --git a/app/src/main/java/ru/fromchat/utils/Ktor.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt similarity index 91% rename from app/src/main/java/ru/fromchat/utils/Ktor.kt rename to composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt index cb2c90c..01fbd5d 100644 --- a/app/src/main/java/ru/fromchat/utils/Ktor.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt @@ -7,11 +7,10 @@ import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.ResponseException import io.ktor.client.plugins.ServerResponseException import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.logging.DEFAULT import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.logging.LoggingConfig +import io.ktor.client.plugins.logging.SIMPLE import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.serialization.Configuration @@ -56,10 +55,10 @@ inline fun HttpClientConfig<*>.defaultRequest( /** * Installs [Logging] in a Ktor client config. - * @param settings Lambda to configure the [LoggingConfig]. + * @param settings Lambda to configure the [Logging.Config]. */ inline fun HttpClientConfig<*>.logging( - crossinline settings: LoggingConfig.() -> Unit + crossinline settings: Logging.Config.() -> Unit ) = install(Logging) { settings() } @@ -68,7 +67,7 @@ inline fun HttpClientConfig<*>.logging( * Installs [Logging] in a Ktor client config with default settings (all logs). */ inline fun HttpClientConfig<*>.logging() = logging { - logger = Logger.DEFAULT + logger = Logger.SIMPLE level = LogLevel.ALL } diff --git a/composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt b/composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt new file mode 100644 index 0000000..ee61d3f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt @@ -0,0 +1,6 @@ +package ru.fromchat + +import ru.fromchat.ui.App +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { App() } diff --git a/composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt new file mode 100644 index 0000000..1b20225 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt @@ -0,0 +1,13 @@ +package ru.fromchat.api + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.engine.darwin.DarwinClientEngineConfig + +actual fun createPlatformHttpClient( + block: HttpClientConfig<*>.() -> Unit +): HttpClient { + @Suppress("UNCHECKED_CAST") + return HttpClient(Darwin, block as HttpClientConfig.() -> Unit) +} diff --git a/composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt b/composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt new file mode 100644 index 0000000..8386ff3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt @@ -0,0 +1,21 @@ +package ru.fromchat.core + +import platform.Foundation.NSLog + +actual object Logger { + actual fun d(tag: String, message: String, throwable: Throwable?) { + NSLog("DEBUG: [%s] %s %s", tag, message, throwable?.message ?: "") + } + + actual fun i(tag: String, message: String, throwable: Throwable?) { + NSLog("INFO: [%s] %s %s", tag, message, throwable?.message ?: "") + } + + actual fun w(tag: String, message: String, throwable: Throwable?) { + NSLog("WARN: [%s] %s %s", tag, message, throwable?.message ?: "") + } + + actual fun e(tag: String, message: String, throwable: Throwable?) { + NSLog("ERROR: [%s] %s %s", tag, message, throwable?.message ?: "") + } +} diff --git a/app/src/test/java/ru/fromchat/ExampleUnitTest.kt b/composeApp/src/test/java/ru/fromchat/ExampleUnitTest.kt similarity index 100% rename from app/src/test/java/ru/fromchat/ExampleUnitTest.kt rename to composeApp/src/test/java/ru/fromchat/ExampleUnitTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b69757a..4e23a8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,43 +1,66 @@ [versions] agp = "8.13.1" +androidx-activityCompose = "1.12.0" +androidx-appcompat = "1.7.1" +androidx-core-ktx = "1.17.0" +androidx-lifecycle = "2.9.6" +compose-multiplatform = "1.9.3" +constraintlayout = "0.6.1-shaded" +coreSplashscreen = "1.2.0" kotlin = "2.2.21" -coreKtx = "1.17.0" -kotlinxDatetime = "0.7.1" -lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.0" -composeBom = "2025.11.01" -materialIconsExtended = "1.7.8" -navigationCompose = "2.9.6" -ktor = "3.3.3" +adaptiveAndroid = "1.2.0" +kotlinStdlib = "2.2.21" +biometric = "1.4.0-alpha04" +gson = "2.13.2" +kotlinxIoBytestring = "0.8.2" +kotlinxCoroutinesCore = "1.10.2" +kotlinxIoCore = "0.8.2" kotlinxSerializationJson = "1.9.0" -compose = "1.9.5" -material3 = "1.5.0-alpha09" -haze = "1.7.1" +material = "1.13.0" +activityKtx = "1.12.0" +navigationCompose = "2.9.1" +datastore = "1.2.0" +ktor = "2.3.12" +slf4j = "1.7.36" +kotlinxDatetime = "0.7.1" [libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } -kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } -ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } -ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } -ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } -ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } -ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" } +jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +jetbrains-kotlinx-io-bytestring = { module = "org.jetbrains.kotlinx:kotlinx-io-bytestring", version.ref = "kotlinxIoBytestring" } +androidx-adaptive-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-android", version.ref = "adaptiveAndroid" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } +biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } -haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } [plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 119a480..6b8db56 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,24 +1,34 @@ +@file:Suppress("UnstableApiUsage") + +rootProject.name = "FromChat" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + pluginManagement { repositories { google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") } } mavenCentral() gradlePluginPortal() } } + dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - google() + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } mavenCentral() } } -rootProject.name = "FromChat" -include(":app") +include(":composeApp", ":utils") \ No newline at end of file diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/utils/build.gradle.kts b/utils/build.gradle.kts new file mode 100644 index 0000000..c9cd332 --- /dev/null +++ b/utils/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xexpect-actual-classes") + } + + androidLibrary { + namespace = "com.pr0gramm3r101.utils" + compileSdk = 36 + minSdk = 24 + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "utils" + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlin.stdlib) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(libs.constraintlayout) + implementation(compose.materialIconsExtended) + implementation(libs.jetbrains.kotlinx.coroutines.core) + } + + androidMain.dependencies { + implementation(libs.androidx.adaptive.android) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.biometric) + implementation(libs.gson) + + implementation(libs.androidx.adaptive.android) + implementation(libs.datastore.preferences) + implementation(libs.datastore.core) + } + + iosMain.dependencies { + // nothing + } + } +} + +compose.resources { + publicResClass = true + packageOfResClass = "com.pr0gramm3r101.utils" + generateResClass = auto +} \ No newline at end of file diff --git a/utils/src/androidMain/AndroidManifest.xml b/utils/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..1d26c87 --- /dev/null +++ b/utils/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt new file mode 100644 index 0000000..c4c861a --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt @@ -0,0 +1,42 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.pr0gramm3r101.components + +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.asAndroidColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.viewinterop.AndroidView +import com.pr0gramm3r101.utils.asAndroidScaleType + +@Composable +inline fun Mipmap( + @DrawableRes id: Int, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null +) { + val update: (ImageView) -> Unit = { + it.setImageResource(id) + it.contentDescription = contentDescription + it.alpha = alpha + it.colorFilter = colorFilter?.asAndroidColorFilter() + it.scaleType = contentScale.asAndroidScaleType() + } + + AndroidView( + modifier = modifier, + factory = { + ImageView(it).apply { + update(this) + } + }, + update = update + ) +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt new file mode 100644 index 0000000..3bce990 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt @@ -0,0 +1,9 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.pr0gramm3r101.utils + +import androidx.compose.material3.adaptive.currentWindowSize +import androidx.compose.runtime.Composable + +@Composable +actual inline fun currentWindowSize() = currentWindowSize() \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java new file mode 100644 index 0000000..db1aeff --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java @@ -0,0 +1,99 @@ +package com.pr0gramm3r101.utils; + +import android.content.Intent; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultCaller; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContract; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityOptionsCompat; + +public final class BetterActivityResult { + /** + * Register activity result using a {@link ActivityResultContract} and an in-place activity result callback like + * the default approach. You can still customise callback using {@link #launch(Object, OnActivityResult)}. + */ + @NonNull + public static BetterActivityResult registerForActivityResult( + @NonNull ActivityResultCaller caller, + @NonNull ActivityResultContract contract, + @Nullable OnActivityResult onActivityResult) { + return new BetterActivityResult<>(caller, contract, onActivityResult); + } + + /** + * Same as {@link #registerForActivityResult(ActivityResultCaller, ActivityResultContract, OnActivityResult)} except + * the last argument is set to {@code null}. + */ + @NonNull + public static BetterActivityResult registerForActivityResult( + @NonNull ActivityResultCaller caller, + @NonNull ActivityResultContract contract) { + return registerForActivityResult(caller, contract, null); + } + + /** + * Specialised method for launching new activities. + */ + @NonNull + public static BetterActivityResult registerActivityForResult( + @NonNull ActivityResultCaller caller) { + return registerForActivityResult(caller, new ActivityResultContracts.StartActivityForResult()); + } + + /** + * Callback interface + */ + @FunctionalInterface + public interface OnActivityResult { + /** + * Called after receiving a result from the target activity + */ + void onActivityResult(O result); + } + + private final ActivityResultLauncher launcher; + @Nullable + private OnActivityResult onActivityResult; + + private BetterActivityResult(@NonNull ActivityResultCaller caller, + @NonNull ActivityResultContract contract, + @Nullable OnActivityResult onActivityResult) { + this.onActivityResult = onActivityResult; + this.launcher = caller.registerForActivityResult(contract, this::callOnActivityResult); + } + + /** + * Launch activity, same as {@link ActivityResultLauncher#launch(Object)} except that it allows a callback + * executed after receiving a result from the target activity. + */ + public void launch(I input, @Nullable OnActivityResult onActivityResult) { + this.onActivityResult = onActivityResult; + launcher.launch(input); + } + + public void launch(I input, ActivityOptionsCompat options, @Nullable OnActivityResult onActivityResult) { + this.onActivityResult = onActivityResult; + launcher.launch(input, options); + } + + /** + * Same as {@link #launch(Object, OnActivityResult)} with last parameter set to {@code null}. + * @noinspection unused + */ + public void launch(I input) { + launch(input, (OnActivityResult) null); + } + + /** @noinspection unused*/ + public void launch(I input, ActivityOptionsCompat options) { + launch(input, options, null); + } + + private void callOnActivityResult(R result) { + if (onActivityResult != null) onActivityResult.onActivityResult(result); + } +} diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt new file mode 100644 index 0000000..df0e8b7 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt @@ -0,0 +1,43 @@ +package com.pr0gramm3r101.utils + +import androidx.annotation.IntDef +import org.jetbrains.annotations.Contract + + +object JobIdManager { + const val JOB_TYPE_CHANNEL_PROGRAMS: Int = 1 + const val JOB_TYPE_CHANNEL_METADATA: Int = 2 + const val JOB_TYPE_CHANNEL_DELETION: Int = 3 + const val JOB_TYPE_CHANNEL_LOGGER: Int = 4 + + const val JOB_TYPE_USER_PREFS: Int = 11 + const val JOB_TYPE_USER_BEHAVIOR: Int = 21 + + //16-1 for short. Adjust per your needs + private const val JOB_TYPE_SHIFTS = 15 + + @Contract(pure = true) + fun getJobId(@JobType jobType: Int, objectId: Int): Int { + if (objectId > 0 && objectId < (1 shl JOB_TYPE_SHIFTS)) { + return (jobType shl JOB_TYPE_SHIFTS) + objectId + } else { + throw IllegalArgumentException(String.format( + "objectId %s must be between %s and %s", + objectId, 0, (1 shl JOB_TYPE_SHIFTS) + )) + } + } + + @IntDef( + value = [ + JOB_TYPE_CHANNEL_PROGRAMS, + JOB_TYPE_CHANNEL_METADATA, + JOB_TYPE_CHANNEL_DELETION, + JOB_TYPE_CHANNEL_LOGGER, + JOB_TYPE_USER_PREFS, + JOB_TYPE_USER_BEHAVIOR + ] + ) + @Retention(AnnotationRetention.SOURCE) + private annotation class JobType +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt new file mode 100644 index 0000000..6ada166 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt @@ -0,0 +1,18 @@ +package com.pr0gramm3r101.utils + +import java.util.concurrent.Executor + +@Suppress("MemberVisibilityCanBePrivate") +class NoParallelExecutor: Executor { + val isRunning get() = thread != null + var thread: Thread? = null + + override fun execute(command: Runnable) { + if (!isRunning) { + thread = async { + command.run() + thread = null + } + } + } +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt new file mode 100644 index 0000000..1fb0dab --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt @@ -0,0 +1,489 @@ +@file:Suppress("MemberVisibilityCanBePrivate") + +package com.pr0gramm3r101.utils + +import android.content.ClipData +import android.content.ComponentName +import android.content.Intent +import android.graphics.Rect +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.os.PersistableBundle +import java.io.InputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.OutputStream +import java.io.Serial +import java.io.Serializable + +fun Intent.serialize(outputStream: OutputStream) = SerializableIntent.serialize(this, outputStream) +fun Intent.toSerializableIntent() = SerializableIntent(this) + +class SerializableIntent(): Intent(), Serializable { + companion object { + @Serial + const val serialVersionUID = 1241895592857245245L + + fun deserialize(inputStream: InputStream): Intent { + ObjectInputStream(inputStream).use { + val obj = it.readObject() as SerializableIntent + return obj.toIntent() + } + } + + fun serialize(intent: Intent, outputStream: OutputStream) { + SerializableIntent(intent).serialize(outputStream) + } + } + + constructor(intent: Intent): this() { + _data = intent.data + _package = intent.`package` + _flags = intent.flags + _type = intent.type + _action = intent.action + _categories.addAll(intent.categories) + _component = intent.component + _extras = intent.extras + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + _identifier = intent.identifier + } + _sourceBounds = intent.sourceBounds + } + + @Transient + var _data: Uri? = null + private var __data: String? = null + var _package: String? = null + var _flags: Int = 0 + var _type: String? = null + val _scheme get() = _data?.scheme + var _action: String? = null + val _categories = mutableSetOf() + @Transient + var _component: ComponentName? = null + private var __component_packageName: String? = null + private var __component_className: String? = null + @Transient + var _extras: Bundle? = null + private var __extras_persistableBundle: PersistableBundle? = null + var _identifier: String? = null + @Transient + var _sourceBounds: Rect? = null + private var __sourceBounds_top: Int? = null + private var __sourceBounds_bottom: Int? = null + private var __sourceBounds_left: Int? = null + private var __sourceBounds_right: Int? = null + + fun prepareSerialize() { + __data = _data?.toString() + __extras_persistableBundle = _extras?.toPersistableBundle() + __component_packageName = _component?.packageName + __component_className = _component?.className + __sourceBounds_top = _sourceBounds?.top + __sourceBounds_bottom = _sourceBounds?.bottom + __sourceBounds_left = _sourceBounds?.left + __sourceBounds_right = _sourceBounds?.right + } + + fun serialize(outputStream: OutputStream) { + prepareSerialize() + ObjectOutputStream(outputStream).use { + it.writeObject(this@SerializableIntent) + } + } + + fun toIntent(): Intent { + val intent = Intent() + intent.setDataAndType(_data, _type) + intent.`package` = _package + intent.flags = _flags + intent.action = _action + _categories.forEach { + intent.addCategory(it) + } + intent.component = _component + _extras?.let { intent.putExtras(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + intent.identifier = _identifier + } + _sourceBounds = Rect( + __sourceBounds_left?: 0, + __sourceBounds_top?: 0, + __sourceBounds_right?: 0, + __sourceBounds_bottom?: 0 + ) + return intent + } + + // OVERRIDES + override fun getData() = _data + override fun setData(uri: Uri?): Intent { + this._data = uri + return this + } + + override fun getPackage() = _package + override fun setPackage(pkg: String?): Intent { + this._package = pkg + return this + } + override fun getFlags() = _flags + override fun setFlags(flags: Int): Intent { + this._flags = flags + return this + } + + override fun addFlags(flags: Int): Intent { + this._flags = this._flags or flags + return this + } + + override fun getType() = _type + override fun setType(type: String?): Intent { + this._type = type + return this + } + + override fun getScheme() = _scheme + + override fun getAction() = _action + override fun setAction(action: String?): Intent { + this._action = action + return this + } + + override fun addCategory(category: String): Intent { + _categories.add(category) + return this + } + override fun getCategories() = _categories + override fun hasCategory(category: String) = _categories.contains(category) + override fun removeCategory(category: String) { + _categories.remove(category) + } + + override fun getClipData() = throw UnsupportedOperationException("Not supported in SerializableIntent") + override fun setClipData(clipData: ClipData?) = throw UnsupportedOperationException("Not supported in SerializableIntent") + + override fun getComponent(): ComponentName? = _component + override fun setComponent(component: ComponentName?): Intent { + this._component = component + return this + } + + override fun getDataString() = _data?.toString() + + override fun getExtras() = _extras + override fun putExtras(extras: Bundle): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putAll(extras) + return this + } + override fun putExtras(src: Intent): Intent { + if (src.extras != null) { + putExtras(src.extras!!) + } + return this + } + override fun putExtra(name: String, value: Array?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putCharSequenceArray(name, value) + return this + } + override fun putExtra(name: String, value: Array?): Intent { + return this // Stub! + } + override fun putExtra(name: String, value: Array?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putStringArray(name, value) + return this + } + override fun putExtra(name: String, value: Boolean): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putBoolean(name, value) + return this + } + override fun putExtra(name: String, value: BooleanArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putBooleanArray(name, value) + return this + } + override fun putExtra(name: String, value: Bundle?): Intent { + return this // Stub! + } + override fun putExtra(name: String, value: Byte): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putByte(name, value) + return this + } + override fun putExtra(name: String, value: ByteArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putByteArray(name, value) + return this + } + override fun putExtra(name: String?, value: Char): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putChar(name, value) + return this + } + override fun putExtra(name: String, value: CharArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putCharArray(name, value) + return this + } + override fun putExtra(name: String, value: CharSequence?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putCharSequence(name, value) + return this + } + override fun putExtra(name: String, value: Double): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putDouble(name, value) + return this + } + override fun putExtra(name: String, value: DoubleArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putDoubleArray(name, value) + return this + } + override fun putExtra(name: String?, value: Float): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putFloat(name, value) + return this + } + override fun putExtra(name: String, value: FloatArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putFloatArray(name, value) + return this + } + override fun putExtra(name: String, value: Int): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putInt(name, value) + return this + } + override fun putExtra(name: String, value: IntArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putIntArray(name, value) + return this + } + override fun putExtra(name: String, value: Long): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putLong(name, value) + return this + } + override fun putExtra(name: String, value: LongArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putLongArray(name, value) + return this + } + override fun putExtra(name: String, value: Parcelable?): Intent { + return this // Stub! + } + override fun putExtra(name: String, value: Short): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putShort(name, value) + return this + } + override fun putExtra(name: String, value: ShortArray?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putShortArray(name, value) + return this + } + override fun putExtra(name: String, value: String?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putString(name, value) + return this + } + override fun putExtra(name: String, value: Serializable?): Intent { + return this // Stub! + } + override fun putStringArrayListExtra(name: String, value: ArrayList?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putStringArrayList(name, value) + return this + } + override fun putIntegerArrayListExtra(name: String, value: ArrayList?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putIntegerArrayList(name, value) + return this + } + override fun putParcelableArrayListExtra(name: String?, value: java.util.ArrayList?): Intent { + return this // Stub! + } + override fun putCharSequenceArrayListExtra(name: String?, value: java.util.ArrayList?): Intent { + if (this._extras == null) { + this._extras = Bundle() + } + this._extras!!.putCharSequenceArrayList(name, value) + return this + } + override fun getBooleanExtra(name: String, defaultValue: Boolean): Boolean { + return _extras?.getBoolean(name, defaultValue) ?: defaultValue + } + override fun getBooleanArrayExtra(name: String): BooleanArray? { + return _extras?.getBooleanArray(name) + } + override fun getByteExtra(name: String, defaultValue: Byte): Byte { + return _extras?.getByte(name, defaultValue) ?: defaultValue + } + override fun getByteArrayExtra(name: String?): ByteArray? { + return _extras?.getByteArray(name) + } + override fun getCharExtra(name: String, defaultValue: Char): Char { + return _extras?.getChar(name, defaultValue) ?: defaultValue + } + override fun getCharArrayExtra(name: String?): CharArray? { + return _extras?.getCharArray(name) + } + override fun getDoubleExtra(name: String, defaultValue: Double): Double { + return _extras?.getDouble(name, defaultValue) ?: defaultValue + } + override fun getDoubleArrayExtra(name: String?): DoubleArray? { + return _extras?.getDoubleArray(name) + } + override fun getFloatExtra(name: String, defaultValue: Float): Float { + return _extras?.getFloat(name, defaultValue) ?: defaultValue + } + override fun getFloatArrayExtra(name: String?): FloatArray? { + return _extras?.getFloatArray(name) + } + override fun getIntExtra(name: String, defaultValue: Int): Int { + return _extras?.getInt(name, defaultValue) ?: defaultValue + } + override fun getIntArrayExtra(name: String?): IntArray? { + return _extras?.getIntArray(name) + } + override fun getLongExtra(name: String, defaultValue: Long): Long { + return _extras?.getLong(name, defaultValue) ?: defaultValue + } + override fun getLongArrayExtra(name: String): LongArray? { + return _extras?.getLongArray(name) + } + @Deprecated("Deprecated in Java") + override fun getSerializableExtra(name: String): Serializable? { + return null // Stub! + } + override fun getStringArrayListExtra(name: String): ArrayList? { + return _extras?.getStringArrayList(name) + } + override fun getIntegerArrayListExtra(name: String): ArrayList? { + return _extras?.getIntegerArrayList(name) + } + override fun getCharSequenceArrayListExtra(name: String): ArrayList? { + return _extras?.getCharSequenceArrayList(name) + } + override fun getSerializableExtra(name: String?, clazz: Class): T? { + return null // Stub! + } + override fun getBundleExtra(name: String): Bundle? { + return null // Stub! + } + override fun getStringExtra(name: String): String? { + return _extras?.getString(name) + } + override fun getShortExtra(name: String, defaultValue: Short): Short { + return _extras?.getShort(name, defaultValue) ?: defaultValue + } + override fun getShortArrayExtra(name: String): ShortArray? { + return _extras?.getShortArray(name) + } + override fun getStringArrayExtra(name: String): Array? { + return _extras?.getStringArray(name) + } + override fun getCharSequenceExtra(name: String): CharSequence? { + return _extras?.getCharSequence(name) + } + override fun getParcelableExtra(name: String?, clazz: Class): T? { + return null // Stub! + } + @Deprecated("Deprecated in Java") + override fun getParcelableExtra(name: String?): T? { + return null // Stub! + } + override fun getCharSequenceArrayExtra(name: String): Array? { + return _extras?.getCharSequenceArray(name) + } + override fun getParcelableArrayExtra(name: String?, clazz: Class): Array? { + return null // Stub! + } + @Deprecated("Deprecated in Java") + override fun getParcelableArrayExtra(name: String): Array? { + return null // Stub! + } + override fun getParcelableArrayListExtra(name: String?, clazz: Class): java.util.ArrayList? { + return null // Stub! + } + @Deprecated("Deprecated in Java") + override fun getParcelableArrayListExtra(name: String?): java.util.ArrayList? { + return null // Stub! + } + + override fun getIdentifier() = _identifier + override fun setIdentifier(identifier: String?): Intent { + this._identifier = identifier + return this + } + + override fun getSelector(): Intent? { + return null + } + override fun setSelector(selector: Intent?) { + // Stub! + } + + override fun getSourceBounds() = _sourceBounds + override fun setSourceBounds(sourceBounds: Rect?) { + this._sourceBounds = sourceBounds + } + + override fun isMismatchingFilter() = false +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt new file mode 100644 index 0000000..09c76b9 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt @@ -0,0 +1,13 @@ +@file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + +package com.pr0gramm3r101.utils + +import android.os.AsyncTask + +abstract class SimpleAsyncTask: AsyncTask() { + final override fun doInBackground(vararg params: Unit) = run() + abstract fun run() + abstract fun postRun() + final override fun onProgressUpdate(vararg values: Unit) {} + final override fun onPostExecute(result: Unit?) = postRun() +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt new file mode 100644 index 0000000..8870977 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt @@ -0,0 +1,832 @@ +@file:Suppress("NOTHING_TO_INLINE", "unused") + +package com.pr0gramm3r101.utils + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.KeyguardManager +import android.app.Notification +import android.content.ComponentName +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_MAIN +import android.content.Intent.CATEGORY_LAUNCHER +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.res.Resources +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.net.Uri +import android.os.BaseBundle +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.os.PersistableBundle +import android.provider.OpenableColumns +import android.service.quicksettings.Tile +import android.util.Base64 +import android.util.Log +import android.util.TypedValue +import android.view.View +import android.view.ViewTreeObserver.OnPreDrawListener +import android.widget.ImageView.ScaleType +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCaller +import androidx.annotation.AttrRes +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresPermission +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.window.core.layout.WindowSizeClass +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.io.IOException +import java.io.InputStream +import java.io.Serializable +import java.lang.ref.WeakReference +import java.security.SecureRandom +import java.util.regex.Pattern +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import kotlin.collections.iterator +import kotlin.random.Random +import kotlin.random.nextInt +import kotlin.reflect.KClass +import kotlin.reflect.KProperty + +@OptIn(ExperimentalLayoutApi::class) +actual fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed { + var isFocused by remember { mutableStateOf(false) } + var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) } + if (isFocused) { + val imeIsVisible = WindowInsets.isImeVisible + val focusManager = LocalFocusManager.current + LaunchedEffect(imeIsVisible) { + if (imeIsVisible) { + @Suppress("AssignedValueIsNeverRead") + keyboardAppearedSinceLastFocused = true + } else if (keyboardAppearedSinceLastFocused) { + focusManager.clearFocus() + } + } + } + onFocusEvent { + if (isFocused != it.isFocused) { + isFocused = it.isFocused + if (isFocused) { + keyboardAppearedSinceLastFocused = false + } + } + } +} + +@SuppressLint("ComposableNaming") +@Composable +actual fun ToggleNavScrimEffect(enabled: Boolean) { + val context = (LocalContext() as Activity) + LaunchedEffect(enabled) { + runCatching { + val window = context.window + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = enabled + } + } + } +} + +val screenWidth: Int inline get() = Resources.getSystem().displayMetrics.widthPixels +@Suppress("unused") +val screenHeight: Int inline get() = Resources.getSystem().displayMetrics.heightPixels + +fun appName(context: Context, packageName: String): String? { + try { + val packageManager = context.packageManager + val applicationInfo = packageManager.getApplicationInfo( + packageName, PackageManager.GET_META_DATA + ) + val appName = packageManager.getApplicationLabel(applicationInfo) as String + return appName + } catch (_: PackageManager.NameNotFoundException) { + return null + } +} + +fun Context.homeScreen() { + val intentHome = Intent(ACTION_MAIN) + intentHome.addCategory(Intent.CATEGORY_HOME) + intentHome.flags = FLAG_ACTIVITY_NEW_TASK + startActivity(intentHome) +} + + +inline fun async(noinline exec: () -> Unit) = Thread(exec).apply { start() } + +inline fun Tile.configure(apply: Tile.() -> Unit) { + apply.invoke(this) + updateTile() +} + +inline fun View.addOneTimeOnPreDrawListener(crossinline listener: () -> Boolean) { + viewTreeObserver.addOnPreDrawListener(object: OnPreDrawListener { + override fun onPreDraw(): Boolean { + viewTreeObserver.removeOnPreDrawListener(this) + return listener() + } + }) +} + +fun Context.launchFiles(): Boolean { + val primaryStorageUri = "content://com.android.externalstorage.documents/root/primary".toUri() + + fun launchIntentWithComponent(action: String, componentName: ComponentName? = null): Boolean { + val intent = Intent(action, primaryStorageUri) + if (componentName != null) { + intent.setComponent(componentName) + } + try { + startActivity(intent) + return true + } catch (_: Throwable) { + return false + } + } + fun intent1() = launchIntentWithComponent( + Intent.ACTION_VIEW, + ComponentName( + "com.google.android.documentsui", + "com.android.documentsui.files.FilesActivity" + ) + ) + fun intent2() = launchIntentWithComponent( + Intent.ACTION_VIEW, + ComponentName( + "com.android.documentsui", + "com.android.documentsui.files.FilesActivity" + ) + ) + fun intent3() = launchIntentWithComponent( + Intent.ACTION_VIEW, + ComponentName( + "com.android.documentsui", + "com.android.documentsui.FilesActivity" + ) + ) + fun intent4() = launchIntentWithComponent(Intent.ACTION_VIEW) + fun intent5() = launchIntentWithComponent("android.provider.action.BROWSE") + fun intent6() = launchIntentWithComponent("android.provider.action.BROWSE_DOCUMENT_ROOT") + + return intent1() || intent2() || intent3() || intent4() || intent5() || intent6() +} + +inline fun Fragment.getSystemService(name: String): Any? = requireActivity().getSystemService(name) +inline fun Fragment.getSystemService(cls: Class): T? = requireActivity().getSystemService(cls) + +inline fun Context.getSystemService(cls: KClass): T? = getSystemService(cls.java) +inline fun Fragment.getSystemService(cls: KClass): T? = requireActivity().getSystemService(cls) + +typealias ActivityLauncher = BetterActivityResult + +val ActivityResultCaller.activityResultLauncher get() = BetterActivityResult.registerActivityForResult(this) + +@Suppress("unused") +fun getOpenDocumentIntent(vararg fileTypes: String) = + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + if (fileTypes.size == 1) { + type = fileTypes[0] + } else { + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, fileTypes) + } + } + +fun Context.getFileName(uri: Uri): String { + if (uri.scheme == "content") { + contentResolver.query( + uri, + null, + null, + null, + null + )!!.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + } + } + } else { + throw UnsupportedOperationException("Cannot process non-\"content://\" URIs") + } + return uri.lastPathSegment.let { name -> + name!! + val matcher = Pattern.compile("\\w+:(.*/)*(.*)").matcher(name) + if (matcher.find()) { + matcher.group(2) + } else { + name + } + } +} + +@Suppress("unused") +inline fun Fragment.getFileName(uri: Uri) = requireActivity().getFileName(uri) + +@Suppress("unused") +val UsbInterface.endpointList: List get() { + val list = mutableListOf() + for (i in 0 until endpointCount) { + list.add(getEndpoint(i)) + } + return list.toList() +} + +fun Activity.resolveAttr(@AttrRes id: Int): Int? { + val typedValue = TypedValue() + if (theme.resolveAttribute( + id, + typedValue, + true + )) { + val resId = typedValue.resourceId + return if (resId != 0) resId else null + } else { + return null + } +} + +@Suppress("UNUSED_PARAMETER", "unused", "MemberVisibilityCanBePrivate") +class QuickAlertDialogBuilder(context: Context): MaterialAlertDialogBuilder(context) { + fun title(title: CharSequence): QuickAlertDialogBuilder { + setTitle(title) + return this + } + fun title(@StringRes titleRes: Int): QuickAlertDialogBuilder { + setTitle(titleRes) + return this + } + + fun message(message: CharSequence): QuickAlertDialogBuilder { + setMessage(message) + return this + } + fun message(@StringRes messageRes: Int): QuickAlertDialogBuilder { + setMessage(messageRes) + return this + } + + fun body(body: CharSequence) = message(body) + fun body(@StringRes bodyRes: Int) = message(bodyRes) + + inline fun positiveButton( + text: CharSequence, + crossinline listener: () -> Unit + ) : QuickAlertDialogBuilder { + setPositiveButton(text) { _, _ -> listener() } + return this + } + inline fun positiveButton(@StringRes textRes: Int, crossinline listener: () -> Unit): QuickAlertDialogBuilder { + setPositiveButton(textRes) { _, _ -> listener() } + return this + } + inline fun positiveButton(text: CharSequence, listener: Nothing? = null): QuickAlertDialogBuilder { + setPositiveButton(text, null) + return this + } + inline fun positiveButton(@StringRes text: Int, listener: Nothing? = null): QuickAlertDialogBuilder { + setPositiveButton(text, null) + return this + } + + inline fun negativeButton(text: CharSequence, crossinline listener: () -> Unit): QuickAlertDialogBuilder { + setNegativeButton(text) { _, _ -> listener() } + return this + } + inline fun negativeButton(@StringRes textRes: Int, crossinline listener: () -> Unit): QuickAlertDialogBuilder { + setNegativeButton(textRes) { _, _ -> listener() } + return this + } + inline fun negativeButton(text: CharSequence, listener: Nothing? = null): QuickAlertDialogBuilder { + setNegativeButton(text, null) + return this + } + inline fun negativeButton(@StringRes text: Int, listener: Nothing? = null): QuickAlertDialogBuilder { + setNegativeButton(text, null) + return this + } + + inline fun neutralButton(text: CharSequence, crossinline listener: () -> Unit): QuickAlertDialogBuilder { + setNeutralButton(text) { _, _ -> listener() } + return this + } + inline fun neutralButton(@StringRes textRes: Int, crossinline listener: () -> Unit): QuickAlertDialogBuilder { + setNeutralButton(textRes) { _, _ -> listener() } + return this + } + inline fun neutralButton(text: CharSequence, listener: Nothing? = null): QuickAlertDialogBuilder { + setNeutralButton(text, null) + return this + } + inline fun neutralButton(@StringRes text: Int, listener: Nothing? = null): QuickAlertDialogBuilder { + setNeutralButton(text, null) + return this + } + + inline fun onCancel(crossinline listener: () -> Unit): QuickAlertDialogBuilder { + setOnCancelListener { listener() } + return this + } + + inline fun cancelable(value: Boolean): QuickAlertDialogBuilder { + setCancelable(value) + return this + } +} + +inline fun Activity.alertDialog(crossinline config: QuickAlertDialogBuilder.() -> Unit): AlertDialog { + val builder = QuickAlertDialogBuilder(this) + config(builder) + return builder.show() +} + +@Suppress("unused") +inline fun Fragment.alertDialog(crossinline config: QuickAlertDialogBuilder.() -> Unit) + = requireActivity().alertDialog(config) + +@Suppress("unused") +class AuthenticationConfig { + var success: ((BiometricPrompt.AuthenticationResult) -> Unit)? = null + var fail: (() -> Unit)? = null + var error: ((Int, String) -> Unit)? = null + var always: (() -> Unit)? = null + + fun success(block: (BiometricPrompt.AuthenticationResult) -> Unit) { + success = block + } + + inline fun ail(noinline block: () -> Unit) { + fail = block + } + + inline fun error(noinline block: (Int, String) -> Unit) { + error = block + } + + inline fun always(noinline block: () -> Unit) { + always = block + } + + lateinit var title: String + var subtitle: String? = null + lateinit var negativeButtonText: String +} + +inline fun FragmentActivity.requestAuthentication(crossinline callback: AuthenticationConfig.() -> Unit): BiometricPrompt { + val config = AuthenticationConfig() + callback(config) + try { + config.title + config.negativeButtonText + } catch (_: UninitializedPropertyAccessException) { + throw IllegalStateException("Required fields haven't been set") + } + val executor = ContextCompat.getMainExecutor(this) + val biometricPrompt = BiometricPrompt( + this, + executor, + object: BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + config.fail?.invoke() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + config.success?.invoke(result) + config.always?.invoke() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + config.error?.invoke(errorCode, "$errString") + config.always?.invoke() + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { + setTitle(config.title) + setSubtitle(config.subtitle) + setNegativeButtonText(config.negativeButtonText) + setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + setConfirmationRequired(false) + }.build() + biometricPrompt.authenticate(promptInfo) + return biometricPrompt +} + +fun ContentScale.asAndroidScaleType(): ScaleType? { + return when (this) { + ContentScale.Fit -> ScaleType.FIT_CENTER + ContentScale.Crop -> ScaleType.CENTER_CROP + ContentScale.FillWidth, + ContentScale.FillHeight, + ContentScale.FillBounds -> ScaleType.FIT_XY + ContentScale.Inside -> ScaleType.CENTER_INSIDE + else -> null + } +} + +inline fun Context.openUrl(url: Uri) { + startActivity(Intent(Intent.ACTION_VIEW, url)) +} + +inline fun Context.openUrl(url: String) { + openUrl( + url.let { + val regex = "^[\\w-_]://".toRegex() + var th = it + if (!regex.containsMatchIn(th)) { + th = "https://$th" + } + th + }.toUri() + ) +} + +inline fun ComponentActivity.ComposeView(crossinline init: @Composable () -> Unit) = + ComposeView(this).apply { + setContent { + init() + } + } + +inline fun backCallback(enabled: Boolean = true, crossinline callback: OnBackPressedCallback.() -> Unit) + = object: OnBackPressedCallback(enabled) { + override fun handleOnBackPressed() { + callback(this) + } +} + +fun String.encrypt(password: String): String { + val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } + val spec = PBEKeySpec(password.toCharArray(), salt, 65536, 256) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val secretKey = factory.generateSecret(spec) + + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val iv = ByteArray(cipher.blockSize).also { SecureRandom().nextBytes(it) } + val params = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, params) + + val encryptedBytes = cipher.doFinal(this.toByteArray()) + return "${Base64.encodeToString(salt, Base64.DEFAULT)}:${Base64.encodeToString(iv, Base64.DEFAULT)}:${Base64.encodeToString(encryptedBytes, Base64.DEFAULT)}" +} + +fun String.decrypt(password: String): String { + val parts = this.split(":") + val salt = Base64.decode(parts[0], Base64.DEFAULT) + val iv = Base64.decode(parts[1], Base64.DEFAULT) + val encryptedBytes = Base64.decode(parts[2], Base64.DEFAULT) + + val spec = PBEKeySpec(password.toCharArray(), salt, 65536, 256) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val secretKey = factory.generateSecret(spec) + + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val params = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, params) + + val decryptedBytes = cipher.doFinal(encryptedBytes) + return String(decryptedBytes) +} + +@Suppress("unused") +inline fun waitUntil(timeout: Long = 0, condition: () -> Boolean): Boolean { + val time = System.currentTimeMillis() + while (!condition()) { + if (timeout > 0 && System.currentTimeMillis() - time > timeout) { + return false + } + } + return true +} + +inline fun waitWhile(timeout: Long = 0, condition: () -> Boolean): Boolean { + val time = System.currentTimeMillis() + while (condition()) { + if (timeout > 0 && System.currentTimeMillis() - time > timeout) { + return false + } + } + return true +} + +inline fun orientationSensorEventListener( + crossinline callback: (Float, Float, Float) -> Unit +) = object: SensorEventListener { + private var accelerometerData: FloatArray? = null + private var geomagneticData: FloatArray? = null + + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) accelerometerData = event.values + if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) geomagneticData = event.values + if (accelerometerData != null && geomagneticData != null) { + val R = FloatArray(9) + val success = SensorManager.getRotationMatrix( + R, + FloatArray(9), + accelerometerData, + geomagneticData + ) + if (success) { + val orientationData = FloatArray(3) + SensorManager.getOrientation(R, orientationData) + callback( + orientationData[0], + orientationData[1], + orientationData[2] + ) + } + } + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} +} + +inline fun SensorEventListener(crossinline callback: (SensorEvent) -> Unit) = object: SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + callback(event) + } + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} +} + +inline val Context.isScreenLocked get() = (getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked + +@Suppress("MemberVisibilityCanBePrivate", "unused") +class NotificationIdManager(vararg reservedIds: Int) { + private val reserved = reservedIds.toMutableList() + + fun reserve(id: Int) { + reserved.add(id) + } + + fun release(id: Int) { + reserved.remove(id) + } + + fun get(): Int { + while (true) { + val random = Random.nextInt(0..Int.MAX_VALUE) + if (!reserved.contains(random)) { + return random + } + } + } + + fun getAndReserve(): Int { + val id = get() + reserve(id) + return id + } +} + +@Composable +fun PaddingValues.copy( + start: Dp? = null, + end: Dp? = null, + top: Dp? = null, + bottom: Dp? = null +) = PaddingValues( + start = start ?: calculateStartPadding(LocalLayoutDirection.current), + end = end ?: calculateEndPadding(LocalLayoutDirection.current), + top = top ?: calculateTopPadding(), + bottom = bottom ?: calculateBottomPadding() +) + +@Suppress("unused") +@Composable +fun PaddingValues.copy( + horizontal: Dp? = null, + vertical: Dp? = null +) = copy( + start = horizontal, + end = horizontal, + top = vertical, + bottom = vertical +) + +inline fun InputStream.test() = + try { + read() + close() + true + } catch (_: IOException) { + false + } + +inline fun ContentResolver.test(item: Uri) = + try { + openInputStream(item)!!.test() + } catch (_: Exception) { + false + } + +inline fun Intent.getParcelableExtraAs(key: String): T { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(key) + }!! +} + +inline fun Intent.getSerializableExtraAs(key: String): T { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getSerializableExtra(key) as T + }!! +} + +fun ActivityInfo.contentEquals(other: ActivityInfo): Boolean { + return ( + ( + if (Build.VERSION.SDK_INT >= 26) + colorMode == other.colorMode + else true + ) && + ( + if (Build.VERSION.SDK_INT >= 34) + requiredDisplayCategory == other.requiredDisplayCategory + else true + ) && + theme == other.theme && + launchMode == other.launchMode && + documentLaunchMode == other.documentLaunchMode && + permission == other.permission && + taskAffinity == other.taskAffinity && + targetActivity == other.targetActivity && + flags == other.flags && + screenOrientation == other.screenOrientation && + configChanges == other.configChanges && + softInputMode == other.softInputMode && + uiOptions == other.uiOptions && + parentActivityName == other.parentActivityName && + maxRecents == other.maxRecents && + windowLayout == other.windowLayout + ) +} + +val ActivityInfo.launchIntent get() = Intent().apply { + component = ComponentName(packageName!!, name) + flags = FLAG_ACTIVITY_NEW_TASK +} + +@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) +fun ActivityInfo.isLauncher(context: Context): Boolean { + with (context) { + @Suppress("ExplicitThis") + val intent = launchIntent.apply { + action = ACTION_MAIN + addCategory(CATEGORY_LAUNCHER) + component = ComponentName(this@isLauncher.packageName!!, name) + flags = FLAG_ACTIVITY_NEW_TASK + } + val infos = packageManager.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER) + for (info in infos) { + val filter = info.filter + if ( + info.activityInfo.contentEquals(this@isLauncher) && + filter != null && + filter.hasAction(ACTION_MAIN) && + filter.hasCategory(CATEGORY_LAUNCHER) + ) { + return true + } + } + return false + } +} + + + +@Suppress("unused", "RedundantSuppression") +operator fun WeakReference.getValue(thisRef: Any?, property: KProperty<*>) = get()!! + +@Suppress("unused") +inline fun runOrLog( + tag: String, + message: String = "An error occurred:", + crossinline block: () -> Unit +) { + try { + block() + } catch (e: Exception) { + Log.e(tag, message, e) + } +} + +@Suppress("unused") +typealias WidthSizeClass = WindowWidthSizeClass +@Suppress("unused") +typealias HeightSizeClass = WindowHeightSizeClass +@Suppress("unused") +typealias SizeClass = WindowSizeClass + +@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) +inline fun Context.notifyIfAllowed( + id: Int, + notification: Notification +) { + with (NotificationManagerCompat.from(this)) { + if ( + checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return@with + } + notify(id, notification) + } +} + +@Suppress("DEPRECATION") +fun BaseBundle.toMap(): Map { + val map = mutableMapOf() + for (key in keySet()) { + map[key] = get(key)!! + } + return map +} + +fun Bundle.toPersistableBundle(): PersistableBundle { + val map = toMap() + val bundle = PersistableBundle() + for ((key, value) in map) { + when (value) { + is Int -> bundle.putInt(key, value) + is Long -> bundle.putLong(key, value) + is Double -> bundle.putDouble(key, value) + is String -> bundle.putString(key, value) + is IntArray -> bundle.putIntArray(key, value) + is LongArray -> bundle.putLongArray(key, value) + is DoubleArray -> bundle.putDoubleArray(key, value) + is PersistableBundle -> bundle.putPersistableBundle(key, value) + is Boolean -> bundle.putBoolean(key, value) + is BooleanArray -> bundle.putBooleanArray(key, value) + is Array<*> -> { + if (value.isArrayOf()) { + @Suppress("UNCHECKED_CAST") + bundle.putStringArray(key, value as Array) + } + } + } + } + return bundle +} + +inline fun String.decodeWindows1251() = String( + toByteArray(Charsets.ISO_8859_1), + Charsets.UTF_8 +) + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +actual val materialYouAvailable get() = Build.VERSION.SDK_INT >= 31 \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt new file mode 100644 index 0000000..c896098 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt @@ -0,0 +1,14 @@ +package com.pr0gramm3r101.utils + +import android.content.Context +import java.lang.ref.WeakReference + +actual object UtilsLibrary { + private var init = false + private lateinit var _context: WeakReference + internal val context get() = _context.get()!! + + actual fun init(vararg args: Any) { + _context = WeakReference(args[0] as Context) + } +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt new file mode 100644 index 0000000..61f495d --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt @@ -0,0 +1,13 @@ +package com.pr0gramm3r101.utils.crypto + +import android.util.Base64 as AndroidBase64 + +actual object Base64 { + actual fun encode(bytes: ByteArray): String { + return AndroidBase64.encodeToString(bytes, AndroidBase64.NO_WRAP) + } + + actual fun decode(base64: String): ByteArray { + return AndroidBase64.decode(base64, AndroidBase64.NO_WRAP) + } +} diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt new file mode 100644 index 0000000..4ae6c08 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt @@ -0,0 +1,15 @@ +package com.pr0gramm3r101.utils.crypto + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +actual object Hmac { + actual suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = withContext(Dispatchers.Default) { + val mac = Mac.getInstance("HmacSHA256") + val secretKey = SecretKeySpec(key, "HmacSHA256") + mac.init(secretKey) + mac.doFinal(data) + } +} diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt new file mode 100644 index 0000000..0221f8d --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt @@ -0,0 +1,115 @@ +package com.pr0gramm3r101.utils.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import com.pr0gramm3r101.utils.settings.DataStoreSingleton + +class AndroidSettings(private val context: Context): Settings { + private val dataStore: DataStore + get() = DataStoreSingleton.dataStore + override fun putString(key: String, value: String) { + runBlocking { + dataStore.edit { preferences -> + preferences[stringPreferencesKey(key)] = value + } + } + } + + override fun getString(key: String, default: String) = runBlocking { + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)] ?: default + }.first() + } + + override fun putInt(key: String, value: Int) = runBlocking { + dataStore.edit { preferences -> + preferences[intPreferencesKey(key)] = value + } + Unit + } + + override fun getInt(key: String, default: Int) = runBlocking { + dataStore.data.map { preferences -> + preferences[intPreferencesKey(key)] ?: default + }.first() + } + + override fun putLong(key: String, value: Long) = runBlocking { + dataStore.edit { preferences -> + preferences[longPreferencesKey(key)] = value + } + Unit + } + + override fun getLong(key: String, default: Long) = runBlocking { + dataStore.data.map { preferences -> + preferences[longPreferencesKey(key)] ?: default + }.first() + } + + override fun putFloat(key: String, value: Float) = runBlocking { + dataStore.edit { preferences -> + preferences[floatPreferencesKey(key)] = value + } + Unit + } + + override fun getFloat(key: String, default: Float) = runBlocking { + dataStore.data.map { preferences -> + preferences[floatPreferencesKey(key)] ?: default + }.first() + } + + override fun putBoolean(key: String, value: Boolean) = runBlocking { + dataStore.edit { preferences -> + preferences[booleanPreferencesKey(key)] = value + } + Unit + } + + override fun getBoolean(key: String, default: Boolean) = runBlocking { + dataStore.data.map { preferences -> + preferences[booleanPreferencesKey(key)] ?: default + }.first() + } + + override fun putStringSet(key: String, value: Set) = runBlocking { + dataStore.edit { preferences -> + preferences[stringSetPreferencesKey(key)] = value + } + Unit + } + + override fun getStringSet(key: String, default: Set) = runBlocking { + dataStore.data.map { preferences -> + preferences[stringSetPreferencesKey(key)] ?: default + }.first() + } + + override fun putStringList(key: String, value: List) = putStringSet(key, value.toSet()) + + override fun getStringList(key: String, default: List) = getStringSet(key, default.toSet()).toList() + + override fun remove(key: String) = runBlocking { + dataStore.edit { preferences -> + preferences.remove(stringPreferencesKey(key)) + preferences.remove(intPreferencesKey(key)) + preferences.remove(longPreferencesKey(key)) + preferences.remove(floatPreferencesKey(key)) + preferences.remove(booleanPreferencesKey(key)) + preferences.remove(stringSetPreferencesKey(key)) + } + Unit + } +} \ No newline at end of file diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt new file mode 100644 index 0000000..749b4a9 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt @@ -0,0 +1,129 @@ +package com.pr0gramm3r101.utils.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.pr0gramm3r101.utils.settings.DataStoreSingleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class AndroidAsyncSettings : AsyncSettings { + private val dataStore: DataStore + get() = DataStoreSingleton.dataStore + + override suspend fun putString(key: String, value: String) { + dataStore.edit { preferences -> + preferences[stringPreferencesKey(key)] = value + } + } + + override suspend fun getString(key: String, default: String): String { + return dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)] ?: default + }.first() + } + + override suspend fun putInt(key: String, value: Int) { + dataStore.edit { preferences -> + preferences[intPreferencesKey(key)] = value + } + } + + override suspend fun getInt(key: String, default: Int): Int { + return dataStore.data.map { preferences -> + preferences[intPreferencesKey(key)] ?: default + }.first() + } + + override suspend fun putLong(key: String, value: Long) { + dataStore.edit { preferences -> + preferences[longPreferencesKey(key)] = value + } + } + + override suspend fun getLong(key: String, default: Long): Long { + return dataStore.data.map { preferences -> + preferences[longPreferencesKey(key)] ?: default + }.first() + } + + override suspend fun putFloat(key: String, value: Float) { + dataStore.edit { preferences -> + preferences[floatPreferencesKey(key)] = value + } + } + + override suspend fun getFloat(key: String, default: Float): Float { + return dataStore.data.map { preferences -> + preferences[floatPreferencesKey(key)] ?: default + }.first() + } + + override suspend fun putBoolean(key: String, value: Boolean) { + dataStore.edit { preferences -> + preferences[booleanPreferencesKey(key)] = value + } + } + + override suspend fun getBoolean(key: String, default: Boolean): Boolean { + return dataStore.data.map { preferences -> + preferences[booleanPreferencesKey(key)] ?: default + }.first() + } + + override suspend fun putStringSet(key: String, value: Set) { + dataStore.edit { preferences -> + preferences[stringSetPreferencesKey(key)] = value + } + } + + override suspend fun getStringSet(key: String, default: Set): Set { + return dataStore.data.map { preferences -> + preferences[stringSetPreferencesKey(key)] ?: default + }.first() + } + + override suspend fun putStringList(key: String, value: List) { + putStringSet(key, value.toSet()) + } + + override suspend fun getStringList(key: String, default: List): List { + return getStringSet(key, default.toSet()).toList() + } + + override suspend fun remove(key: String) { + dataStore.edit { preferences -> + preferences.remove(stringPreferencesKey(key)) + preferences.remove(intPreferencesKey(key)) + preferences.remove(longPreferencesKey(key)) + preferences.remove(floatPreferencesKey(key)) + preferences.remove(booleanPreferencesKey(key)) + preferences.remove(stringSetPreferencesKey(key)) + } + } + + override suspend fun contains(key: String): Boolean { + return dataStore.data.map { preferences -> + preferences.contains(stringPreferencesKey(key)) || + preferences.contains(intPreferencesKey(key)) || + preferences.contains(longPreferencesKey(key)) || + preferences.contains(floatPreferencesKey(key)) || + preferences.contains(booleanPreferencesKey(key)) || + preferences.contains(stringSetPreferencesKey(key)) + }.first() + } + + override suspend fun clear() { + dataStore.edit { preferences -> + preferences.clear() + } + } +} + +actual val asyncSettings: AsyncSettings = AndroidAsyncSettings() diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt new file mode 100644 index 0000000..d07fb95 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt @@ -0,0 +1,17 @@ +package com.pr0gramm3r101.utils.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.pr0gramm3r101.utils.UtilsLibrary + +/** + * Singleton DataStore instance to avoid multiple DataStore instances for the same file + */ +object DataStoreSingleton { + private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + + val dataStore: DataStore + get() = UtilsLibrary.context.dataStore +} diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt new file mode 100644 index 0000000..fc36384 --- /dev/null +++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt @@ -0,0 +1,5 @@ +package com.pr0gramm3r101.utils.settings + +import com.pr0gramm3r101.utils.UtilsLibrary + +actual val settings: Settings get() = AndroidSettings(UtilsLibrary.context) \ No newline at end of file diff --git a/utils/src/androidMain/res/values-ru/strings.xml b/utils/src/androidMain/res/values-ru/strings.xml new file mode 100644 index 0000000..2aaf96d --- /dev/null +++ b/utils/src/androidMain/res/values-ru/strings.xml @@ -0,0 +1,6 @@ + + + Показать пароль + Скрыть пароль + Что-то пошло не так + \ No newline at end of file diff --git a/utils/src/androidMain/res/values/strings.xml b/utils/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..ba4ee06 --- /dev/null +++ b/utils/src/androidMain/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Show password + Hide password + Something went wrong + \ No newline at end of file diff --git a/utils/src/androidMain/res/values/themes.xml b/utils/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000..5bc96a1 --- /dev/null +++ b/utils/src/androidMain/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/utils/src/commonMain/composeResources/drawable/license.xml b/utils/src/commonMain/composeResources/drawable/license.xml new file mode 100644 index 0000000..23fb3cf --- /dev/null +++ b/utils/src/commonMain/composeResources/drawable/license.xml @@ -0,0 +1,9 @@ + + + diff --git a/utils/src/commonMain/composeResources/drawable/siren_filled.xml b/utils/src/commonMain/composeResources/drawable/siren_filled.xml new file mode 100644 index 0000000..3dffcb1 --- /dev/null +++ b/utils/src/commonMain/composeResources/drawable/siren_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/utils/src/commonMain/composeResources/drawable/siren_outlined.xml b/utils/src/commonMain/composeResources/drawable/siren_outlined.xml new file mode 100644 index 0000000..70acd19 --- /dev/null +++ b/utils/src/commonMain/composeResources/drawable/siren_outlined.xml @@ -0,0 +1,9 @@ + + + diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt new file mode 100644 index 0000000..0be4a01 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt @@ -0,0 +1,1086 @@ +@file:Suppress("unused", "MemberVisibilityCanBePrivate", "UnusedReceiverParameter", "NOTHING_TO_INLINE") + +package com.pr0gramm3r101.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardColors +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pr0gramm3r101.utils.invoke +import com.pr0gramm3r101.utils.left +import com.pr0gramm3r101.utils.link +import com.pr0gramm3r101.utils.plus +import com.pr0gramm3r101.utils.right +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import tech.annexflow.constraintlayout.compose.ConstraintLayout +import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope +import tech.annexflow.constraintlayout.compose.Dimension +import kotlin.random.Random + +@Composable +fun ListItem( + modifier: Modifier = Modifier, + bodyModifier: Modifier = Modifier, + headline: String, + supportingText: String? = null, + leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, + trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, + enabled: Boolean = true, + divider: Boolean = false, + dividerColor: Color = DividerDefaults.color, + dividerThickness: Dp = DividerDefaults.Thickness, + dividerAnimated: Boolean = false, + onClick: (() -> Unit)? = null, + bodyOnClick: (() -> Unit)? = null, + leadingAndBodyShared: Boolean = false, + bottomContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null +) { + Column { + @Composable + fun ProvideStyle( + content: @Composable BoxScope?.() -> Unit + ) { + if (!enabled) { + CompositionLocalProvider( + value = LocalContentColor provides MaterialTheme.colorScheme.onSurface, + ) { + Box( + modifier = Modifier.alpha(0.38f), + content = content + ) + } + } else { + content(null) + } + } + + ProvideStyle { + ConstraintLayout( + modifier = Modifier.let { + var mod = it.fillMaxWidth() + if (onClick != null) { + mod += Modifier.clickable(onClick = onClick) + } + mod + modifier + } + ) { + val (leading, listItem, trailing, btm) = createRefs() + + @Composable + fun leadingBody() { + if (leadingContent != null) { + ConstraintLayout( + modifier = Modifier + .constrainAs(leading) { + top link parent.top + bottom link + if (bottomContent != null) btm.top + else parent.bottom + left link parent.left + right link listItem.left + } + .padding( + start = 16.dp, + top = 8.dp, + bottom = 8.dp + ), + content = leadingContent + ) + } + ListItem( + headlineContent = { Text(headline) }, + supportingContent = { if (supportingText != null) Text(supportingText) }, + modifier = Modifier + .let { + var mod = it.constrainAs(listItem) { + top link parent.top + bottom link + if (bottomContent != null) btm.top + else parent.bottom + + left link + if (leadingContent != null) leading.right + else parent.left + right link + if (trailingContent != null) trailing.left + else parent.right + width = Dimension.fillToConstraints + } + if (bodyOnClick != null && !leadingAndBodyShared) { + mod += Modifier.clickable(onClick = bodyOnClick) + } + mod + bodyModifier + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) + } + + if (leadingAndBodyShared) { + Row( + modifier = Modifier + .constrainAs(listItem) { + top link parent.top + bottom link + if (bottomContent != null) btm.top + else parent.bottom + left link parent.left + right link + if (trailingContent != null) trailing.left + else parent.right + width = Dimension.fillToConstraints + } + + + if (bodyOnClick != null) + Modifier.clickable(onClick = bodyOnClick) + else + Modifier, + verticalAlignment = Alignment.CenterVertically + ) { + leadingBody() + } + } else { + leadingBody() + } + + if (trailingContent != null) { + ConstraintLayout( + modifier = Modifier + .constrainAs(trailing) { + top link parent.top + bottom link + if (bottomContent != null) btm.top + else parent.bottom + right link parent.right + left link listItem.right + } + .padding( + end = 16.dp, + top = 8.dp, + bottom = 8.dp + ), + content = trailingContent + ) + } + if (bottomContent != null) { + ConstraintLayout( + modifier = Modifier + .constrainAs(btm) { + bottom link parent.bottom + left link parent.left + right link parent.right + width = Dimension.fillToConstraints + } + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + content = bottomContent + ) + } + } + } + if (dividerAnimated) { + AnimatedVisibility( + visible = divider, + enter = expandVertically(), + exit = shrinkVertically() + ) { + HorizontalDivider( + color = dividerColor, + thickness = dividerThickness + ) + } + } else if (divider) { + HorizontalDivider( + color = dividerColor, + thickness = dividerThickness + ) + } + } +} + +@Composable +inline fun SwitchListItem( + modifier: Modifier = Modifier, + headline: String, + supportingText: String? = null, + noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, + checked: Boolean, + noinline onCheckedChange: (Boolean) -> Unit, + crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) }, + divider: Boolean = false, + dividerColor: Color = DividerDefaults.color, + dividerThickness: Dp = DividerDefaults.Thickness, + dividerAnimated: Boolean = false, + enabled: Boolean = true +) { + val interactionSource = remember { MutableInteractionSource() } + ListItem( + modifier = if (enabled) Modifier.clickable( + interactionSource = interactionSource, + indication = LocalIndication(), + onClick = { listItemOnClick() } + ) + modifier else modifier, + headline = headline, + supportingText = supportingText, + leadingContent = leadingContent, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + interactionSource = interactionSource, + enabled = enabled + ) + }, + divider = divider, + dividerColor = dividerColor, + dividerThickness = dividerThickness, + dividerAnimated = dividerAnimated, + enabled = enabled + ) +} + +@Composable +inline fun SeparatedSwitchListItem( + modifier: Modifier = Modifier, + bodyModifier: Modifier = Modifier, + headline: String, + supportingText: String? = null, + noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, + checked: Boolean, + noinline onCheckedChange: (Boolean) -> Unit, + noinline bodyOnClick: () -> Unit, + divider: Boolean = false, + dividerColor: Color = DividerDefaults.color, + dividerThickness: Dp = DividerDefaults.Thickness, + dividerAnimated: Boolean = false, + enabled: Boolean = true +) { + ListItem( + modifier = modifier, + bodyModifier = bodyModifier, + bodyOnClick = bodyOnClick, + headline = headline, + supportingText = supportingText, + leadingContent = leadingContent, + trailingContent = { + val (div, switch) = createRefs() + VerticalDivider( + modifier = Modifier + .padding() + .padding(end = 16.dp) + .constrainAs(div) { + top link parent.top + bottom link parent.bottom + left link parent.left + right link switch.left + height = Dimension.fillToConstraints + } + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.constrainAs(switch) { + top link parent.top + bottom link parent.bottom + right link parent.right + left link div.right + }, + enabled = enabled + ) + }, + divider = divider, + dividerColor = dividerColor, + dividerThickness = dividerThickness, + dividerAnimated = dividerAnimated, + leadingAndBodyShared = true, + enabled = enabled + ) +} + +@Composable +fun SwitchCard( + modifier: Modifier = Modifier, + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + cardOnClick: () -> Unit = { onCheckedChange(!checked) } +) { + val interactionSource = remember { MutableInteractionSource() } + Card( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 10.dp) + .fillMaxWidth() + + modifier, + colors = cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + shape = RoundedCornerShape(25.dp) + ) { + Box( + Modifier.clickable( + interactionSource = interactionSource, + indication = LocalIndication(), + onClick = cardOnClick + ) + ) { + ConstraintLayout( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp) + ) { + val (txt, switch) = createRefs() + Box( + modifier = Modifier.constrainAs(txt) { + top link parent.top + bottom link parent.bottom + left link parent.left + right link switch.left + width = Dimension.fillToConstraints + height = Dimension.fillToConstraints + }, + contentAlignment = Alignment.CenterStart + ) { + Text( + text = text, + fontSize = 20.sp + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.constrainAs(switch) { + top link parent.top + bottom link parent.bottom + right link parent.right + }, + interactionSource = interactionSource + ) + } + } + } +} + + +// TODO fix +/*@Composable +inline fun SecureTextField( + value: String, + noinline onValueChange: (String) -> Unit, + passwordHidden: Boolean, + noinline visibilityOnClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle(), + noinline label: @Composable (() -> Unit)? = null, + noinline leadingIcon: @Composable (() -> Unit)? = null, + isError: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + interactionSource: MutableInteractionSource? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + TextField( + value = value, + onValueChange = onValueChange, + visualTransformation = + if (!passwordHidden) + VisualTransformation.None + else + PasswordVisualTransformation(), + label = label, + trailingIcon = { + IconButton( + onClick = visibilityOnClick + ) { + val visibilityIcon = + if (passwordHidden) Icons.Filled.Visibility else + Icons.Filled.VisibilityOff + // Provide localized description for accessibility services + val description = + if (passwordHidden) + stringResource(R.string.show_pw) + else + stringResource(R.string.hide_pw) + Icon(imageVector = visibilityIcon, contentDescription = description) + } + }, + modifier = modifier, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false + ), + maxLines = 1, + singleLine = true, + enabled = enabled, + textStyle = textStyle, + leadingIcon = leadingIcon, + isError = isError, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + shape = shape, + colors = colors + ) +}*/ + +@Composable +inline fun ToggleIconButton( + checked: Boolean, + crossinline onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = IconButtonDefaults.filledShape, + checkedColors: IconButtonColors = IconButtonDefaults.filledIconButtonColors(), + uncheckedColors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors(), + interactionSource: MutableInteractionSource? = null, + noinline content: @Composable () -> Unit +) { + if (checked) { + FilledIconButton( + onClick = { onCheckedChange(false) }, + modifier = Modifier.size(60.dp, 60.dp) + modifier, + enabled = enabled, + shape = shape, + colors = checkedColors, + interactionSource = interactionSource, + content = content + ) + } else { + FilledTonalIconButton( + onClick = { onCheckedChange(true) }, + modifier = Modifier.size(60.dp, 60.dp) + modifier, + enabled = enabled, + shape = shape, + colors = uncheckedColors, + interactionSource = interactionSource, + content = content + ) + } +} + +@Composable +inline fun ElevatedButton( + noinline onClick: () -> Unit, + crossinline icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.elevatedShape, + colors: ButtonColors = ButtonDefaults.elevatedButtonColors(), + elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding, + interactionSource: MutableInteractionSource? = null, + crossinline content: @Composable RowScope.() -> Unit +) { + ElevatedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource + ) { + Box( + modifier = Modifier.size(18.dp) + ) { + icon() + } + Spacer(Modifier.width(8.dp)) + content() + } +} + +@Composable +inline fun Button( + noinline onClick: () -> Unit, + crossinline icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, + colors: ButtonColors = ButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ButtonDefaults.buttonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding, + interactionSource: MutableInteractionSource? = null, + crossinline content: @Composable RowScope.() -> Unit +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource + ) { + Box( + modifier = Modifier.size(18.dp) + ) { + icon() + } + Spacer(Modifier.width(8.dp)) + content() + } +} + +@Composable +inline fun FilledTonalButton( + noinline onClick: () -> Unit, + crossinline icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.filledTonalShape, + colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + elevation: ButtonElevation? = ButtonDefaults.filledTonalButtonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding, + interactionSource: MutableInteractionSource? = null, + crossinline content: @Composable RowScope.() -> Unit +) { + FilledTonalButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource + ) { + Box( + modifier = Modifier.size(18.dp) + ) { + icon() + } + Spacer(Modifier.width(8.dp)) + content() + } +} + +@Composable +inline fun TextButton( + noinline onClick: () -> Unit, + crossinline icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.textShape, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + elevation: ButtonElevation? = null, + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.TextButtonWithIconContentPadding, + interactionSource: MutableInteractionSource? = null, + crossinline content: @Composable RowScope.() -> Unit +) { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource + ) { + Box( + modifier = Modifier.size(18.dp) + ) { + icon() + } + Spacer(Modifier.width(8.dp)) + content() + } +} + +class RadioButtonControllerScope @PublishedApi internal constructor() { + private val radioButtons = mutableMapOf>() + + val checkedItemAsState: MutableState = mutableStateOf(null) + + private fun processCheckedChange(id: T) { + val toModify = mutableListOf() + + radioButtons.forEach { (i) -> + if (i != id) { + toModify.add(i) + } + } + + toModify.forEach { i -> + radioButtons[i]!!.value = false + } + } + + fun addRadioButton(id: T, checked: MutableState, coroutineScope: CoroutineScope) { + radioButtons[id] = checked + coroutineScope.launch { + snapshotFlow { checked.value }.collect { + if (it) { + processCheckedChange(id) + checkedItemAsState.value = checkedItem + } + } + } + } + + fun addRadioButtons( + vararg radioButtons: Pair>, + coroutineScope: CoroutineScope + ) = + radioButtons.forEach { (id, checked) -> + addRadioButton(id, checked, coroutineScope) + } + + fun removeRadioButton(id: T) = radioButtons.remove(id) + + fun isChecked(id: T) = radioButtons[id]!!.value + val checkedItem: T? + get() = radioButtons.firstNotNullOfOrNull { if (it.value.value) it.key else null } + + fun clearCheckedItems() = radioButtons.values.forEach { it.value = false } + + interface Id { + val id: T + } + + class RandomIntId internal constructor(): Id { + override val id = Random.nextInt() + } + + class OrderedIntId internal constructor(override val id: Int): Id + + abstract class Ids, T> { + abstract operator fun component1(): IdT + abstract operator fun component2(): IdT + abstract operator fun component3(): IdT + abstract operator fun component4(): IdT + abstract operator fun component5(): IdT + abstract operator fun component6(): IdT + abstract operator fun component7(): IdT + abstract operator fun component8(): IdT + abstract operator fun component9(): IdT + abstract operator fun component10(): IdT + abstract operator fun component11(): IdT + abstract operator fun component12(): IdT + abstract operator fun component13(): IdT + abstract operator fun component14(): IdT + abstract operator fun component15(): IdT + abstract operator fun component16(): IdT + } + + class OrderedIntIds internal constructor(): Ids() { + override fun component1() = OrderedIntId(0) + override fun component2() = OrderedIntId(1) + override fun component3() = OrderedIntId(2) + override fun component4() = OrderedIntId(3) + override fun component5() = OrderedIntId(4) + override fun component6() = OrderedIntId(5) + override fun component7() = OrderedIntId(6) + override fun component8() = OrderedIntId(7) + override fun component9() = OrderedIntId(8) + override fun component10() = OrderedIntId(9) + override fun component11() = OrderedIntId(10) + override fun component12() = OrderedIntId(11) + override fun component13() = OrderedIntId(12) + override fun component14() = OrderedIntId(13) + override fun component15() = OrderedIntId(14) + override fun component16() = OrderedIntId(15) + } + + @Suppress("SameReturnValue") + class Ints internal constructor() { + operator fun component1() = 0 + operator fun component2() = 1 + operator fun component3() = 2 + operator fun component4() = 3 + operator fun component5() = 4 + operator fun component6() = 5 + operator fun component7() = 6 + operator fun component8() = 7 + operator fun component9() = 8 + operator fun component10() = 9 + operator fun component11() = 10 + operator fun component12() = 11 + operator fun component13() = 12 + operator fun component14() = 13 + operator fun component15() = 14 + operator fun component16() = 15 + } + + fun createRandomId() = RandomIntId() + fun createRandomIds() = object: Ids, Int>() { + override operator fun component1() = RandomIntId() + override operator fun component2() = RandomIntId() + override operator fun component3() = RandomIntId() + override operator fun component4() = RandomIntId() + override operator fun component5() = RandomIntId() + override operator fun component6() = RandomIntId() + override operator fun component7() = RandomIntId() + override operator fun component8() = RandomIntId() + override operator fun component9() = RandomIntId() + override operator fun component10() = RandomIntId() + override operator fun component11() = RandomIntId() + override operator fun component12() = RandomIntId() + override operator fun component13() = RandomIntId() + override operator fun component14() = RandomIntId() + override operator fun component15() = RandomIntId() + override operator fun component16() = RandomIntId() + } + + fun createOrderedId(index: Int) = OrderedIntId(index) + fun createOrderedIds() = OrderedIntIds() + + fun createIntIds() = Ints() +} + +@Composable +inline fun rememberRadioButtonController() = rememberSaveable { RadioButtonControllerScope() } + +@Composable +inline fun RadioButtonController( + content: @Composable RadioButtonControllerScope.() -> Unit +) { + content(RadioButtonControllerScope()) +} + +@Composable +inline fun CheckboxWithText( + checked: Boolean, + crossinline onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: CheckboxColors = CheckboxDefaults.colors(), + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp), + content: @Composable () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(contentPadding) + + + if (enabled) + Modifier.toggleable( + value = checked, + onValueChange = { onCheckedChange(!checked) }, + role = Role.Checkbox + ) + else Modifier + + modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + enabled = enabled, + colors = colors, + ) + Spacer(modifier = Modifier.width(8.dp)) + content() + } +} + +@Composable +inline fun SwitchWithText( + checked: Boolean, + crossinline onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SwitchColors = SwitchDefaults.colors(), + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp), + content: @Composable () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(contentPadding) + + + if (enabled) + Modifier.toggleable( + value = checked, + onValueChange = { onCheckedChange(!checked) }, + role = Role.Switch + ) + else Modifier + + modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + content() + Spacer(modifier = Modifier.weight(1f)) + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + colors = colors, + ) + } +} + + +@Composable +inline fun RadioButtonWithText( + modifier: Modifier = Modifier, + selected: Boolean, + crossinline onSelectedChange: () -> Unit, + enabled: Boolean = true, + crossinline content: @Composable () -> Unit +) { + Row( + Modifier + .fillMaxWidth() + .height(50.dp) + .selectable( + selected = selected, + onClick = { + if (enabled) onSelectedChange() + }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp) + + modifier, + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selected, + onClick = null + ) + Spacer(modifier = Modifier.width(16.dp)) + ProvideTextStyle(value = MaterialTheme.typography.bodyLarge) { + content() + } + } +} + +@Composable +inline fun SimpleAlertDialog( + open: Boolean, + crossinline onDismissRequest: () -> Unit, + crossinline onPositiveButtonClick: (() -> Unit) = {}, + crossinline onNegativeButtonClick: (() -> Unit) = {}, + positiveButtonText: String? = null, + negativeButtonText: String? = null, + title: String, + body: String? = null, + dsaString: String? = null, + crossinline onDsa: (() -> Unit) = {}, + noinline icon: @Composable (() -> Unit) = {}, +) { + if (open) { + var dsaChecked by remember { mutableStateOf(false) } + + AlertDialog( + icon = icon, + title = { + Text(text = title) + }, + text = + if (body != null || dsaString != null) { + { + if (body != null) { + Text(text = body) + } + if (dsaString != null) { + CheckboxWithText( + checked = dsaChecked, + onCheckedChange = { dsaChecked = it }, + contentPadding = PaddingValues(top = 10.dp) + ) { + Text(text = dsaString) + } + } + } + } else null, + onDismissRequest = { onDismissRequest() }, + confirmButton = { + if (!positiveButtonText.isNullOrBlank()) { + TextButton( + onClick = { + onPositiveButtonClick() + if (dsaChecked) onDsa() + onDismissRequest() + } + ) { + Text(positiveButtonText) + } + } + }, + dismissButton = if (!negativeButtonText.isNullOrBlank()) { + { + TextButton( + onClick = { + onNegativeButtonClick() + if (dsaChecked) onDsa() + onDismissRequest() + } + ) { + Text(negativeButtonText) + } + } + } else null + ) + } +} + +@Composable +inline fun RadioGroup( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column(Modifier.selectableGroup() + modifier) { + content() + } +} + +@Composable +inline fun SwipeToDismissBackground( + dismissState: SwipeToDismissBoxState, + startToEndColor: Color = Color(0xFFFF1744), + startToEndIcon: @Composable () -> Unit = { + Icon( + Icons.Default.Delete, + contentDescription = "Delete" + ) + }, + endToStartColor: Color = Color(0xFF1DE9B6), + endToStartIcon: @Composable () -> Unit = { + Icon( + Icons.Filled.Archive, + contentDescription = "Archive" + ) + } +) { + val color = when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> startToEndColor + SwipeToDismissBoxValue.EndToStart -> endToStartColor + SwipeToDismissBoxValue.Settled -> Color.Transparent + } + + Row( + modifier = Modifier + .fillMaxSize() + .background(color) + .padding(12.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + startToEndIcon() + Spacer(modifier = Modifier) + endToStartIcon() + } +} + +object CategoryDefaults { + val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp) + val dividerColor @Composable get() = MaterialTheme.colorScheme.surface + val dividerThickness: Dp = 2.dp +} + +@Composable +inline fun ColumnScope.Category( + modifier: Modifier = Modifier, + title: String? = null, + margin: PaddingValues = CategoryDefaults.margin, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + noinline content: @Composable ColumnScope.() -> Unit +) { + if (title != null) { + Text( + text = title, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 10.dp, start = 32.dp, end = 32.dp) + ) + } + Card( + modifier = modifier + .padding(margin) + .fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = cardColors(containerColor = containerColor), + content = content + ) +} \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt new file mode 100644 index 0000000..96765ac --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt @@ -0,0 +1,29 @@ +@file:Suppress("UnusedReceiverParameter", "unused") + +package com.pr0gramm3r101.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.outlined.Language +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.vectorResource +import com.pr0gramm3r101.utils.Res +import com.pr0gramm3r101.utils.license +import com.pr0gramm3r101.utils.siren_filled +import com.pr0gramm3r101.utils.siren_outlined + +inline val Icons.Outlined.Internet get() = Icons.Outlined.Language +inline val Icons.Filled.Internet get() = Icons.Filled.Language + +inline val Icons.Outlined.Website get() = Icons.Outlined.Language +inline val Icons.Filled.Website get() = Icons.Filled.Language + +inline val Icons.Outlined.License + @Composable get() = vectorResource(Res.drawable.license) +inline val Icons.Filled.License + @Composable get() = Icons.Outlined.License + +inline val Icons.Outlined.Siren + @Composable get() = vectorResource(Res.drawable.siren_outlined) +inline val Icons.Filled.Siren + @Composable get() = vectorResource(Res.drawable.siren_filled) \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt new file mode 100644 index 0000000..101ff7e --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt @@ -0,0 +1,290 @@ +@file:JvmName("Adaptive") + +package com.pr0gramm3r101.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import kotlin.jvm.JvmField +import kotlin.jvm.JvmName +import kotlin.jvm.JvmStatic + +@Composable expect inline fun currentWindowSize(): IntSize + +/** + * Calculates and returns [WindowAdaptiveInfo] of the provided context. It's a convenient function + * that uses the default [WindowSizeClass] constructor to retrieve [WindowSizeClass]. + * + * @return [WindowAdaptiveInfo] of the provided context + */ +@Composable +fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo { + val windowSize = with(LocalDensity.current) { currentWindowSize().toSize().toDpSize() } + return WindowAdaptiveInfo( + WindowSizeClass.compute(windowSize.width.value, windowSize.height.value) + ) +} + +/** + * This class collects window info that affects adaptation decisions. An adaptive layout is supposed + * to use the info from this class to decide how the layout is supposed to be adapted. + * + * @param windowSizeClass [WindowSizeClass] of the current window. + * @constructor create an instance of [WindowAdaptiveInfo] + */ +@Immutable +class WindowAdaptiveInfo(val windowSizeClass: WindowSizeClass) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WindowAdaptiveInfo) return false + if (windowSizeClass != other.windowSizeClass) return false + return true + } + + override fun hashCode() = windowSizeClass.hashCode() + + override fun toString(): String { + return "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass)" + } +} + +/** + * [WindowSizeClass] represents breakpoints for a viewport. The recommended width and height break + * points are presented through [windowWidthSizeClass] and [windowHeightSizeClass]. Designers + * should design around the different combinations of width and height buckets. Developers should + * use the different buckets to specify the layouts. Ideally apps will work well in each bucket and + * by extension work well across multiple devices. If two devices are in similar buckets they + * should behave similarly. + * + * This class is meant to be a common definition that can be shared across different device types. + * Application developers can use WindowSizeClass to have standard window buckets and design the UI + * around those buckets. Library developers can use these buckets to create different UI with + * respect to each bucket. This will help with consistency across multiple device types. + * + * A library developer use-case can be creating some navigation UI library. For a size + * class with the [WindowWidthSizeClass.EXPANDED] width it might be more reasonable to have a side + * navigation. For a [WindowWidthSizeClass.COMPACT] width, a bottom navigation might be a better + * fit. + * + * An application use-case can be applied for apps that use a list-detail pattern. The app can use + * the [WindowWidthSizeClass.MEDIUM] to determine if there is enough space to show the list and the + * detail side by side. If all apps follow this guidance then it will present a very consistent user + * experience. + * + * In some cases developers or UI systems may decide to create their own break points. A developer + * might optimize for a window that is smaller than the supported break points or larger. A UI + * system might find that some break points are better suited than the recommended break points. + * In these cases developers may wish to specify their own custom break points and match using + * a `when` statement. + * + * @see WindowWidthSizeClass + * @see WindowHeightSizeClass + */ +class WindowSizeClass private constructor( + /** + * Returns the [WindowWidthSizeClass] that corresponds to the widthDp of the window. + */ + val windowWidthSizeClass: WindowWidthSizeClass, + /** + * Returns the [WindowHeightSizeClass] that corresponds to the heightDp of the window. + */ + val windowHeightSizeClass: WindowHeightSizeClass +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as WindowSizeClass + + if (windowWidthSizeClass != other.windowWidthSizeClass) return false + if (windowHeightSizeClass != other.windowHeightSizeClass) return false + + return true + } + + override fun hashCode(): Int { + var result = windowWidthSizeClass.hashCode() + result = 31 * result + windowHeightSizeClass.hashCode() + return result + } + + override fun toString(): String { + return "WindowSizeClass {" + + "windowWidthSizeClass=$windowWidthSizeClass, " + + "windowHeightSizeClass=$windowHeightSizeClass }" + } + + companion object { + /** + * Computes the recommended [WindowSizeClass] for the given width and height in DP. + * @param dpWidth width of a window in DP. + * @param dpHeight height of a window in DP. + * @return [WindowSizeClass] that is recommended for the given dimensions. + * @throws IllegalArgumentException if [dpWidth] or [dpHeight] is + * negative. + */ + @JvmStatic + fun compute(dpWidth: Float, dpHeight: Float): WindowSizeClass { + return WindowSizeClass( + WindowWidthSizeClass.compute(dpWidth), + WindowHeightSizeClass.compute(dpHeight) + ) + } + + /** + * Computes the [WindowSizeClass] for the given width and height in pixels with density. + * @param widthPx width of a window in PX. + * @param heightPx height of a window in PX. + * @param density density of the display where the window is shown. + * @return [WindowSizeClass] that is recommended for the given dimensions. + * @throws IllegalArgumentException if [widthPx], [heightPx], or [density] is + * negative. + */ + @JvmStatic + fun compute(widthPx: Int, heightPx: Int, density: Float): WindowSizeClass { + val widthDp = widthPx / density + val heightDp = heightPx / density + return compute(widthDp, heightDp) + } + } +} + +/** + * A class to represent the width size buckets for a viewport. The possible values are [COMPACT], + * [MEDIUM], and [EXPANDED]. [WindowWidthSizeClass] should not be used as a proxy for the device + * type. It is possible to have resizeable windows in different device types. + * The viewport might change from a [COMPACT] all the way to an [EXPANDED] size class. + */ +class WindowWidthSizeClass private constructor( + private val rawValue: Int +) { + override fun toString(): String { + val name = when (this) { + COMPACT -> "COMPACT" + MEDIUM -> "MEDIUM" + EXPANDED -> "EXPANDED" + else -> "UNKNOWN" + } + return "WindowWidthSizeClass: $name" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null) return false + if (this::class != other::class) return false + + val that = other as WindowWidthSizeClass + + return rawValue == that.rawValue + } + + override fun hashCode(): Int { + return rawValue + } + + companion object { + /** + * A bucket to represent a compact width window, typical for a phone in portrait. + */ + @JvmField + val COMPACT: WindowWidthSizeClass = WindowWidthSizeClass(0) + + /** + * A bucket to represent a medium width window, typical for a phone in landscape or + * a tablet. + */ + @JvmField + val MEDIUM: WindowWidthSizeClass = WindowWidthSizeClass(1) + + /** + * A bucket to represent an expanded width window, typical for a large tablet or desktop + * form-factor. + */ + @JvmField + val EXPANDED: WindowWidthSizeClass = WindowWidthSizeClass(2) + + /** + * Returns a recommended [WindowWidthSizeClass] for the width of a window given the width + * in DP. + * @param dpWidth the width of the window in DP + * @return A recommended size class for the width + * @throws IllegalArgumentException if the width is negative + */ + internal fun compute(dpWidth: Float): WindowWidthSizeClass { + require(dpWidth >= 0) { "Width must be positive, received $dpWidth" } + return when { + dpWidth < 600 -> COMPACT + dpWidth < 840 -> MEDIUM + else -> EXPANDED + } + } + } +} + +class WindowHeightSizeClass private constructor( + private val rawValue: Int +) { + + override fun toString(): String { + val name = when (this) { + COMPACT -> "COMPACT" + MEDIUM -> "MEDIUM" + EXPANDED -> "EXPANDED" + else -> "UNKNOWN" + } + return "WindowHeightSizeClass: $name" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null) return false + if (this::class != other::class) return false + + val that = other as WindowHeightSizeClass + + return rawValue == that.rawValue + } + + override fun hashCode(): Int { + return rawValue + } + + companion object { + /** + * A bucket to represent a compact height, typical for a phone that is in landscape. + */ + @JvmField + val COMPACT: WindowHeightSizeClass = WindowHeightSizeClass(0) + + /** + * A bucket to represent a medium height, typical for a phone in portrait or a tablet. + */ + @JvmField + val MEDIUM: WindowHeightSizeClass = WindowHeightSizeClass(1) + + /** + * A bucket to represent an expanded height window, typical for a large tablet or a + * desktop form-factor. + */ + @JvmField + val EXPANDED: WindowHeightSizeClass = WindowHeightSizeClass(2) + + /** + * Returns a recommended [WindowHeightSizeClass] for the height of a window given the + * height in DP. + * @param dpHeight the height of the window in DP + * @return A recommended size class for the height + * @throws IllegalArgumentException if the height is negative + */ + internal fun compute(dpHeight: Float): WindowHeightSizeClass { + require(dpHeight >= 0) { "Height must be positive, received $dpHeight" } + return when { + dpHeight < 480 -> COMPACT + dpHeight < 900 -> MEDIUM + else -> EXPANDED + } + } + } +} diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt new file mode 100644 index 0000000..bc7bab0 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt @@ -0,0 +1,76 @@ +package com.pr0gramm3r101.utils + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +data class NavigationItem( + val name: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector = selectedIcon, + val onClick: () -> Unit +) + +@Composable +fun SimpleNavigationBar( + selectedItem: Int, + vararg items: NavigationItem, + modifier: Modifier = Modifier, +) { + NavigationBar(modifier) { + items.forEachIndexed { index, item -> + NavigationBarItem( + icon = { + Icon( + imageVector = + if (selectedItem == index) + items[index].selectedIcon + else + items[index].unselectedIcon, + contentDescription = item.name + ) + }, + label = { Text(item.name) }, + selected = selectedItem == index, + onClick = items[index].onClick + ) + } + } +} + +@Composable +fun SimpleNavigationRail( + selectedItem: Int, + vararg items: NavigationItem, + modifier: Modifier = Modifier, +) { + NavigationRail( + modifier, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + items.forEachIndexed { index, item -> + NavigationRailItem( + icon = { + Icon( + imageVector = + if (selectedItem == index) + items[index].selectedIcon + else + items[index].unselectedIcon, + contentDescription = item.name + ) + }, + label = { Text(item.name) }, + selected = selectedItem == index, + onClick = items[index].onClick + ) + } + } +} \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt new file mode 100644 index 0000000..e1b8ec7 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt @@ -0,0 +1,183 @@ +@file:Suppress("UnusedReceiverParameter", "unused", "NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") + +package com.pr0gramm3r101.utils + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocal +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.LayoutDirection +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.FontResource +import tech.annexflow.constraintlayout.compose.ConstrainScope +import tech.annexflow.constraintlayout.compose.ConstrainedLayoutReference +import tech.annexflow.constraintlayout.compose.ConstraintLayoutBaseScope +import tech.annexflow.constraintlayout.compose.HorizontalAnchorable +import tech.annexflow.constraintlayout.compose.VerticalAnchorable + +@Composable +fun Typography(fontRes: FontResource) = Typography().let { + val font = FontFamily(Font(fontRes)) + it.copy( + displayLarge = it.displayLarge.copy(fontFamily = font), + displayMedium = it.displayMedium.copy(fontFamily = font), + displaySmall = it.displaySmall.copy(fontFamily = font), + headlineLarge = it.headlineLarge.copy(fontFamily = font), + headlineMedium = it.headlineMedium.copy(fontFamily = font), + headlineSmall = it.headlineSmall.copy(fontFamily = font), + titleLarge = it.titleLarge.copy(fontFamily = font), + titleMedium = it.titleMedium.copy(fontFamily = font), + titleSmall = it.titleSmall.copy(fontFamily = font), + bodyLarge = it.bodyLarge.copy(fontFamily = font), + bodyMedium = it.bodyMedium.copy(fontFamily = font), + bodySmall = it.bodySmall.copy(fontFamily = font), + labelLarge = it.labelLarge.copy(fontFamily = font), + labelMedium = it.labelMedium.copy(fontFamily = font), + labelSmall = it.labelSmall.copy(fontFamily = font) + ) +} + +val ConstrainScope.left get() = absoluteLeft +val ConstrainScope.right get() = absoluteRight + +val ConstrainedLayoutReference.left get() = absoluteLeft +val ConstrainedLayoutReference.right get() = absoluteRight + +inline infix fun HorizontalAnchorable.link( + anchor: ConstraintLayoutBaseScope.HorizontalAnchor +) = linkTo(anchor) + +inline infix fun VerticalAnchorable.link( + anchor: ConstraintLayoutBaseScope.VerticalAnchor +) = linkTo(anchor) + + +expect fun Modifier.clearFocusOnKeyboardDismiss(): Modifier + +operator fun Modifier.plus(other: Modifier) = then(other) + +var ClipboardManager.text + get() = getText() + set(value) = setText(value!!) + +@Composable +expect fun ToggleNavScrimEffect(enabled: Boolean = false) + +interface SupportClipboardManager { + suspend fun setText(string: String) + suspend fun getText(): String? + suspend fun hasText(): Boolean = getText()?.isNotEmpty() == true + + fun setTextListener(listener: (String) -> Unit) +} + +val LocalSupportClipboardManager: ProvidableCompositionLocal = compositionLocalOf { + throw IllegalStateException("This CompositionLocal hasn't been provided.") +} + +val supportClipboardManagerImpl + @Composable get() = LocalClipboardManager().toSupport() + +inline fun ClipboardManager.toSupport() = object : SupportClipboardManager { + private var listener: ((String) -> Unit)? = null + + override suspend fun setText(string: String) { + setText(string.toAnnotatedString()) + listener?.invoke(string) + } + override suspend fun getText() = this@toSupport.getText()?.toString() ?: "" + + override fun setTextListener(listener: (String) -> Unit) { + this.listener = listener + } +} + +fun String.toAnnotatedString() = AnnotatedString(this) + +@Composable +inline operator fun CompositionLocal.invoke() = current + +val LocalSnackbar: ProvidableCompositionLocal = compositionLocalOf { throw NullPointerException() } + +inline fun resetFocus( + keyboardController: SoftwareKeyboardController?, + focusManager: FocusManager +) { + keyboardController?.hide() + focusManager.clearFocus(true) +} + +val unsupported: Nothing + get() = throw UnsupportedOperationException() + +val WindowWidthSizeClass.rawValue: Int get() = + when (this) { + WindowWidthSizeClass.COMPACT -> 0 + WindowWidthSizeClass.MEDIUM -> 1 + WindowWidthSizeClass.EXPANDED -> 2 + else -> throw IllegalArgumentException("Unsupported WindowWidthSizeClass: $this") + } + +val WindowHeightSizeClass.rawValue: Int get() = + when (this) { + WindowHeightSizeClass.COMPACT -> 0 + WindowHeightSizeClass.MEDIUM -> 1 + WindowHeightSizeClass.EXPANDED -> 2 + else -> throw IllegalArgumentException("Unsupported WindowHeightSizeClass: $this") + } + +operator fun WindowWidthSizeClass.compareTo(other: WindowWidthSizeClass) = this.rawValue.compareTo(other.rawValue) +operator fun WindowHeightSizeClass.compareTo(other: WindowHeightSizeClass) = this.rawValue.compareTo(other.rawValue) + +val WindowSizeClass.width get() = windowWidthSizeClass +val WindowSizeClass.height get() = windowHeightSizeClass + +val WindowAdaptiveInfo.widthSizeClass get() = windowSizeClass.width +@Suppress("unused") +val WindowAdaptiveInfo.heightSizeClass get() = windowSizeClass.height + +fun String.encodeJSON(): String { + val out = StringBuilder() + for (i in indices) { + when (val c: Char = get(i)) { + '"', '\\', '/' -> out.append('\\').append(c) + '\t' -> out.append("\\t") + '\b' -> out.append("\\b") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + else -> if (c.code <= 0x1F) { + out.append("\\u${c.code.toString(16).padStart(4, '0')}") + } else { + out.append(c) + } + } + } + return "$out" +} + +@Composable +operator fun PaddingValues.plus(other: PaddingValues) = PaddingValues( + top = calculateTopPadding() + other.calculateTopPadding(), + bottom = calculateBottomPadding() + other.calculateTopPadding(), + start = when (LocalLayoutDirection()) { + LayoutDirection.Ltr -> calculateLeftPadding(LocalLayoutDirection()) + LayoutDirection.Rtl -> calculateRightPadding(LocalLayoutDirection()) + }, + end = when (LocalLayoutDirection()) { + LayoutDirection.Ltr -> calculateRightPadding(LocalLayoutDirection()) + LayoutDirection.Rtl -> calculateLeftPadding(LocalLayoutDirection()) + } +) + +expect val materialYouAvailable: Boolean \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt new file mode 100644 index 0000000..beda22b --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt @@ -0,0 +1,5 @@ +package com.pr0gramm3r101.utils + +expect object UtilsLibrary { + fun init(vararg args: Any) +} \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt new file mode 100644 index 0000000..5586057 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt @@ -0,0 +1,19 @@ +package com.pr0gramm3r101.utils.crypto + +/** + * Platform-specific base64 encoding/decoding using native implementations + */ +expect object Base64 { + fun encode(bytes: ByteArray): String + fun decode(base64: String): ByteArray +} + +/** + * Extension function to encode ByteArray to base64 string + */ +fun ByteArray.toBase64(): String = Base64.encode(this) + +/** + * Extension function to decode base64 string to ByteArray + */ +fun String.fromBase64(): ByteArray = Base64.decode(this) \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt new file mode 100644 index 0000000..6fa549c --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt @@ -0,0 +1,8 @@ +package com.pr0gramm3r101.utils.crypto + +/** + * Platform-specific HMAC-SHA256 implementation + */ +expect object Hmac { + suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray +} diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt new file mode 100644 index 0000000..97bbc71 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt @@ -0,0 +1,59 @@ +package com.pr0gramm3r101.utils.crypto + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Derives authentication secret from password using HKDF + * Matches web client implementation: + * - Salt: fromchat.user:${username} encoded as bytes + * - Info: "auth-secret" encoded as bytes + * - Output: 32 bytes, then base64 encoded + */ +object PasswordHash { + /** + * Derives a 32-byte key using HKDF-SHA256 + * Implementation of RFC 5869 HKDF + */ + suspend fun hkdfExtractAndExpand( + inputKeyMaterial: ByteArray, + salt: ByteArray, + info: ByteArray, + length: Int = 32 + ): ByteArray = withContext(Dispatchers.Default) { + // HKDF-Extract: PRK = HMAC-Hash(salt, IKM) + val prk = Hmac.hmacSha256(salt, inputKeyMaterial) + + // HKDF-Expand: OKM = HKDF-Expand(PRK, info, L) + val hashLen = 32 // SHA-256 output length + val n = (length + hashLen - 1) / hashLen // number of blocks + + val okm = ByteArray(length) + var offset = 0 + + var t = ByteArray(0) + for (i in 0 until n) { + val tInput = t + info + byteArrayOf((i + 1).toByte()) + t = Hmac.hmacSha256(prk, tInput) + + val copyLen = minOf(t.size, length - offset) + t.copyInto(okm, offset, 0, copyLen) + offset += copyLen + } + + okm + } +} + +/** + * Derives authentication secret so the raw password never leaves the client + */ +suspend fun deriveAuthSecret(username: String, password: String): String { + val salt = "fromchat.user:$username".encodeToByteArray() + val info = "auth-secret".encodeToByteArray() + val passwordBytes = password.encodeToByteArray() + + val derived = PasswordHash.hkdfExtractAndExpand(passwordBytes, salt, info, 32) + + return derived.toBase64() +} diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt new file mode 100644 index 0000000..e0d8e27 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt @@ -0,0 +1,37 @@ +package com.pr0gramm3r101.utils.settings + +/** + * Async version of Settings API using suspend functions + */ +interface AsyncSettings { + suspend fun putString(key: String, value: String) + suspend fun getString(key: String, default: String = ""): String + + suspend fun putInt(key: String, value: Int) + suspend fun getInt(key: String, default: Int = 0): Int + + suspend fun putLong(key: String, value: Long) + suspend fun getLong(key: String, default: Long = 0L): Long + + suspend fun putFloat(key: String, value: Float) + suspend fun getFloat(key: String, default: Float = 0f): Float + + suspend fun putBoolean(key: String, value: Boolean) + suspend fun getBoolean(key: String, default: Boolean = false): Boolean + + suspend fun putStringSet(key: String, value: Set) + suspend fun getStringSet(key: String, default: Set = emptySet()): Set + + suspend fun putStringList(key: String, value: List) + suspend fun getStringList(key: String, default: List = emptyList()): List + + suspend fun remove(key: String) + + suspend fun contains(key: String): Boolean + suspend fun clear() +} + +/** + * Global async settings instance + */ +expect val asyncSettings: AsyncSettings diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt new file mode 100644 index 0000000..343c5cc --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt @@ -0,0 +1,33 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.pr0gramm3r101.utils.settings + +interface Settings { + companion object { + inline fun get() = settings + inline operator fun invoke() = settings + } + + fun putString(key: String, value: String) + fun getString(key: String, default: String = ""): String + + fun putInt(key: String, value: Int) + fun getInt(key: String, default: Int = 0): Int + + fun putLong(key: String, value: Long) + fun getLong(key: String, default: Long = 0L): Long + + fun putFloat(key: String, value: Float) + fun getFloat(key: String, default: Float = 0f): Float + + fun putBoolean(key: String, value: Boolean) + fun getBoolean(key: String, default: Boolean = false): Boolean + + fun putStringSet(key: String, value: Set) + fun getStringSet(key: String, default: Set = emptySet()): Set + + fun putStringList(key: String, value: List) + fun getStringList(key: String, default: List = emptyList()): List + + fun remove(key: String) +} \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt new file mode 100644 index 0000000..7dd5e78 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt @@ -0,0 +1,3 @@ +package com.pr0gramm3r101.utils.settings + +expect val settings: Settings \ No newline at end of file diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt new file mode 100644 index 0000000..0e67139 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt @@ -0,0 +1,29 @@ +package com.pr0gramm3r101.utils.storage + +/** + * Authentication token storage + */ +object AuthStorage { + private val storage = NamespacedStorage("auth") + + suspend fun getToken(): String? { + val token = storage.getString("token") + return token.ifEmpty { null } + } + + suspend fun setToken(token: String?) { + if (token != null) { + storage.putString("token", token) + } else { + storage.remove("token") + } + } + + suspend fun clearToken() { + storage.remove("token") + } + + suspend fun putString(key: String, value: String) = storage.putString(key, value) + suspend fun getString(key: String, default: String = ""): String = storage.getString(key, default) + suspend fun remove(key: String) = storage.remove(key) +} diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt new file mode 100644 index 0000000..bcc6fa9 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt @@ -0,0 +1,55 @@ +package com.pr0gramm3r101.utils.storage + +/** + * Server configuration data + */ +data class ServerConfigData( + val serverUrl: String, + val httpsEnabled: Boolean +) + +/** + * Server configuration storage + */ +object ServerConfigStorage { + private val storage = NamespacedStorage("server_config") + + private const val SERVER_URL_KEY = "server_url" + private const val HTTPS_ENABLED_KEY = "https_enabled" + + suspend fun getServerUrl(): String? { + val url = storage.getString(SERVER_URL_KEY) + return if (url.isEmpty()) null else url + } + + suspend fun setServerUrl(url: String) { + storage.putString(SERVER_URL_KEY, url) + } + + suspend fun getHttpsEnabled(): Boolean? { + return if (storage.contains(HTTPS_ENABLED_KEY)) { + storage.getBoolean(HTTPS_ENABLED_KEY, true) + } else { + null + } + } + + suspend fun setHttpsEnabled(enabled: Boolean) { + storage.putBoolean(HTTPS_ENABLED_KEY, enabled) + } + + suspend fun hasConfiguration(): Boolean { + return getServerUrl() != null + } + + suspend fun getConfig(): ServerConfigData { + val url = getServerUrl() ?: "fromchat.ru" + val https = getHttpsEnabled() ?: true + return ServerConfigData(url, https) + } + + suspend fun saveConfig(config: ServerConfigData) { + setServerUrl(config.serverUrl) + setHttpsEnabled(config.httpsEnabled) + } +} diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt new file mode 100644 index 0000000..d729012 --- /dev/null +++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt @@ -0,0 +1,59 @@ +package com.pr0gramm3r101.utils.storage + +import com.pr0gramm3r101.utils.settings.AsyncSettings +import com.pr0gramm3r101.utils.settings.asyncSettings + +/** + * Generic key-value storage using AsyncSettings + */ +class Storage(private val settings: AsyncSettings = asyncSettings) { + suspend fun putString(key: String, value: String) = settings.putString(key, value) + suspend fun getString(key: String, default: String = ""): String = settings.getString(key, default) + + suspend fun putInt(key: String, value: Int) = settings.putInt(key, value) + suspend fun getInt(key: String, default: Int = 0): Int = settings.getInt(key, default) + + suspend fun putLong(key: String, value: Long) = settings.putLong(key, value) + suspend fun getLong(key: String, default: Long = 0L): Long = settings.getLong(key, default) + + suspend fun putFloat(key: String, value: Float) = settings.putFloat(key, value) + suspend fun getFloat(key: String, default: Float = 0f): Float = settings.getFloat(key, default) + + suspend fun putBoolean(key: String, value: Boolean) = settings.putBoolean(key, value) + suspend fun getBoolean(key: String, default: Boolean = false): Boolean = settings.getBoolean(key, default) + + suspend fun putStringSet(key: String, value: Set) = settings.putStringSet(key, value) + suspend fun getStringSet(key: String, default: Set = emptySet()): Set = settings.getStringSet(key, default) + + suspend fun putStringList(key: String, value: List) = settings.putStringList(key, value) + suspend fun getStringList(key: String, default: List = emptyList()): List = settings.getStringList(key, default) + + suspend fun remove(key: String) = settings.remove(key) + suspend fun contains(key: String): Boolean = settings.contains(key) + suspend fun clear() = settings.clear() +} + +/** + * Namespaced storage for specific features + */ +class NamespacedStorage(private val prefix: String, private val storage: Storage = Storage()) { + private fun key(name: String) = "$prefix:$name" + + suspend fun putString(name: String, value: String) = storage.putString(key(name), value) + suspend fun getString(name: String, default: String = ""): String = storage.getString(key(name), default) + + suspend fun putInt(name: String, value: Int) = storage.putInt(key(name), value) + suspend fun getInt(name: String, default: Int = 0): Int = storage.getInt(key(name), default) + + suspend fun putLong(name: String, value: Long) = storage.putLong(key(name), value) + suspend fun getLong(name: String, default: Long = 0L): Long = storage.getLong(key(name), default) + + suspend fun putFloat(name: String, value: Float) = storage.putFloat(key(name), value) + suspend fun getFloat(name: String, default: Float = 0f): Float = storage.getFloat(key(name), default) + + suspend fun putBoolean(name: String, value: Boolean) = storage.putBoolean(key(name), value) + suspend fun getBoolean(name: String, default: Boolean = false): Boolean = storage.getBoolean(key(name), default) + + suspend fun remove(name: String) = storage.remove(key(name)) + suspend fun contains(name: String): Boolean = storage.contains(key(name)) +} diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt new file mode 100644 index 0000000..25196eb --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt @@ -0,0 +1,9 @@ +package com.pr0gramm3r101.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalWindowInfo + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +actual inline fun currentWindowSize() = LocalWindowInfo.current.containerSize \ No newline at end of file diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt new file mode 100644 index 0000000..746dbf3 --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt @@ -0,0 +1,25 @@ +@file:OptIn(ExperimentalNativeApi::class) +@file:Suppress("NOTHING_TO_INLINE") + +package com.pr0gramm3r101.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import platform.posix._OSSwapInt16 +import platform.posix._OSSwapInt32 +import platform.posix._OSSwapInt64 +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.Platform.isLittleEndian + +actual fun Modifier.clearFocusOnKeyboardDismiss() = this + +@Composable +actual fun ToggleNavScrimEffect(enabled: Boolean) {} +actual val materialYouAvailable get() = false + +inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short +inline fun htonl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data +inline fun htonll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data +inline fun ntohs(data: UShort) = if (isLittleEndian) _OSSwapInt16(data) else data +inline fun ntohl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data +inline fun ntohll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data \ No newline at end of file diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt new file mode 100644 index 0000000..9005bfd --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt @@ -0,0 +1,5 @@ +package com.pr0gramm3r101.utils + +actual object UtilsLibrary { + actual fun init(vararg args: Any) {} +} \ No newline at end of file diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt new file mode 100644 index 0000000..4ed01bd --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt @@ -0,0 +1,23 @@ +package com.pr0gramm3r101.utils.crypto + +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData + +actual object Base64 { + actual fun encode(bytes: ByteArray): String { + return bytes.usePinned { pinned -> + val nsData = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) + nsData.base64EncodedStringWithOptions(0u) + } + } + + actual fun decode(base64: String): ByteArray { + val nsData = NSData.create(base64EncodedString = base64, options = 0u) ?: return ByteArray(0) + val length = nsData.length.toInt() + val bytes = nsData.bytes ?: return ByteArray(0) + return ByteArray(length) { index -> + bytes.reinterpret()[index] + } + } +} diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt new file mode 100644 index 0000000..0b9d178 --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt @@ -0,0 +1,29 @@ +package com.pr0gramm3r101.utils.crypto + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.CommonCrypto.CCHmac +import platform.CommonCrypto.kCCHmacAlgSHA256 + +actual object Hmac { + actual suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = withContext(Dispatchers.Default) { + val result = ByteArray(32) + + key.usePinned { keyPinned -> + data.usePinned { dataPinned -> + result.usePinned { resultPinned -> + CCHmac( + algorithm = kCCHmacAlgSHA256, + key = keyPinned.addressOf(0), + keyLength = key.size.toULong(), + data = dataPinned.addressOf(0), + dataLength = data.size.toULong(), + macOut = resultPinned.addressOf(0) + ) + } + } + } + + result + } +} diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt new file mode 100644 index 0000000..c1fd475 --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt @@ -0,0 +1,99 @@ +package com.pr0gramm3r101.utils.settings + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.Foundation.NSUserDefaults + +class IosAsyncSettings : AsyncSettings { + private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults + + override suspend fun putString(key: String, value: String) = withContext(Dispatchers.Default) { + defaults.setObject(value, key) + defaults.synchronize() + } + + override suspend fun getString(key: String, default: String): String = withContext(Dispatchers.Default) { + defaults.stringForKey(key) ?: default + } + + override suspend fun putInt(key: String, value: Int) = withContext(Dispatchers.Default) { + defaults.setInteger(value.toLong(), key) + defaults.synchronize() + } + + override suspend fun getInt(key: String, default: Int): Int = withContext(Dispatchers.Default) { + val value = defaults.integerForKey(key) + if (value != null) value.toInt() else default + } + + override suspend fun putLong(key: String, value: Long) = withContext(Dispatchers.Default) { + defaults.setObject(value, key) + defaults.synchronize() + } + + override suspend fun getLong(key: String, default: Long): Long = withContext(Dispatchers.Default) { + (defaults.objectForKey(key) as? platform.Foundation.NSNumber)?.longValue ?: default + } + + override suspend fun putFloat(key: String, value: Float) = withContext(Dispatchers.Default) { + defaults.setFloat(value, key) + defaults.synchronize() + } + + override suspend fun getFloat(key: String, default: Float): Float = withContext(Dispatchers.Default) { + val value = defaults.floatForKey(key) + if (value != null) value.toFloat() else default + } + + override suspend fun putBoolean(key: String, value: Boolean) = withContext(Dispatchers.Default) { + defaults.setBool(value, key) + defaults.synchronize() + } + + override suspend fun getBoolean(key: String, default: Boolean): Boolean = withContext(Dispatchers.Default) { + if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + default + } + } + + override suspend fun putStringSet(key: String, value: Set) = withContext(Dispatchers.Default) { + defaults.setObject(value.toList(), key) + defaults.synchronize() + } + + override suspend fun getStringSet(key: String, default: Set): Set = withContext(Dispatchers.Default) { + val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default + list.filterIsInstance().toSet() + } + + override suspend fun putStringList(key: String, value: List) = withContext(Dispatchers.Default) { + defaults.setObject(value, key) + defaults.synchronize() + } + + override suspend fun getStringList(key: String, default: List): List = withContext(Dispatchers.Default) { + val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default + list.filterIsInstance() + } + + override suspend fun remove(key: String) = withContext(Dispatchers.Default) { + defaults.removeObjectForKey(key) + defaults.synchronize() + } + + override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) { + defaults.objectForKey(key) != null + } + + override suspend fun clear() = withContext(Dispatchers.Default) { + val domain = defaults.persistentDomainForName(defaults.bundleIdentifier() ?: "") + domain?.keys?.forEach { key -> + defaults.removeObjectForKey(key as String) + } + defaults.synchronize() + } +} + +actual val asyncSettings: AsyncSettings = IosAsyncSettings() diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt new file mode 100644 index 0000000..4d82e55 --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt @@ -0,0 +1,70 @@ +package com.pr0gramm3r101.utils.settings + +import platform.Foundation.NSUserDefaults + +// TODO fix +class IosSettings : Settings { + private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults + + override fun putString(key: String, value: String) { + defaults.setObject(value, key) + } + + override fun getString(key: String, default: String): String { + return defaults.stringForKey(key) ?: default + } + + override fun putInt(key: String, value: Int) { + defaults.setInteger(value.toLong(), key) + } + + override fun getInt(key: String, default: Int): Int { + return defaults.integerForKey(key).toInt() + } + + override fun putLong(key: String, value: Long) { + defaults.setObject(value, key) + } + + override fun getLong(key: String, default: Long): Long { + return defaults.objectForKey(key) as? Long ?: default + } + + override fun putFloat(key: String, value: Float) { + defaults.setFloat(value, key) + } + + override fun getFloat(key: String, default: Float): Float { + return defaults.floatForKey(key) + } + + override fun putBoolean(key: String, value: Boolean) { + defaults.setBool(value, key) + } + + override fun getBoolean(key: String, default: Boolean): Boolean { + return defaults.boolForKey(key) + } + + override fun putStringSet(key: String, value: Set) { + defaults.setObject(value.toList(), key) + } + + override fun getStringSet(key: String, default: Set): Set { + val list = defaults.objectForKey(key) as? List<*> ?: return default + return list.filterIsInstance().toSet() + } + + override fun putStringList(key: String, value: List) { + defaults.setObject(value, key) + } + + override fun getStringList(key: String, default: List): List { + val list = defaults.objectForKey(key) as? List<*> ?: return default + return list.filterIsInstance() + } + + override fun remove(key: String) { + defaults.removeObjectForKey(key) + } +} \ No newline at end of file diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt new file mode 100644 index 0000000..0d678c9 --- /dev/null +++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt @@ -0,0 +1,3 @@ +package com.pr0gramm3r101.utils.settings + +actual val settings: Settings get() = IosSettings() \ No newline at end of file