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 c209e78..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ 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 b2dfe3d..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ 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 4f0f1d6..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ 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 948a307..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ 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 1b9a695..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ 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