diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..be32af4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,44 @@
+---
+name: Bug report
+about: Report a bug or an issue with existing features
+title: "[\U0001F41E] "
+labels: bug, triage
+assignees: Nek-12
+
+---
+
+### Description (required):
+
+describe the difference between expected and actual behavior, if not evident
+
+### Steps to reproduce (required)
+
+Steps to reproduce the behavior:
+
+1. ...
+
+---
+
+
+ Stacktrace (if applicable):
+
+```plaintext
+
+```
+
+
+
+---
+
+
+ Relevant code:
+
+```kotlin
+
+```
+
+
+
+---
+
+- [ ] This issue hasn't been reported already
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..b313b32
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,27 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: "[\U0001F680] "
+labels: feature, triage
+assignees: Nek-12
+
+---
+
+### Description (required)
+
+describe what you are trying to solve and what is missing
+
+---
+
+
+ Relevant code (if applicable):
+
+```kotlin
+
+```
+
+
+
+---
+
+- [ ] This issue hasn't been reported already
diff --git a/.github/ISSUE_TEMPLATE/something-else.md b/.github/ISSUE_TEMPLATE/something-else.md
new file mode 100644
index 0000000..e43c9a5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/something-else.md
@@ -0,0 +1,13 @@
+---
+name: Something else
+about: Ask a question, request documentation, or something else
+title: "[โ] "
+labels: question, triage
+assignees: Nek-12
+
+---
+
+---
+
+- [ ] This question or issue hasn't been asked or reported already via Discussions or Issues
+- [ ] I have read the [documentation and FAQ](https://opensource.respawn.pro/FlowMVI/#/faq)
diff --git a/.github/changelog_config.json b/.github/changelog_config.json
new file mode 100644
index 0000000..ddc8bde
--- /dev/null
+++ b/.github/changelog_config.json
@@ -0,0 +1,57 @@
+{
+ "template" : "#{{CHANGELOG}}",
+ "pr_template" : "- #{{TITLE_ONLY}}",
+ "trim_values" : true,
+ "categories" : [
+ {
+ "title" : "## ๐ New Features",
+ "labels" : [
+ "feat",
+ "feature"
+ ]
+ },
+ {
+ "title" : "## ๐งจ Api Changes",
+ "labels" : [
+ "feat!",
+ "breaking",
+ "api"
+ ]
+ },
+ {
+ "title" : "## ๐ Bug Fixes",
+ "labels" : [
+ "fix",
+ "bug"
+ ]
+ },
+ {
+ "title" : "## โ Other",
+ "labels" : []
+ },
+ {
+ "title" : "## ๐ Docs",
+ "labels" : [
+ "doc",
+ "docs"
+ ]
+ }
+ ],
+ "custom_placeholders" : [
+ {
+ "name" : "TITLE_ONLY",
+ "source" : "TITLE",
+ "transformer" : {
+ "method" : "regexr",
+ "pattern" : "(\\w+(\\(.+\\))?: ?)?(.+)",
+ "target" : "$2"
+ }
+ }
+ ],
+ "label_extractor" : [
+ {
+ "pattern" : "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)",
+ "target" : "$1"
+ }
+ ]
+}
diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties
index 2cbbe0c..dce30b2 100644
--- a/.github/ci-gradle.properties
+++ b/.github/ci-gradle.properties
@@ -1,6 +1,9 @@
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
+org.gradle.jvmargs=-Xmx3g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -Dfile.encoding=UTF-8
+kotlin.daemon.jvmargs=-Xmx3g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -XX:MaxMetaspaceSize=1g
android.useAndroidX=true
kotlin.code.style=official
+org.gradle.caching=true
+org.gradle.parallel=true
android.enableR8.fullMode=true
org.gradle.configureondemand=true
android.enableJetifier=false
@@ -10,15 +13,16 @@ android.experimental.enableSourceSetPathsMap=true
android.experimental.cacheCompileLibResources=true
kotlin.mpp.enableCInteropCommonization=true
kotlin.mpp.stability.nowarn=true
-kotlin.mpp.androidSourceSetLayoutVersion=2
-android.nonFinalResIds=true
kotlin.mpp.androidGradlePluginCompatibility.nowarn=true
-# changed
-org.gradle.caching=true
-org.gradle.parallel=true
-warningsAsErrors=true
-org.gradle.daemon=false
-org.gradle.workers.max=2
-org.gradle.unsafe.configuration-cache=false
+org.gradle.unsafe.configuration-cache=true
+kotlin.mpp.androidSourceSetLayoutVersion=2
android.disableResourceValidation=false
+org.gradle.daemon=true
+android.nonFinalResIds=true
+kotlin.native.ignoreIncorrectDependencies=true
kotlinx.atomicfu.enableJvmIrTransformation=true
+org.jetbrains.compose.experimental.macos.enabled=true
+org.gradle.configuration-cache.problems=warn
+nl.littlerobots.vcu.resolver=true
+org.gradle.console=plain
+CI=true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..b552cbd
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/assign_self.yml b/.github/workflows/assign_self.yml
new file mode 100644
index 0000000..b4d9afe
--- /dev/null
+++ b/.github/workflows/assign_self.yml
@@ -0,0 +1,16 @@
+name: Assign self to PR
+
+on:
+ pull_request:
+ types: [ opened ]
+ branches:
+ - master
+ - main
+
+jobs:
+ assign_author:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: samspills/assign-pr-to-author@v1.0.2
+ with:
+ repo-token: '${{ secrets.GITHUB_TOKEN }}'
diff --git a/.github/workflows/autotag.yml b/.github/workflows/autotag.yml
new file mode 100644
index 0000000..e990f14
--- /dev/null
+++ b/.github/workflows/autotag.yml
@@ -0,0 +1,27 @@
+name: Auto-tag
+on:
+ pull_request:
+ types:
+ - closed
+ branches:
+ - master
+
+jobs:
+ build:
+ if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'autorelease')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.merge_commit_sha }}
+ fetch-depth: '0'
+
+ - name: Bump version and push tag
+ uses: anothrNick/github-tag-action@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.OPENSOURCE_PAT }} # Use PAT to trigger workflows
+ CUSTOM_TAG: ${{ github.event.pull_request.title }}
+ WITH_V: false
+ PRERELEASE: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dde4cf0..3989a38 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,18 +15,13 @@ jobs:
runs-on: macos-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- - name: Create local properties
- env:
- LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }}
- run: echo "$LOCAL_PROPERTIES" > local.properties
-
- name: set up JDK
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: 'zulu'
check-latest: true
@@ -40,11 +35,16 @@ jobs:
with:
xcode-version: latest
+ - name: Create local properties
+ env:
+ LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }}
+ run: echo "$LOCAL_PROPERTIES" > local.properties
+
- name: Run detekt
run: ./gradlew detektAll
- name: Build
- run: ./gradlew assemble --stacktrace
+ run: ./gradlew assemble --stacktrace --no-configuration-cache
- name: Unit tests
- run: ./gradlew allTests --stacktrace
+ run: ./gradlew allTests --stacktrace --no-configuration-cache
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index bddb92a..ae3e3dd 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -19,13 +19,13 @@ jobs:
runs-on: macos-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: set up JDK
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: 'zulu'
check-latest: true
@@ -44,19 +44,19 @@ jobs:
run: cp ./README.md ./docs/README.md
- name: Generate docs
- run: ./gradlew dokkaHtmlMultiModule --no-configuration-cache
+ run: ./gradlew :dokkaHtmlMultiModule --no-configuration-cache
- name: Move docs to the parent docs dir
- run: cp -r ./build/dokka ./docs/javadocs/
+ run: cp -r ./build/dokka/htmlMultiModule/ ./docs/javadocs/
- name: Setup Pages
- uses: actions/configure-pages@v3
+ uses: actions/configure-pages@v5
- name: Upload pages
- uses: actions/upload-pages-artifact@v1
+ uses: actions/upload-pages-artifact@v3
with:
path: './docs/'
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v1
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 5b0b2f1..bc15f23 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,9 +1,15 @@
-name: publish
+name: Publish a new Release
on:
push:
tags:
- '1.*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ required: true
+ type: string
+ description: 'Tag to use for the release and changelog'
concurrency:
group: "publish"
@@ -12,15 +18,16 @@ concurrency:
jobs:
publish:
runs-on: macos-latest
+ environment: publishing
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: set up JDK
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: 'zulu'
check-latest: true
@@ -43,4 +50,27 @@ jobs:
env:
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }}
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }}
- run: ./gradlew publishAllPublicationsToSonatypeRepository --stacktrace -Dorg.gradle.workers.max=1
+ SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
+ SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
+ # It's important to not upload in parallel or duplicate repos will be created
+ # repository creds are broken with gradle 8.6 https://github.com/gradle/gradle/issues/24040
+ run: ./gradlew publishAllPublicationsToSonatypeRepository -Dorg.gradle.parallel=false --stacktrace --no-configuration-cache
+
+ - name: Generate Changelog
+ uses: mikepenz/release-changelog-builder-action@v4
+ id: build_changelog
+ with:
+ commitMode: true
+ configuration: ./github/changelog_config.json
+
+ - name: Create GH release
+ uses: ncipollo/release-action@v1.14.0
+ id: create_release
+ with:
+ draft: true
+ artifactErrorsFailBuild: false
+ prerelease: false
+ body: ${{steps.build_changelog.outputs.changelog}}
+ tag: ${{ inputs.tag != '' && inputs.tag || github.ref_name }}
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 0d47e48..02f4f5a 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -2,6 +2,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 01923f5..06b7eb4 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,9 @@
import nl.littlerobots.vcu.plugin.versionCatalogUpdate
import nl.littlerobots.vcu.plugin.versionSelector
+import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
+import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradleSubplugin
import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockMismatchReport
+import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin
import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@@ -11,7 +14,13 @@ plugins {
alias(libs.plugins.dokka)
alias(libs.plugins.dependencyAnalysis)
alias(libs.plugins.atomicfu)
- kotlin("plugin.serialization") version libs.versions.kotlin.get() apply false
+ alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.compose) apply false
+ alias(libs.plugins.serialization) apply false
+ // plugins already on a classpath (conventions)
+ // alias(libs.plugins.androidApplication) apply false
+ // alias(libs.plugins.androidLibrary) apply false
+ // alias(libs.plugins.kotlinMultiplatform) apply false
}
buildscript {
@@ -25,10 +34,22 @@ buildscript {
allprojects {
group = Config.artifactId
version = Config.versionName
+ plugins.withType().configureEach {
+ the().apply {
+ enableIntrinsicRemember = true
+ enableNonSkippingGroupOptimization = true
+ enableStrongSkippingMode = true
+ stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_definitions.txt")
+ if (properties["enableComposeCompilerReports"] == "true") {
+ val metricsDir = layout.buildDirectory.dir("compose_metrics")
+ metricsDestination = metricsDir
+ reportsDestination = metricsDir
+ }
+ }
+ }
tasks.withType().configureEach {
compilerOptions {
jvmTarget.set(Config.jvmTarget)
- languageVersion.set(Config.kotlinVersion)
freeCompilerArgs.addAll(Config.jvmCompilerArgs)
optIn.addAll(Config.optIns)
}
@@ -133,8 +154,10 @@ tasks {
distributionType = Wrapper.DistributionType.BIN
}
}
-extensions.findByType()?.run {
- yarnLockMismatchReport = YarnLockMismatchReport.WARNING
- reportNewYarnLock = true
- yarnLockAutoReplace = false
+rootProject.plugins.withType().configureEach {
+ rootProject.the().apply {
+ yarnLockMismatchReport = YarnLockMismatchReport.WARNING // NONE | FAIL | FAIL_AFTER_BUILD
+ reportNewYarnLock = true
+ yarnLockAutoReplace = true
+ }
}
diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts
index 5f0643d..ab3dafa 100644
--- a/buildSrc/settings.gradle.kts
+++ b/buildSrc/settings.gradle.kts
@@ -9,3 +9,4 @@ dependencyResolutionManagement {
}
}
}
+rootProject.name = "buildSrc"
diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt
index da19709..963ce70 100644
--- a/buildSrc/src/main/kotlin/Config.kt
+++ b/buildSrc/src/main/kotlin/Config.kt
@@ -7,7 +7,6 @@
import org.gradle.api.JavaVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
object Config {
@@ -17,15 +16,20 @@ object Config {
const val artifactId = "$group.$artifact"
const val majorRelease = 1
- const val minorRelease = 3
- const val patch = 2
+ const val minorRelease = 4
+ const val patch = 0
const val postfix = ""
const val versionName = "$majorRelease.$minorRelease.$patch$postfix"
+ const val supportEmail = "hello@respawn.pro"
+ const val vendorName = "Respawn Open Source Team"
+ const val vendorId = "respawn-app"
const val url = "https://github.com/respawn-app/kmputils"
+ const val developerUrl = "https://respawn.pro"
+ const val licenseFile = "LICENSE.txt"
const val licenseName = "The Apache Software License, Version 2.0"
- const val licenseUrl = "http://www.apache.org/licenses/LICENSE-2.0.txt"
- const val scmUrl = "https://github.com/respawn-app/kmmutils.git"
+ const val licenseUrl = "https://www.apache.org/licenses/LICENSE-2.0.txt"
+ const val scmUrl = "https://github.com/respawn-app/KMPUtils.git"
const val description = """A collection of Kotlin Multiplatform essentials"""
// kotlin
@@ -49,10 +53,9 @@ object Config {
val jvmTarget = JvmTarget.JVM_11
val javaVersion = JavaVersion.VERSION_11
- val kotlinVersion = KotlinVersion.KOTLIN_1_9
const val compileSdk = 34
const val targetSdk = compileSdk
- const val minSdk = 21
+ const val minSdk = 24
const val appMinSdk = 26
const val publishingVariant = "release"
diff --git a/buildSrc/src/main/kotlin/ConfigureAndroid.kt b/buildSrc/src/main/kotlin/ConfigureAndroid.kt
index 2a59b72..f99ada6 100644
--- a/buildSrc/src/main/kotlin/ConfigureAndroid.kt
+++ b/buildSrc/src/main/kotlin/ConfigureAndroid.kt
@@ -4,11 +4,8 @@ import com.android.build.api.dsl.CommonExtension
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Project
-fun Project.configureAndroid(
- commonExtension: CommonExtension<*, *, *, *, *, *>,
-) = commonExtension.apply {
+fun CommonExtension<*, *, *, *, *, *>.configureAndroid() = apply {
compileSdk = Config.compileSdk
- // val libs by versionCatalog
defaultConfig {
minSdk = Config.minSdk
@@ -66,15 +63,10 @@ fun Project.configureAndroid(
}
}
}
-
- // composeOptions {
- // kotlinCompilerExtensionVersion = libs.requireVersion("compose-compiler")
- // useLiveLiterals = true
- // }
}
fun Project.configureAndroidLibrary(variant: LibraryExtension) = variant.apply {
- configureAndroid(this)
+ configureAndroid()
testFixtures {
enable = true
diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt
index fa6bab7..f4eb6ff 100644
--- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt
+++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt
@@ -3,11 +3,12 @@
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getValue
import org.gradle.kotlin.dsl.getting
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyBuilder
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
-@OptIn(ExperimentalWasmDsl::class)
-@Suppress("LongParameterList", "CyclomaticComplexMethod")
+@OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class)
fun Project.configureMultiplatform(
ext: KotlinMultiplatformExtension,
jvm: Boolean = true,
@@ -18,33 +19,29 @@ fun Project.configureMultiplatform(
tvOs: Boolean = true,
macOs: Boolean = true,
watchOs: Boolean = true,
+ windows: Boolean = true,
wasmJs: Boolean = true,
+ wasmWasi: Boolean = false, // TODO: Coroutines do not support wasmWasi yet
+ configure: KotlinHierarchyBuilder.Root.() -> Unit = {},
) = ext.apply {
val libs by versionCatalog
explicitApi()
- applyDefaultHierarchyTemplate()
+ applyDefaultHierarchyTemplate(configure)
withSourcesJar(true)
if (linux) {
linuxX64()
linuxArm64()
- mingwX64()
}
- if (js) {
- js(IR) {
- browser()
- nodejs()
- binaries.library()
- }
- }
+ if (windows) mingwX64()
- if (android) androidTarget {
- publishLibraryVariants("release")
+ if (js) js(IR) {
+ browser()
+ nodejs()
+ binaries.library()
}
- if (jvm) jvm()
-
if (wasmJs) wasmJs {
moduleName = this@configureMultiplatform.name
nodejs()
@@ -52,6 +49,14 @@ fun Project.configureMultiplatform(
binaries.library()
}
+ if (wasmWasi) wasmWasi()
+
+ if (android) androidTarget {
+ publishLibraryVariants("release")
+ }
+
+ if (jvm) jvm()
+
sequence {
if (iOs) {
yield(iosX64())
@@ -73,13 +78,7 @@ fun Project.configureMultiplatform(
yield(watchosDeviceArm64())
yield(watchosSimulatorArm64())
}
- }.forEach {
- it.binaries.framework {
- binaryOption("bundleId", Config.artifactId)
- binaryOption("bundleVersion", Config.versionName)
- baseName = Config.artifactId
- }
- }
+ }.toList() // for now, do nothing, but iterate the lazy sequence
sourceSets.apply {
if (jvm) {
@@ -92,7 +91,6 @@ fun Project.configureMultiplatform(
all {
languageSettings {
progressiveMode = true
- languageVersion = Config.kotlinVersion.version
Config.optIns.forEach { optIn(it) }
}
}
diff --git a/buildSrc/src/main/kotlin/ConfigurePublication.kt b/buildSrc/src/main/kotlin/ConfigurePublication.kt
index 8056f67..099fd97 100644
--- a/buildSrc/src/main/kotlin/ConfigurePublication.kt
+++ b/buildSrc/src/main/kotlin/ConfigurePublication.kt
@@ -16,27 +16,25 @@ import org.gradle.plugins.signing.Sign
* Configures Maven publishing to sonatype for this project
*/
fun Project.publishMultiplatform() {
- val properties by localProperties
+ val properties = localProperties()
val isReleaseBuild = properties["release"]?.toString().toBoolean()
-
val javadocTask = tasks.named("emptyJavadocJar") // TODO: dokka does not support kmp javadocs yet
afterEvaluate {
requireNotNull(extensions.findByType()).apply {
- sonatypeRepository(isReleaseBuild, properties)
-
publications.withType().configureEach {
+ groupId = rootProject.group.toString()
artifact(javadocTask)
configurePom()
configureVersion(isReleaseBuild)
}
+ sonatypeRepository(isReleaseBuild, properties)
}
signPublications(isReleaseBuild, properties)
}
tasks.withType().configureEach {
dependsOn(javadocTask)
- dependsOn(tasks.withType())
}
}
@@ -44,6 +42,8 @@ fun Project.publishMultiplatform() {
* Publish the android artifact
*/
fun Project.publishAndroid(ext: LibraryExtension) = with(ext) {
+ val properties = localProperties()
+ val isReleaseBuild = requireNotNull(properties["release"]).toString().toBooleanStrict()
publishing {
singleVariant(Config.publishingVariant) {
withSourcesJar()
@@ -52,27 +52,21 @@ fun Project.publishAndroid(ext: LibraryExtension) = with(ext) {
}
afterEvaluate {
- val properties by localProperties
- val isReleaseBuild = properties["release"]?.toString().toBoolean()
-
requireNotNull(extensions.findByType()).apply {
- sonatypeRepository(isReleaseBuild, properties)
-
- publications {
- maybeCreate(Config.publishingVariant, MavenPublication::class).apply {
- from(components[Config.publishingVariant])
- suppressPomMetadataWarningsFor(Config.publishingVariant)
- groupId = rootProject.group.toString()
- artifactId = project.name
-
- configurePom()
- configureVersion(isReleaseBuild)
- }
+ publications.maybeCreate(Config.publishingVariant, MavenPublication::class).apply {
+ from(components[Config.publishingVariant])
+ groupId = rootProject.group.toString()
+ configurePom()
+ configureVersion(isReleaseBuild)
}
+ sonatypeRepository(isReleaseBuild, properties)
}
signPublications(isReleaseBuild, properties)
}
+ tasks.withType().configureEach {
+ dependsOn(tasks.withType())
+ }
tasks.withType().configureEach {
dependsOn(tasks.withType())
}
diff --git a/buildSrc/src/main/kotlin/PublishingExt.kt b/buildSrc/src/main/kotlin/PublishingExt.kt
index ebabbe6..7d2ce27 100644
--- a/buildSrc/src/main/kotlin/PublishingExt.kt
+++ b/buildSrc/src/main/kotlin/PublishingExt.kt
@@ -32,18 +32,22 @@ internal fun MavenPublication.configurePom() = pom {
}
developers {
developer {
- id.set("respawn-app")
- name.set("Respawn")
- email.set("hello@respawn.pro")
- url.set("https://respawn.pro")
- organization.set("Respawn")
+ id.set(Config.vendorId)
+ name.set(Config.vendorName)
+ email.set(Config.supportEmail)
+ url.set(Config.developerUrl)
+ organization.set(Config.vendorName)
organizationUrl.set(url)
}
}
- scm { url.set(Config.scmUrl) }
+ scm {
+ url.set(Config.scmUrl)
+ }
}
internal fun PublishingExtension.sonatypeRepository(release: Boolean, localProps: Properties) = repositories {
+ val username = localProps["sonatypeUsername"]?.toString() ?: System.getenv("SONATYPE_USERNAME")
+ val password = localProps["sonatypePassword"]?.toString() ?: System.getenv("SONATYPE_PASSWORD")
maven {
name = "sonatype"
url = URI(
@@ -54,16 +58,15 @@ internal fun PublishingExtension.sonatypeRepository(release: Boolean, localProps
}
)
credentials {
- username = localProps["sonatypeUsername"]?.toString()
- password = localProps["sonatypePassword"]?.toString()
+ this.username = username.takeIf { !it.isNullOrBlank() }
+ this.password = password.takeIf { !it.isNullOrBlank() }
}
}
}
-internal fun Project.signPublications(isRelease: Boolean, localProps: Properties) =
+internal fun Project.signPublications(isRelease: Boolean, localProps: Properties) {
requireNotNull(extensions.findByType()).apply {
val publishing = requireNotNull(extensions.findByType())
-
val signingKey: String? = localProps["signing.key"]?.toString()
val signingPassword: String? = localProps["signing.password"]?.toString()
@@ -85,3 +88,4 @@ internal fun Project.signPublications(isRelease: Boolean, localProps: Properties
}
}
}
+}
diff --git a/buildSrc/src/main/kotlin/Util.kt b/buildSrc/src/main/kotlin/Util.kt
index 32d89d8..7461b4b 100644
--- a/buildSrc/src/main/kotlin/Util.kt
+++ b/buildSrc/src/main/kotlin/Util.kt
@@ -1,9 +1,4 @@
-@file:Suppress(
- "MemberVisibilityCanBePrivate",
- "MissingPackageDeclaration",
- "UndocumentedPublicProperty",
- "UndocumentedPublicFunction"
-)
+@file:Suppress("MissingPackageDeclaration", "UndocumentedPublicFunction", "UndocumentedPublicProperty")
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
@@ -20,7 +15,7 @@ import java.util.Properties
* to obtain a version/lib use:
* ```
* val libs by versionCatalog
- * libs.requireVersion("androidxCompose")
+ * libs.findVersion("androidxCompose").get().toString()
* libs.requireLib("androidx.core.ktx")
* ```
*/
@@ -54,12 +49,11 @@ fun List.toJavaArrayString() = buildString {
fun String.toBase64() = Base64.getEncoder().encodeToString(toByteArray())
-val Project.localProperties
- get() = lazy {
- Properties().apply {
- load(FileInputStream(File(rootProject.rootDir, "local.properties")))
- }
- }
+fun Project.localProperties() = Properties().apply {
+ val file = File(rootProject.rootDir.absolutePath, "local.properties")
+ require(file.exists()) { "Please create root local.properties file" }
+ load(FileInputStream(file))
+}
fun stabilityLevel(version: String): Int {
Config.stabilityLevels.forEachIndexed { index, postfix ->
diff --git a/buildSrc/src/main/kotlin/android-library.gradle.kts b/buildSrc/src/main/kotlin/android-library.gradle.kts
deleted file mode 100644
index ad950de..0000000
--- a/buildSrc/src/main/kotlin/android-library.gradle.kts
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-plugins {
- id("com.android.library")
- kotlin("multiplatform")
-}
-
-android {
- configureAndroidLibrary(this)
-}
diff --git a/buildSrc/src/main/kotlin/pro.respawn.android-library.gradle.kts b/buildSrc/src/main/kotlin/pro.respawn.android-library.gradle.kts
deleted file mode 100644
index 7d34e31..0000000
--- a/buildSrc/src/main/kotlin/pro.respawn.android-library.gradle.kts
+++ /dev/null
@@ -1,20 +0,0 @@
-plugins {
- kotlin("android")
- id("com.android.library")
- id("maven-publish")
- signing
-}
-
-kotlin {
- explicitApi()
-}
-
-android {
- configureAndroidLibrary(this)
- publishAndroid(this)
-
- kotlinOptions {
- jvmTarget = Config.jvmTarget.target
- languageVersion = Config.kotlinVersion.version
- }
-}
diff --git a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt
index a21904d..674a43e 100644
--- a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt
+++ b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt
@@ -4,6 +4,8 @@ package pro.respawn.kmmutils.common
import kotlin.contracts.contract
import kotlin.enums.enumEntries
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.jvm.JvmName
/**
@@ -120,3 +122,34 @@ public fun Throwable?.rethrowErrors(): Exception? = this?.let { it as? Exception
*/
@JvmName("rethrowErrorsNotNull")
public fun Throwable.rethrowErrors(): Exception = this as? Exception? ?: throw this
+
+/**
+ * Encode this string to Base64
+ */
+public fun String.toBase64(): String = encodeToByteArray().toBase64()
+
+/**
+ * Encode this byte array to base 64
+ */
+@OptIn(ExperimentalEncodingApi::class)
+public fun ByteArray.toBase64(): String = Base64.Default.encode(this)
+
+/**
+ * Add quotes to this string, if not present. Will do nothing to `null`s.
+ *
+ * Will turn :
+ * * `foo` -> `"foo"`,
+ * * `"foo"` -> `"foo"` (no change)
+ * * `null` -> `null` (no change)
+ */
+@get:JvmName("quotedOrNull")
+public inline val String?.quoted: String? get() = this?.quoted
+
+/**
+ * Add quotes to this string, if not present.
+ *
+ * Will turn :
+ * * `foo` -> `"foo"`,
+ * * `"foo"` -> `"foo"` (no change)
+ */
+public inline val String.quoted: String get() = """"${removeSurrounding("\"")}""""
diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts
new file mode 100644
index 0000000..c31a171
--- /dev/null
+++ b/compose/build.gradle.kts
@@ -0,0 +1,64 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+
+plugins {
+ id(libs.plugins.kotlin.multiplatform.id)
+ id(libs.plugins.androidLibrary.id)
+ alias(libs.plugins.compose)
+ alias(libs.plugins.compose.compiler)
+ id("maven-publish")
+ signing
+}
+
+android {
+ configureAndroidLibrary(this)
+ namespace = "${Config.namespace}.compose"
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+kotlin {
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ configureMultiplatform(
+ ext = this,
+ jvm = true,
+ android = true,
+ iOs = true,
+ macOs = true,
+ watchOs = false,
+ tvOs = false,
+ linux = false,
+ js = true,
+ wasmJs = true,
+ windows = false,
+ ) {
+ common {
+ group("web") {
+ withJs()
+ withWasmJs()
+ }
+ }
+ }
+ sourceSets {
+ commonMain.dependencies {
+ api(compose.components.resources)
+
+ api(libs.lifecycle.runtime)
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.animationGraphics)
+ implementation(compose.animation)
+ }
+ jvmMain.dependencies {
+ implementation(compose.desktop.common)
+ }
+ androidMain.dependencies {
+ api(libs.androidx.lifecycle.viewmodel)
+ api(libs.androidx.activity.compose)
+ implementation(projects.system)
+ }
+ }
+}
+
+publishMultiplatform()
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ActivityComposeExt.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ActivityComposeExt.kt
new file mode 100644
index 0000000..af7a82d
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ActivityComposeExt.kt
@@ -0,0 +1,121 @@
+package pro.respawn.kmmutils.compose
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.os.Build
+import android.view.WindowManager
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.result.ActivityResult
+import androidx.annotation.RequiresPermission
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+import pro.respawn.kmmutils.system.android.launchCatching
+import pro.respawn.kmmutils.system.android.withApiLevel
+
+/**
+ * Possible values for [orientation]:
+ *
+ * * SCREEN_ORIENTATION_UNSPECIFIED,
+ * * SCREEN_ORIENTATION_LANDSCAPE,
+ * * SCREEN_ORIENTATION_PORTRAIT,
+ * * SCREEN_ORIENTATION_USER,
+ * * SCREEN_ORIENTATION_BEHIND,
+ * * SCREEN_ORIENTATION_SENSOR,
+ * * SCREEN_ORIENTATION_NOSENSOR,
+ * * SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
+ * * SCREEN_ORIENTATION_SENSOR_PORTRAIT,
+ * * SCREEN_ORIENTATION_REVERSE_LANDSCAPE,
+ * * SCREEN_ORIENTATION_REVERSE_PORTRAIT,
+ * * SCREEN_ORIENTATION_FULL_SENSOR,
+ * * SCREEN_ORIENTATION_USER_LANDSCAPE,
+ * * SCREEN_ORIENTATION_USER_PORTRAIT,
+ * * SCREEN_ORIENTATION_FULL_USER,
+ * * SCREEN_ORIENTATION_LOCKED.
+ *
+ * @param orientation the int constant of [Activity.getRequestedOrientation]
+ */
+@Composable
+public fun LockScreenOrientation(orientation: Int) {
+ val context = LocalContext.current
+ DisposableEffect(orientation) {
+ val activity = context.findActivity() ?: return@DisposableEffect onDispose {}
+ val originalOrientation = activity.requestedOrientation
+ activity.requestedOrientation = orientation
+ onDispose {
+ // restore original orientation when view disappears
+ activity.requestedOrientation = originalOrientation
+ }
+ }
+}
+
+/**
+ * Traverses the context hierarchy until an activity is found, or null if not present.
+ */
+public fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
+/**
+ * Disables overlays over the content of current activity for as long as this composable is in the composition
+ * Works only on API 31 (S) and above, otherwise does nothing.
+ *
+ * Requires permission [Manifest.permission.HIDE_OVERLAY_WINDOWS]
+ * @see android.view.Window.setHideOverlayWindows
+ */
+@Composable
+@RequiresPermission(Manifest.permission.HIDE_OVERLAY_WINDOWS)
+public fun DisallowOverlays(): Unit = withApiLevel(Build.VERSION_CODES.S) {
+ val context = LocalContext.current
+ DisposableEffect(Unit) {
+ val window = context.findActivity()?.window
+ window?.setHideOverlayWindows(true)
+ onDispose { window?.setHideOverlayWindows(false) }
+ }
+}
+
+/**
+ * Sets flags to the parent activity, if exists, temporarily, until the composition is left.
+ * The flags are added and then removed, other flags are not changed
+ * Possible values of [flags] are from [WindowManager.LayoutParams]
+ *
+ * @see [android.view.Window.setFlags]
+ */
+@Composable
+public fun SetWindowFlags(flags: Int) {
+ val context = LocalContext.current
+ DisposableEffect(flags) {
+ val window = context.findActivity()?.window
+ window?.addFlags(flags)
+ onDispose { window?.clearFlags(flags) }
+ }
+}
+
+/**
+ * A shortcut for sending messages to any social media app installed in the
+ * user's device. This will optionally return an activity result as it's why an
+ * extension for [ManagedActivityResultLauncher].
+ *
+ * @param text the message to send.
+ * @param onAppNotFound a callback for error, if ever no apps are installed that can handle the request.
+ */
+
+public inline fun ManagedActivityResultLauncher.shareAsText(
+ text: String,
+ onAppNotFound: (e: Exception) -> Unit
+) {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_TEXT, text)
+ type = "text/plain"
+ }
+ val shareIntent = Intent.createChooser(intent, null)
+ launchCatching(
+ input = shareIntent,
+ onNotFound = onAppNotFound,
+ )
+}
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/RememberRetainedValue.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/RememberRetainedValue.kt
new file mode 100644
index 0000000..82c5727
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/RememberRetainedValue.kt
@@ -0,0 +1,37 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.currentCompositeKeyHash
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.viewModel
+
+/**
+ * Remember the value produced by [calculation].
+ * It behaves similarly to [rememberSaveable], but uses [ViewModel] to store the value.
+ *
+ * You **must** have a [ViewModelStoreOwner] provided via a composition local to use this function
+ */
+@Composable
+@Suppress("ComposableParametersOrdering")
+public fun rememberRetainedValue(
+ key: String? = null,
+ viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
+ "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
+ },
+ onDispose: (T.() -> Unit)? = null,
+ calculation: () -> T,
+): T {
+ val finalKey = if (!key.isNullOrBlank()) key else currentCompositeKeyHash.toString()
+ return viewModel(
+ key = finalKey, viewModelStoreOwner = viewModelStoreOwner
+ ) { CacheViewModel(calculation(), onDispose) }.value
+}
+
+private class CacheViewModel(val value: T, private val dispose: (T.() -> Unit)?) : ViewModel() {
+
+ override fun onCleared() {
+ dispose?.invoke(value)
+ }
+}
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.android.kt
new file mode 100644
index 0000000..7ce1e54
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.android.kt
@@ -0,0 +1,25 @@
+package pro.respawn.kmmutils.compose
+
+import android.view.WindowManager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalContext
+
+/**
+ * Keeps the screen of the current activity on while this composable is in composition.
+ */
+@Composable
+public actual fun KeepScreenOn(enabled: Boolean) {
+ val context = LocalContext.current
+ DisposableEffect(enabled) {
+ val window = context.findActivity()?.window
+ if (enabled) {
+ window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ onDispose {
+ window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ } else {
+ onDispose { }
+ }
+ }
+}
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/SdkIntExt.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/SdkIntExt.android.kt
new file mode 100644
index 0000000..0a7a472
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/SdkIntExt.android.kt
@@ -0,0 +1,23 @@
+package pro.respawn.kmmutils.compose
+
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+
+/**
+ * @returns whether Blur composition is currently supported.
+ * Currently returns `false` on all platforms except android as this is not yet implemented in Compose.
+ */
+@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+public val supportsBlur: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+/**
+ * @return whether dynamic colors are supported. Only supported on Android since S, all other platforms return false
+ */
+@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+public val supportsDynamicColors: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+/**
+ * @return whether dynamic colors are supported. Only supported on Android since S, all other platforms return false
+ */
+@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
+public val supportsShaders: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/Service.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/Service.kt
new file mode 100644
index 0000000..76c5658
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/Service.kt
@@ -0,0 +1,52 @@
+package pro.respawn.kmmutils.compose
+
+import android.app.Service
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Binder
+import android.os.IBinder
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+
+/**
+ * Remembers a reference to a [Service] [BoundService]'s [Binder] [BoundServiceBinder] in composition.
+ * The function will bind to a service while it is in the composition and unbind when it has left the composition.
+ * The function will maintain a [ServiceConnection] and return a [State] with a nullable value of service.
+ *
+ * Binding to the service takes some time and connection breakages can occur,
+ * so the return state value may be null at times.
+ */
+@Composable
+@Suppress("ComposableParametersOrdering")
+public inline fun rememberBoundLocalService(
+ flags: Int = Context.BIND_AUTO_CREATE,
+ noinline getService: @DisallowComposableCalls BoundServiceBinder.() -> BoundService,
+): State {
+ val context: Context = LocalContext.current
+ val boundService = remember(context) { mutableStateOf(null) }
+
+ val serviceConnection: ServiceConnection = remember(context, getService) {
+ object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ boundService.value = (service as BoundServiceBinder).getService()
+ }
+
+ override fun onServiceDisconnected(arg0: ComponentName) {
+ boundService.value = null
+ }
+ }
+ }
+ DisposableEffect(context, serviceConnection, flags) {
+ context.bindService(Intent(context, BoundService::class.java), serviceConnection, flags)
+
+ onDispose { context.unbindService(serviceConnection) }
+ }
+ return boundService
+}
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.android.kt
new file mode 100644
index 0000000..5ddf862
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.android.kt
@@ -0,0 +1,91 @@
+package pro.respawn.kmmutils.compose.resources
+
+import android.text.format.DateFormat
+import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
+import androidx.compose.animation.graphics.res.animatedVectorResource
+import androidx.compose.animation.graphics.vector.AnimatedImageVector
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.booleanResource
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.integerArrayResource
+import androidx.compose.ui.res.integerResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.unit.Dp
+
+@Composable
+public fun Int.plural(
+ quantity: Int,
+ vararg args: Any,
+): String = pluralStringResource(this, quantity, formatArgs = args)
+
+@Composable
+public fun Int?.plural(quantity: Int, vararg args: Any): String? = this?.plural(quantity, args)
+
+@Composable
+public fun Int.vector(): ImageVector = ImageVector.vectorResource(this)
+
+@Composable
+public fun Int?.vector(): ImageVector? = this?.vector()
+
+@ExperimentalAnimationGraphicsApi
+@Composable
+public fun Int.animatedVector(): AnimatedImageVector = AnimatedImageVector.animatedVectorResource(id = this)
+
+@ExperimentalAnimationGraphicsApi
+@Composable
+public fun Int?.animatedVector(): AnimatedImageVector? = this?.animatedVector()
+
+@Composable
+public fun Int.string(vararg args: Any): String = stringResource(id = this, formatArgs = args)
+
+@Composable
+public fun Int?.string(vararg args: Any): String? = this?.string(args)
+
+@Composable
+public fun Int.painter(): Painter = painterResource(this)
+
+@Composable
+public fun Int?.painter(): Painter? = this?.painter()
+
+@Composable
+public fun Int.integerRes(): Int = integerResource(this)
+
+@Composable
+public fun Int?.integerRes(): Int? = this?.integerRes()
+
+@Composable
+public fun Int.integerArrayRes(): IntArray = integerArrayResource(this)
+
+@Composable
+public fun Int?.integerArrayRes(): IntArray? = this?.integerArrayRes()
+
+@Composable
+public fun Int.booleanRes(): Boolean = booleanResource(this)
+
+@Composable
+public fun Int?.booleanRes(): Boolean? = this?.booleanRes()
+
+@Composable
+public fun Int.color(): Color = colorResource(this)
+
+@Composable
+public fun Int?.color(): Color? = this?.color()
+
+@Composable
+public fun Int.dimen(): Dp = dimensionResource(this)
+
+@Composable
+public fun Int?.dimen(): Dp? = this?.dimen()
+
+public val isSystem24Hour: Boolean @Composable get() = DateFormat.is24HourFormat(LocalContext.current)
+
+public val displayDensity: Int @Composable get() = LocalConfiguration.current.densityDpi
diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.android.kt
new file mode 100644
index 0000000..a25f4cd
--- /dev/null
+++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.android.kt
@@ -0,0 +1,22 @@
+package pro.respawn.kmmutils.compose.windowsize
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+
+/**
+ * Get the window size of the current window in pixels.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+@ExperimentalComposeUiApi
+public actual val windowSizePx: IntSize
+ @ReadOnlyComposable
+ @Composable get() = with(LocalDensity.current) {
+ with(LocalConfiguration.current) {
+ IntSize(screenWidthDp.dp.roundToPx(), screenHeightDp.dp.roundToPx())
+ }
+ }
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Annotations.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Annotations.kt
new file mode 100644
index 0000000..ac39d53
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Annotations.kt
@@ -0,0 +1,140 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.TextUnit
+
+/**
+ * Annotates this string with the [spanStyle] provided. The whole string is annotated.
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.annotate(spanStyle: SpanStyle): AnnotatedString = buildAnnotatedString {
+ withStyle(spanStyle) {
+ append(this@annotate)
+ }
+}
+
+/**
+ * Produces an annotated string identical to the original string
+ * For those times when an api requires AnnotatedString but you don't want to build one
+ */
+public fun String.annotate(): AnnotatedString = AnnotatedString(this)
+
+/**
+ * Annotates this string using the [builder] provided. The string is the argument of the lambda.
+ *
+ * @return the [AnnotatedString] created
+ */
+public inline fun String.annotate(
+ builder: AnnotatedString.Builder.(String) -> Unit
+): AnnotatedString = buildAnnotatedString { builder(this@annotate) }
+
+/**
+ * Applies [FontWeight.Bold] to `this` string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.bold(): AnnotatedString = weight(FontWeight.Bold)
+
+/**
+ * Applies [FontStyle.Italic] to `this` string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.italic(): AnnotatedString = style(FontStyle.Italic)
+
+/**
+ * Strikes through this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.strike(): AnnotatedString = annotate(SpanStyle(textDecoration = TextDecoration.LineThrough))
+
+/**
+ * Adds an underline to this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.underline(): AnnotatedString = annotate(SpanStyle(textDecoration = TextDecoration.Underline))
+
+/**
+ * Adds text [decorations] provided to this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.decorate(
+ vararg decorations: TextDecoration
+): AnnotatedString = annotate(SpanStyle(textDecoration = TextDecoration.combine(decorations.asList())))
+
+/**
+ * Applies absolute [size] to the text
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.size(size: TextUnit): AnnotatedString = annotate(SpanStyle(fontSize = size))
+
+/**
+ * Sets the background [color] to this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.background(color: Color): AnnotatedString = annotate(SpanStyle(background = color))
+
+/**
+ * Sets the foreground [color] of this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.color(color: Color): AnnotatedString = annotate(SpanStyle(color = color))
+
+/**
+ * Sets the font [weight] for this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.weight(weight: FontWeight): AnnotatedString = annotate(SpanStyle(fontWeight = weight))
+
+/**
+ * Applies a [style] [FontStyle] to this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.style(style: FontStyle): AnnotatedString = annotate(SpanStyle(fontStyle = style))
+
+/**
+ * Adds a shadow to `this` string. The shadow has a [color], an [offset] and a [blurRadius]
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.shadow(
+ color: Color,
+ offset: Offset = Offset.Zero,
+ blurRadius: Float = 0.0f
+): AnnotatedString = annotate(SpanStyle(shadow = Shadow(color, offset, blurRadius)))
+
+/**
+ * Changes the [fontFamily] of this string
+ *
+ * @return the [AnnotatedString] created
+ */
+public fun String.font(fontFamily: FontFamily): AnnotatedString = annotate(SpanStyle(fontFamily = fontFamily))
+
+// TODO: Waiting for compose update
+
+// public fun String.clickable(onClick: () -> Unit): AnnotatedString = annotate {
+// pushLink(LinkAnnotation.Clickable("clickable") { onClick() })
+// pushStyle(SpanStyle(textDecoration = TextDecoration.Underline))
+// append(this@clickable)
+// pop()
+// pop()
+// }
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/GraphicsLayerExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/GraphicsLayerExt.kt
new file mode 100644
index 0000000..0dac98f
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/GraphicsLayerExt.kt
@@ -0,0 +1,54 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.GraphicsLayerScope
+
+/**
+ * Scale the element uniformly. Alias for [GraphicsLayerScope.scaleX] and [GraphicsLayerScope.scaleY].
+ * Getter returns the average value of the scale
+ */
+public var GraphicsLayerScope.scale: Float
+ get() = (scaleX + scaleY) / 2f
+ set(value) {
+ scaleX = value
+ scaleY = value
+ }
+
+/**
+ * Inverts the X and Y coordinates of a given offset
+ */
+public inline val Offset.inverted: Offset get() = Offset(-x, -y)
+
+/**
+ * Inverts the X coordinate of a given offset
+ */
+public inline val Offset.invertedX: Offset get() = copy(x = -x)
+
+/**
+ * Inverts the Y coordinate of a given offset
+ */
+public inline val Offset.invertedY: Offset get() = copy(x = -x)
+
+/**
+ * Offset expressed as a fraction of the size of the widget.
+ */
+public typealias FractionalOffset = Offset
+
+/**
+ * Converts fractional offset to absolute offset
+ */
+public fun FractionalOffset.asAbsolute(size: Size): Offset = Offset(x * size.width, y * size.height)
+
+/**
+ * Converts absolute offset to fractional offset
+ */
+public fun Offset.asFractional(size: Size): FractionalOffset = Offset(x / size.width, y / size.height)
+
+/**
+ * Applies an [offset] to this [GraphicsLayerScope]
+ */
+public fun GraphicsLayerScope.offset(offset: Offset) {
+ translationX = offset.x
+ translationY = offset.y
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Haptics.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Haptics.kt
new file mode 100644
index 0000000..bbb36c9
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Haptics.kt
@@ -0,0 +1,24 @@
+@file:Suppress("unused")
+
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+
+/**
+ * Plays a medium haptic feedback, similar to force-touch or long-tap effect.
+ *
+ * The effect can vary between platforms or be unavailable (e.g. desktop, browser).
+ *
+ * @see HapticFeedbackType.LongPress
+ */
+public fun HapticFeedback.medium(): Unit = performHapticFeedback(HapticFeedbackType.LongPress)
+
+/**
+ * Plays a short haptic feedback, similar to a software keyboard typing vibration.
+ *
+ * The effect can vary between platforms or be unavailable (e.g. desktop, browser).
+ *
+ * @see HapticFeedbackType.TextHandleMove
+ */
+public fun HapticFeedback.short(): Unit = performHapticFeedback(HapticFeedbackType.TextHandleMove)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/InputExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/InputExt.kt
new file mode 100644
index 0000000..5d60391
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/InputExt.kt
@@ -0,0 +1,103 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.text.KeyboardActionScope
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillNode
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.focus.onFocusEvent
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofill
+import androidx.compose.ui.platform.LocalAutofillTree
+import androidx.compose.ui.platform.LocalFocusManager
+import kotlinx.coroutines.launch
+
+/**
+ * Mark this element as autofillable.
+ *
+ * @param autofillType required first autofill type
+ * @param autofillTypes additional autofill types
+ * @param onFill callback to execute when the user agrees to autofill the form. Most often used to update the value
+ * of the form
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+public fun Modifier.autofill(
+ autofillType: AutofillType,
+ vararg autofillTypes: AutofillType,
+ onFill: (String) -> Unit,
+): Modifier {
+ val autofill = LocalAutofill.current
+ val types = remember(autofillType, *autofillTypes) {
+ buildList {
+ add(autofillType)
+ addAll(autofillTypes)
+ }
+ }
+ val autofillNode = AutofillNode(onFill = onFill, autofillTypes = types)
+ LocalAutofillTree.current += autofillNode
+
+ return onGloballyPositioned {
+ autofillNode.boundingBox = it.boundsInWindow()
+ }.onFocusChanged { focusState ->
+ autofill?.run {
+ if (focusState.isFocused) {
+ requestAutofillForNode(autofillNode)
+ } else {
+ cancelAutofillForNode(autofillNode)
+ }
+ }
+ }
+}
+
+/**
+ * Scrolls to this widget when an element is focused (i.e. user taps the input field).
+ * Use on text input fields to keep them visible while editing.
+ *
+ * For this to work, wrap your scrollable container in another and add the .imePadding() modifier
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+public fun Modifier.bringIntoViewOnFocus(): Modifier {
+ val coroutineScope = rememberCoroutineScope()
+ val requester = remember { BringIntoViewRequester() }
+
+ return bringIntoViewRequester(requester)
+ .onFocusEvent {
+ if (it.isFocused) {
+ coroutineScope.launch {
+ requester.bringIntoView()
+ }
+ }
+ }
+}
+
+/**
+ * @param onOther an actions to be run when you specify one that is not the one that can be handled by focusManager
+ * e.g. Go, Search, and Send. By default does nothing.
+ */
+@Composable
+public fun KeyboardActions.Companion.default(
+ onProceed: (KeyboardActionScope.() -> Unit)? = null
+): KeyboardActions = LocalFocusManager.current.run {
+ remember {
+ KeyboardActions(
+ onDone = { clearFocus() },
+ onNext = { moveFocus(FocusDirection.Next) },
+ onPrevious = { moveFocus(FocusDirection.Previous) },
+ onGo = onProceed,
+ onSearch = onProceed,
+ onSend = onProceed,
+ )
+ }
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.kt
new file mode 100644
index 0000000..946c8c0
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.kt
@@ -0,0 +1,36 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+
+/**
+ * Registers a new lifecycle observer for the lifetime of the composition of this function, then clears it.
+ */
+@Composable
+public fun ObserveLifecycle(onEvent: (event: Lifecycle.Event) -> Unit) {
+ val lifecycle = LocalLifecycleOwner.current
+ val action by rememberUpdatedState(onEvent)
+ DisposableEffect(lifecycle) {
+ val observer = LifecycleEventObserver { _, event -> action(event) }
+
+ lifecycle.lifecycle.addObserver(observer)
+
+ onDispose {
+ lifecycle.lifecycle.removeObserver(observer)
+ }
+ }
+}
+
+/**
+ * Keeps the device screen on while the current composable is in the composition.
+ *
+ * Currently supported only on iOS and Android.
+ * No-op on other platforms
+ */
+@Composable
+public expect fun KeepScreenOn(enabled: Boolean = true)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Transitions.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Transitions.kt
new file mode 100644
index 0000000..c7b92e0
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Transitions.kt
@@ -0,0 +1,120 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.unit.IntOffset
+import kotlin.math.roundToInt
+
+private val DefaultSpec = spring(
+ stiffness = Spring.StiffnessMediumLow,
+ visibilityThreshold = IntOffset.VisibilityThreshold
+)
+
+/**
+ * Positive [itemHeightOffsetFraction] means the item is moving **up**.
+ */
+@Stable
+public fun slideInVertically(
+ itemHeightOffsetFraction: Float,
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInVertically(spec) { (it * itemHeightOffsetFraction).roundToInt() }
+
+/**
+ * Positive [itemHeightOffsetFraction] means the item is moving **down**.
+ */
+@Stable
+public fun slideOutVertically(
+ itemHeightOffsetFraction: Float,
+ spec: FiniteAnimationSpec = DefaultSpec
+): ExitTransition = slideOutVertically(spec) { (it * itemHeightOffsetFraction).roundToInt() }
+
+/**
+ * Returns an exit transition that slides the element to the bottom fully
+ */
+@Stable
+public fun slideToBottom(
+ spec: FiniteAnimationSpec = DefaultSpec
+): ExitTransition = slideOutVertically(1f, spec)
+
+/**
+ * Returns an exit transition that slides the element to the top fully
+ */
+@Stable
+public fun slideToTop(
+ spec: FiniteAnimationSpec = DefaultSpec
+): ExitTransition = slideOutVertically(-1f, spec)
+
+/**
+ * Returns an enter transition that slides the element from the bottom fully
+ */
+@Stable
+public fun slideFromBottom(
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInVertically(1f, spec)
+
+/**
+ * Returns an enter transition that slides the element from the top fully
+ */
+@Stable
+public fun slideFromTop(
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInVertically(-1f, spec)
+
+/**
+ * A positive value means sliding from right to left, whereas a negative value would slide the content from left to right.
+ */
+@Stable
+public fun slideInHorizontally(
+ widthOffsetFraction: Float,
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInHorizontally(spec) { (it * widthOffsetFraction).roundToInt() }
+
+/**
+ * A positive value means sliding to the right, whereas a negative value would slide the content towards the left.
+ */
+@Stable
+public fun slideOutHorizontally(
+ widthOffsetFraction: Float,
+ spec: FiniteAnimationSpec = DefaultSpec
+): ExitTransition = slideOutHorizontally(spec) { (it * widthOffsetFraction).roundToInt() }
+
+/**
+ * Returns an enter transition that slides the element from the left fully
+ */
+@Stable
+public fun slideFromLeft(
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInHorizontally(-1f, spec)
+
+/**
+ * Returns an enter transition that slides the element from the right fully
+ */
+@Stable
+public fun slideFromRight(
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInHorizontally(1f, spec)
+
+/**
+ * Returns an exit transition that slides the element to the left fully
+ */
+@Stable
+public fun slideToLeft(
+ spec: FiniteAnimationSpec = DefaultSpec
+): ExitTransition = slideOutHorizontally(-1f, spec)
+
+/**
+ * Returns an exit transition that slides the element to the right fully
+ */
+@Stable
+public fun slideToRight(
+ spec: FiniteAnimationSpec = DefaultSpec
+): EnterTransition = slideInHorizontally(1f, spec)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeConverters.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeConverters.kt
new file mode 100644
index 0000000..5286444
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeConverters.kt
@@ -0,0 +1,22 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.TwoWayConverter
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+
+private val TextUnitVectorConverter = TwoWayConverter({ AnimationVector1D(it.value) }, { it.value.sp })
+
+/**
+ * A vector converter that converts this [TextUnit] value to a [Float] to allow you to animate it.
+ */
+public val TextUnit.Companion.VectorConverter: TwoWayConverter
+ get() = TextUnitVectorConverter
+
+private val LongVectorConverter = TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toLong() })
+
+/**
+ * A vector converter that converts this [Long] value to a [Float] to allow you to animate it.
+ */
+public val Long.Companion.VectorConverter: TwoWayConverter
+ get() = LongVectorConverter
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeCrossfade.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeCrossfade.kt
new file mode 100644
index 0000000..55b80bb
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeCrossfade.kt
@@ -0,0 +1,103 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Transition
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.util.fastForEach
+
+/**
+ * TypeCrossfade is a [Crossfade] variation that runs a fade-through animation when the type of the [state] [T] changes.
+ * It will not run the animation when the object itself changes.
+ *
+ * This should be used for any page where multiple states are defined to transition between them.
+ * This can have a small performance impact though, so avoid using this where the type changes very frequently.
+ */
+@Composable
+public inline fun TypeCrossfade(
+ state: T,
+ modifier: Modifier = Modifier,
+ fill: Boolean = true,
+ alignment: Alignment = Alignment.Center,
+ animationSpec: FiniteAnimationSpec = tween(),
+ crossinline content: @Composable T.() -> Unit
+) {
+ val transition = updateTransition(targetState = state, label = "TypeCrossfade")
+ transition.Crossfade(
+ contentKey = { it::class },
+ animationSpec = animationSpec,
+ contentAlignment = alignment,
+ modifier = modifier.then(if (fill) Modifier.fillMaxSize() else Modifier),
+ ) {
+ content(it)
+ }
+}
+
+/**
+ * Fork of [androidx.compose.animation.Crossfade] that accepts [contentAlignment].
+ */
+@Composable
+@PublishedApi
+internal fun Transition.Crossfade(
+ modifier: Modifier = Modifier,
+ contentAlignment: Alignment,
+ animationSpec: FiniteAnimationSpec = tween(),
+ contentKey: (targetState: T) -> Any? = { it },
+ content: @Composable BoxScope.(targetState: T) -> Unit
+) {
+ val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } }
+ val contentMap = remember {
+ mutableMapOf Unit>()
+ }
+ if (currentState == targetState) {
+ // If not animating, just display the current state
+ if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) {
+ // Remove all the intermediate items from the list once the animation is finished.
+ currentlyVisible.removeAll { it != targetState }
+ contentMap.clear()
+ }
+ }
+ if (!contentMap.contains(targetState)) {
+ // Replace target with the same key if any
+ val replacementId = currentlyVisible.indexOfFirst {
+ contentKey(it) == contentKey(targetState)
+ }
+ if (replacementId == -1) {
+ currentlyVisible.add(targetState)
+ } else {
+ currentlyVisible[replacementId] = targetState
+ }
+ contentMap.clear()
+ currentlyVisible.fastForEach { stateForContent ->
+ contentMap[stateForContent] = {
+ val alpha by animateFloat(
+ transitionSpec = { animationSpec }
+ ) { if (it == stateForContent) 1f else 0f }
+ Box(Modifier.graphicsLayer { this.alpha = alpha }) {
+ content(stateForContent)
+ }
+ }
+ }
+ }
+
+ Box(modifier, contentAlignment = contentAlignment) {
+ currentlyVisible.fastForEach {
+ key(contentKey(it)) {
+ contentMap[it]?.invoke()
+ }
+ }
+ }
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Utils.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Utils.kt
new file mode 100644
index 0000000..65a80e5
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Utils.kt
@@ -0,0 +1 @@
+package pro.respawn.kmmutils.compose
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/GridExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/GridExt.kt
new file mode 100644
index 0000000..fac62de
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/GridExt.kt
@@ -0,0 +1,13 @@
+package pro.respawn.kmmutils.compose.lazy
+
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridItemScope
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.runtime.Composable
+
+/**
+ * Adds an item that spans the whole row of this grid.
+ */
+public fun LazyGridScope.fullWidthItem(
+ content: @Composable LazyGridItemScope.() -> Unit,
+): Unit = item(span = { GridItemSpan(maxLineSpan) }, content = content)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerEffects.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerEffects.kt
new file mode 100644
index 0000000..f39dc85
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerEffects.kt
@@ -0,0 +1,69 @@
+@file:OptIn(ExperimentalFoundationApi::class)
+
+package pro.respawn.kmmutils.compose.lazy
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.layout.lerp
+import androidx.compose.ui.util.lerp
+
+private const val DefaultPagerScaleFactor = 0.85f
+private const val MaxRotationDegrees = 30f
+
+/**
+ * Calculate absolute offset for current page [page].
+ * * For previous page, this will be -1
+ * * For current, 0
+ * * For next, 1
+ * * For one after the next - 2
+ *
+ * and so on...
+ */
+@Stable
+public fun PagerState.offsetForPage(page: Int): Float = (currentPage - page + currentPageOffsetFraction) * -1
+
+/**
+ * Applies a scale effect when scrolling through pages.
+ *
+ * The highest scale change factor possible is determined by [scaleFactor], achieved when the item is fully swiped away.
+ */
+public fun Modifier.scalePagerEffect(
+ state: PagerState,
+ index: Int,
+ scaleFactor: Float = DefaultPagerScaleFactor
+): Modifier = graphicsLayer {
+ // Calculate the absolute offset for the current page from the
+ // scroll position. We use the absolute value which allows us to mirror
+ // any effects for both directions
+ val pageOffset = state.offsetForPage(index)
+
+ lerp(
+ start = ScaleFactor(scaleFactor, scaleFactor),
+ stop = ScaleFactor(1f, 1f),
+ fraction = 1f - pageOffset.coerceIn(0f, 1f)
+ ).also { scale ->
+ scaleX = scale.scaleX
+ scaleY = scale.scaleY
+ }
+}
+
+/**
+ * Applies a "spin" (rotate) effect when scrolling through pages.
+ *
+ * The highest angle possible is determined by [maxRotationDeg], achieved when the item is fully swiped away.
+ */
+public fun Modifier.spinPagerEffect(
+ state: PagerState,
+ index: Int,
+ maxRotationDeg: Float = MaxRotationDegrees
+): Modifier = graphicsLayer {
+ rotationZ = lerp(
+ start = 0f,
+ stop = maxRotationDeg,
+ fraction = state.offsetForPage(index).coerceIn(-1f, 1f)
+ )
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerExt.kt
new file mode 100644
index 0000000..38c8acd
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerExt.kt
@@ -0,0 +1,22 @@
+package pro.respawn.kmmutils.compose.lazy
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.pager.PagerState
+
+/**
+ * Same behavior as [LazyListState.firstVisibleItemIndex].
+ */
+@OptIn(ExperimentalFoundationApi::class)
+public inline val PagerState.firstVisiblePage: Int
+ get() = when {
+ currentPageOffsetFraction >= 0 -> currentPage
+ else -> currentPage - 1
+ }.coerceIn(0..pageCount)
+
+/**
+ * Same behavior as [LazyListState.firstVisibleItemScrollOffset].
+ */
+@OptIn(ExperimentalFoundationApi::class)
+public inline val PagerState.firstVisiblePageOffsetFraction: Float
+ get() = getOffsetFractionForPage(firstVisiblePage.coerceIn(0..pageCount))
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagesPerScreen.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagesPerScreen.kt
new file mode 100644
index 0000000..933e5db
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagesPerScreen.kt
@@ -0,0 +1,20 @@
+package pro.respawn.kmmutils.compose.lazy
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.PageSize
+import androidx.compose.ui.unit.Density
+
+/**
+ * Defines a [PageSize] that shows [amount] pages on the screen at all times. The size of pages will be changed
+ * to always show [amount] pages.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+public class PagesPerScreen(
+ public val amount: Int
+) : PageSize {
+
+ override fun Density.calculateMainAxisPageSize(
+ availableSpace: Int,
+ pageSpacing: Int
+ ): Int = (availableSpace - 2 * pageSpacing) / amount
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Clickable.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Clickable.kt
new file mode 100644
index 0000000..b458bde
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Clickable.kt
@@ -0,0 +1,17 @@
+package pro.respawn.kmmutils.compose.modifier
+
+// TODO: Waiting for compose update
+
+// public fun Modifier.noIndicationClickable(
+// enabled: Boolean = true,
+// onClickLabel: String? = null,
+// role: Role? = null,
+// onClick: () -> Unit,
+// ): Modifier = clickable(
+// interactionSource = null,
+// indication = null,
+// enabled = enabled,
+// onClickLabel = onClickLabel,
+// role = role,
+// onClick = onClick
+// )
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/FadingEdge.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/FadingEdge.kt
new file mode 100644
index 0000000..55a5f4a
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/FadingEdge.kt
@@ -0,0 +1,67 @@
+package pro.respawn.kmmutils.compose.modifier
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * The edge to use with [fadingEdge] modifier.
+ */
+public enum class FadingEdge {
+ Start, End, Top, Bottom
+}
+
+private const val EdgeAlpha = 0.99f
+
+/**
+ * Makes this widget "fade out" into transparency over specified [size].
+ * The edge specified by [fadingEdge] will be faded only.
+ *
+ * For RTL layouts, if [rtlAware] is true, automatically inverts the fading edge direction.
+ */
+public fun Modifier.fadingEdge(
+ fadingEdge: FadingEdge,
+ size: Dp,
+ rtlAware: Boolean = false,
+): Modifier = composed {
+ val direction = LocalLayoutDirection.current
+ val invert = direction == LayoutDirection.Rtl && rtlAware
+ val edge = when (fadingEdge) {
+ FadingEdge.Top, FadingEdge.Bottom -> fadingEdge
+ FadingEdge.Start -> if (invert) FadingEdge.End else FadingEdge.Start
+ FadingEdge.End -> if (invert) FadingEdge.Start else FadingEdge.End
+ }
+ graphicsLayer { alpha = EdgeAlpha }.drawWithCache {
+ val colors = listOf(Color.Transparent, Color.Black)
+ val sizePx = size.toPx()
+ val brush = when (edge) {
+ FadingEdge.Start -> Brush.horizontalGradient(colors, startX = 0f, endX = sizePx)
+ FadingEdge.End -> Brush.horizontalGradient(
+ colors.reversed(),
+ startX = this.size.width - sizePx,
+ endX = this.size.width
+ )
+
+ FadingEdge.Top -> Brush.verticalGradient(colors, startY = 0f, endY = sizePx)
+ FadingEdge.Bottom -> Brush.verticalGradient(
+ colors.reversed(),
+ startY = this.size.height - sizePx,
+ endY = this.size.height
+ )
+ }
+ onDrawWithContent {
+ drawContent()
+ drawRect(
+ brush = brush,
+ blendMode = BlendMode.DstIn
+ )
+ }
+ }
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Flip.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Flip.kt
new file mode 100644
index 0000000..f697404
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Flip.kt
@@ -0,0 +1,40 @@
+package pro.respawn.kmmutils.compose.modifier
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.DrawTransform
+import androidx.compose.ui.graphics.drawscope.withTransform
+
+/**
+ * Defines available modes for the [flip] modifier
+ */
+public enum class FlipDirection(internal val x: Float, internal val y: Float) {
+ Vertical(1f, -1f),
+ Horizontal(-1f, 1f)
+}
+
+/**
+ * Flip this composable using [direction]
+ */
+public fun Modifier.flip(direction: FlipDirection): Modifier = scale(direction.x, direction.y)
+
+/**
+ * Flip this canvas using [direction] and [pivot] point
+ */
+public fun DrawTransform.flip(direction: FlipDirection?, pivot: Offset = center) {
+ direction?.run { scale(x, y, pivot) }
+}
+
+/**
+ * Create a new draw transform scope for [block], flipping the contents using [direction]]
+ */
+public inline fun DrawScope.flip(
+ direction: FlipDirection?,
+ pivot: Offset = center,
+ block: DrawScope.() -> Unit
+): Unit = withTransform(
+ transformBlock = { flip(direction, pivot) },
+ drawBlock = block
+)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GradientTint.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GradientTint.kt
new file mode 100644
index 0000000..04973c3
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GradientTint.kt
@@ -0,0 +1,33 @@
+package pro.respawn.kmmutils.compose.modifier
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+
+/**
+ * Tint the composable with the provided [brush]. This will draw over any pixels that are not completely transparent in
+ * the source composable. For example, this can be used to tint over icons or any other complex shapes.
+ */
+// srcAtop only works when alpha != 1f
+public fun Modifier.tint(brush: Brush): Modifier = graphicsLayer(alpha = 0.99f)
+ .drawWithCache {
+ onDrawWithContent {
+ drawContent()
+ drawRect(brush, size = size, blendMode = BlendMode.SrcAtop)
+ }
+ }
+
+/**
+ * Tint the composable with the provided [color]. This will draw over any pixels that are not completely transparent in
+ * the source composable. For example, this can be used to tint over icons or any other complex shapes.
+ */
+public fun Modifier.tint(color: Color): Modifier = graphicsLayer(alpha = 0.99f)
+ .drawWithCache {
+ onDrawWithContent {
+ drawContent()
+ drawRect(color = color, size = size, blendMode = BlendMode.SrcAtop)
+ }
+ }
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GrayScale.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GrayScale.kt
new file mode 100644
index 0000000..842ce05
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GrayScale.kt
@@ -0,0 +1,27 @@
+package pro.respawn.kmmutils.compose.modifier
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+
+/**
+ * Apply 0-saturation color transformation to this composable, rendering it grayscale.
+ */
+public fun Modifier.grayScale(): Modifier = drawWithCache {
+ val saturationMatrix = ColorMatrix().apply { setToSaturation(0f) }
+ val saturationFilter = ColorFilter.colorMatrix(saturationMatrix)
+ val paint = Paint().apply { colorFilter = saturationFilter }
+ val canvasBounds = Rect(Offset.Zero, size)
+ onDrawWithContent {
+ drawIntoCanvas {
+ it.saveLayer(canvasBounds, paint)
+ drawContent()
+ it.restore()
+ }
+ }
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/ThenIf.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/ThenIf.kt
new file mode 100644
index 0000000..3e067a1
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/ThenIf.kt
@@ -0,0 +1,27 @@
+package pro.respawn.kmmutils.compose.modifier
+
+import androidx.compose.ui.Modifier
+
+/**
+ * Apply the [ifTrue] block to the modifier chain only if the [condition] is `true`, otherwise apply [ifFalse]
+ * (nothing additional by default)
+ *
+ * @return the continued modifier chain with changes applied, if any.
+ */
+public inline fun Modifier.thenIf(
+ condition: Boolean,
+ ifFalse: Modifier.() -> Modifier = { this },
+ ifTrue: Modifier.() -> Modifier
+): Modifier = then(Modifier.let { if (condition) it.ifTrue() else it.ifFalse() })
+
+/**
+ * Apply the [block] to the modifier chain only if the [value] is not null, otherwise apply [ifNull]
+ * (nothing additional by default)
+ *
+ * @return the continued modifier chain with changes applied, if any.
+ */
+public inline fun Modifier.thenIfNotNull(
+ value: T,
+ ifNull: Modifier.() -> Modifier = { this },
+ block: Modifier.(T & Any) -> Modifier
+): Modifier = then(Modifier.let { if (value != null) it.block(value) else it.ifNull() })
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.kt
new file mode 100644
index 0000000..2cb7894
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.kt
@@ -0,0 +1,54 @@
+package pro.respawn.kmmutils.compose.resources
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonSkippableComposable
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import org.jetbrains.compose.resources.DrawableResource
+import org.jetbrains.compose.resources.PluralStringResource
+import org.jetbrains.compose.resources.StringArrayResource
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.imageResource
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.pluralStringResource
+import org.jetbrains.compose.resources.stringArrayResource
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
+
+@Composable
+@NonSkippableComposable
+public fun DrawableResource.vector(): ImageVector = vectorResource(this)
+
+@Composable
+@NonSkippableComposable
+public fun DrawableResource.painter(): Painter = painterResource(this)
+
+@Composable
+@NonSkippableComposable
+public fun DrawableResource.image(): ImageBitmap = imageResource(this)
+
+@Composable
+@NonSkippableComposable
+public fun StringArrayResource.strings(): List = stringArrayResource(this)
+
+@Composable
+@NonSkippableComposable
+public fun StringResource.string(vararg args: Any): String = stringResource(this, formatArgs = args)
+
+@Composable
+@NonSkippableComposable
+public fun PluralStringResource.plural(
+ quantity: Int,
+ vararg args: Any
+): String = pluralStringResource(this, quantity, formatArgs = args)
+
+@Composable
+public fun Text.string(): String = when (this) {
+ is Text.Dynamic -> value
+ is Text.Resource -> stringResource(id, formatArgs = args)
+}
+
+public fun String.text(): Text.Dynamic = Text.Dynamic(this)
+
+public fun StringResource.text(vararg args: Any): Text.Resource = Text.Resource(this, args = args)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/Text.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/Text.kt
new file mode 100644
index 0000000..5b932aa
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/Text.kt
@@ -0,0 +1,40 @@
+package pro.respawn.kmmutils.compose.resources
+
+import androidx.compose.runtime.Immutable
+import org.jetbrains.compose.resources.StringResource
+import kotlin.jvm.JvmInline
+
+@Immutable
+public sealed interface Text {
+
+ @JvmInline
+ public value class Dynamic(public val value: String) : Text
+
+ public class Resource(
+ internal val id: StringResource,
+ internal vararg val args: Any
+ ) : Text {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Resource) return false
+
+ if (id != other.id) return false
+ if (!args.contentEquals(other.args)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = id.hashCode()
+ result = 31 * result + args.contentHashCode()
+ return result
+ }
+ }
+
+ public companion object {
+
+ public operator fun invoke(value: String): Dynamic = Dynamic(value)
+ public operator fun invoke(id: StringResource, vararg args: Any): Resource = Resource(id, args = args)
+ }
+}
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeClass.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeClass.kt
new file mode 100644
index 0000000..ad79a6b
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeClass.kt
@@ -0,0 +1,297 @@
+package pro.respawn.kmmutils.compose.windowsize
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.toSize
+
+/**
+ * Calculates the window's [WindowSizeClass].
+ *
+ * A new [WindowSizeClass] will be returned whenever a change causes the width or
+ * height of the window to cross a breakpoint, such as when the device is rotated or the window
+ * is resized.
+ */
+@Composable
+@ReadOnlyComposable
+public fun calculateWindowSizeClass(): WindowSizeClass = WindowSizeClass.calculateFromSize(
+ size = windowSizePx.toSize(),
+ density = LocalDensity.current
+)
+
+/**
+ * Window size classes are a set of opinionated viewport breakpoints to design, develop, and test
+ * responsive application layouts against.
+ * For more details check Support different screen sizes documentation.
+ *
+ * WindowSizeClass contains a [WindowWidthSizeClass] and [WindowHeightSizeClass], representing the
+ * window size classes for this window's width and height respectively.
+ *
+ * See [calculateWindowSizeClass] to calculate the WindowSizeClass.
+ *
+ * @property widthSizeClass width-based window size class ([WindowWidthSizeClass])
+ * @property heightSizeClass height-based window size class ([WindowHeightSizeClass])
+ */
+@Immutable
+public class WindowSizeClass private constructor(
+ public val widthSizeClass: WindowWidthSizeClass,
+ public val heightSizeClass: WindowHeightSizeClass,
+) {
+
+ public companion object {
+
+ internal fun calculateFromSize(size: DpSize): WindowSizeClass {
+ val windowWidthSizeClass = WindowWidthSizeClass.fromWidth(size.width)
+ val windowHeightSizeClass = WindowHeightSizeClass.fromHeight(size.height)
+ return WindowSizeClass(windowWidthSizeClass, windowHeightSizeClass)
+ }
+
+ /**
+ * Calculates the best matched [WindowSizeClass] for a given [size] and [Density] according
+ * to the provided [supportedWidthSizeClasses] and [supportedHeightSizeClasses].
+ *
+ * @param size of the window
+ * @param density of the window
+ * @param supportedWidthSizeClasses the set of width size classes that are supported
+ * @param supportedHeightSizeClasses the set of height size classes that are supported
+ * @return [WindowSizeClass] corresponding to the given width and height
+ */
+ public fun calculateFromSize(
+ size: Size,
+ density: Density,
+ supportedWidthSizeClasses: Set =
+ WindowWidthSizeClass.DefaultSizeClasses,
+ supportedHeightSizeClasses: Set =
+ WindowHeightSizeClass.DefaultSizeClasses,
+ ): WindowSizeClass {
+ val windowWidthSizeClass =
+ WindowWidthSizeClass.fromWidth(size.width, density, supportedWidthSizeClasses)
+ val windowHeightSizeClass =
+ WindowHeightSizeClass.fromHeight(size.height, density, supportedHeightSizeClasses)
+ return WindowSizeClass(windowWidthSizeClass, 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 (widthSizeClass != other.widthSizeClass) return false
+ if (heightSizeClass != other.heightSizeClass) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = widthSizeClass.hashCode()
+ result = 31 * result + heightSizeClass.hashCode()
+ return result
+ }
+
+ override fun toString(): String = "WindowSizeClass($widthSizeClass, $heightSizeClass)"
+}
+
+/**
+ * Width-based window size class.
+ *
+ * A window size class represents a breakpoint that can be used to build responsive layouts. Each
+ * window size class breakpoint represents a majority case for typical device scenarios so your
+ * layouts will work well on most devices and configurations.
+ *
+ * For more details see Window size classes documentation.
+ */
+@Immutable
+@kotlin.jvm.JvmInline
+public value class WindowWidthSizeClass private constructor(private val value: Int) :
+ Comparable {
+
+ override operator fun compareTo(other: WindowWidthSizeClass): Int =
+ breakpoint().compareTo(other.breakpoint())
+
+ override fun toString(): String = "WindowWidthSizeClass." + when (this) {
+ Compact -> "Compact"
+ Medium -> "Medium"
+ Expanded -> "Expanded"
+ else -> ""
+ }
+
+ public companion object {
+
+ /** Represents the majority of phones in portrait. */
+ public val Compact: WindowWidthSizeClass = WindowWidthSizeClass(0)
+
+ /**
+ * Represents the majority of tablets in portrait and large unfolded inner displays in
+ * portrait.
+ */
+ public val Medium: WindowWidthSizeClass = WindowWidthSizeClass(1)
+
+ /**
+ * Represents the majority of tablets in landscape and large unfolded inner displays in
+ * landscape.
+ */
+ public val Expanded: WindowWidthSizeClass = WindowWidthSizeClass(2)
+
+ /**
+ * The default set of size classes that includes [Compact], [Medium], and [Expanded] size
+ * classes. Should never expand to ensure behavioral consistency.
+ */
+ public val DefaultSizeClasses: Set = setOf(Compact, Medium, Expanded)
+
+ /**
+ * The standard set of size classes. It's supposed to include all size classes and will be
+ * expanded whenever a new size class is defined. By default
+ * [WindowSizeClass.calculateFromSize] will only return size classes in [DefaultSizeClasses]
+ * in order to avoid behavioral changes when new size classes are added. You can opt in to
+ * support all available size classes by doing:
+ * ```
+ * WindowSizeClass.calculateFromSize(
+ * size = size,
+ * density = density,
+ * supportedWidthSizeClasses = WindowWidthSizeClass.StandardSizeClasses,
+ * supportedHeightSizeClasses = WindowHeightSizeClass.StandardSizeClasses
+ * )
+ * ```
+ */
+ public val StandardSizeClasses: Set get() = DefaultSizeClasses
+
+ private fun WindowWidthSizeClass.breakpoint(): Dp = when {
+ this == Expanded -> 840.dp
+ this == Medium -> 600.dp
+ else -> 0.dp
+ }
+
+ /** Calculates the [WindowWidthSizeClass] for a given [width] */
+ internal fun fromWidth(width: Dp): WindowWidthSizeClass = fromWidth(
+ with(defaultDensity) { width.toPx() },
+ defaultDensity,
+ DefaultSizeClasses,
+ )
+
+ /**
+ * Calculates the best matched [WindowWidthSizeClass] for a given [width] in Pixels and
+ * a given [Density] from [supportedSizeClasses].
+ */
+ internal fun fromWidth(
+ width: Float,
+ density: Density,
+ supportedSizeClasses: Set,
+ ): WindowWidthSizeClass {
+ require(width >= 0) { "Width must not be negative" }
+ require(supportedSizeClasses.isNotEmpty()) { "Must support at least one size class" }
+ val sortedSizeClasses = supportedSizeClasses.sortedDescending()
+ // Find the largest supported size class that matches the width
+ sortedSizeClasses.forEach {
+ if (width >= with(density) { it.breakpoint().toPx() }) {
+ return it
+ }
+ }
+ // If none of the size classes matches, return the smallest one.
+ return sortedSizeClasses.last()
+ }
+ }
+}
+
+/**
+ * Height-based window size class.
+ *
+ * A window size class represents a breakpoint that can be used to build responsive layouts. Each
+ * window size class breakpoint represents a majority case for typical device scenarios so your
+ * layouts will work well on most devices and configurations.
+ *
+ * For more details see Window size classes documentation.
+ */
+@Immutable
+@kotlin.jvm.JvmInline
+public value class WindowHeightSizeClass private constructor(private val value: Int) :
+ Comparable {
+
+ override operator fun compareTo(other: WindowHeightSizeClass): Int = breakpoint().compareTo(other.breakpoint())
+
+ override fun toString(): String = "WindowHeightSizeClass." + when (this) {
+ Compact -> "Compact"
+ Medium -> "Medium"
+ Expanded -> "Expanded"
+ else -> ""
+ }
+
+ public companion object {
+
+ /** Represents the majority of phones in landscape */
+ public val Compact: WindowHeightSizeClass = WindowHeightSizeClass(0)
+
+ /** Represents the majority of tablets in landscape and majority of phones in portrait */
+ public val Medium: WindowHeightSizeClass = WindowHeightSizeClass(1)
+
+ /** Represents the majority of tablets in portrait */
+ public val Expanded: WindowHeightSizeClass = WindowHeightSizeClass(2)
+
+ /**
+ * The default set of size classes that includes [Compact], [Medium], and [Expanded] size
+ * classes. Should never expand to ensure behavioral consistency.
+ */
+ public val DefaultSizeClasses: Set = setOf(Compact, Medium, Expanded)
+
+ /**
+ * The standard set of size classes. It's supposed to include all size classes and will be
+ * expanded whenever a new size class is defined. By default
+ * [WindowSizeClass.calculateFromSize] will only return size classes in [DefaultSizeClasses]
+ * in order to avoid behavioral changes when new size classes are added. You can opt in to
+ * support all available size classes by doing:
+ * ```
+ * WindowSizeClass.calculateFromSize(
+ * size = size,
+ * density = density,
+ * supportedWidthSizeClasses = WindowWidthSizeClass.StandardSizeClasses,
+ * supportedHeightSizeClasses = WindowHeightSizeClass.StandardSizeClasses
+ * )
+ * ```
+ */
+ public val StandardSizeClasses: Set get() = DefaultSizeClasses
+
+ private fun WindowHeightSizeClass.breakpoint(): Dp = when {
+ this == Expanded -> 900.dp
+ this == Medium -> 480.dp
+ else -> 0.dp
+ }
+
+ /** Calculates the [WindowHeightSizeClass] for a given [height] */
+ internal fun fromHeight(height: Dp) = fromHeight(
+ with(defaultDensity) { height.toPx() },
+ defaultDensity,
+ DefaultSizeClasses,
+ )
+
+ /**
+ * Calculates the best matched [WindowHeightSizeClass] for a given [height] in Pixels and
+ * a given [Density] from [supportedSizeClasses].
+ */
+ internal fun fromHeight(
+ height: Float,
+ density: Density,
+ supportedSizeClasses: Set,
+ ): WindowHeightSizeClass {
+ require(height >= 0) { "Width must not be negative" }
+ require(supportedSizeClasses.isNotEmpty()) { "Must support at least one size class" }
+ val sortedSizeClasses = supportedSizeClasses.sortedDescending()
+ // Find the largest supported size class that matches the width
+ sortedSizeClasses.forEach {
+ if (height >= with(density) { it.breakpoint().toPx() }) {
+ return it
+ }
+ }
+ // If none of the size classes matches, return the smallest one.
+ return sortedSizeClasses.last()
+ }
+ }
+}
+
+private val defaultDensity = Density(1F, 1F)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.kt
new file mode 100644
index 0000000..56d561d
--- /dev/null
+++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.kt
@@ -0,0 +1,59 @@
+package pro.respawn.kmmutils.compose.windowsize
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.toSize
+
+/**
+ * Whether the window width is long (longer than most phones **in portrait**).
+ *
+ * This is not a real device size (although it matches closely on mobile platforms sometimes), but an app window size,
+ * which can be different when resizing.
+ *
+ * @see WindowWidthSizeClass
+ */
+public inline val isWideScreen: Boolean
+ @Composable get() = calculateWindowSizeClass().widthSizeClass > WindowWidthSizeClass.Compact
+
+/**
+ * Whether the window height is long (longer than most phones **in portrait**).
+ *
+ * This is not a real device size (although it matches closely on mobile platforms sometimes), but an app window size
+ * which can be different when resizing.
+ *
+ * @see WindowWidthSizeClass
+ */
+public inline val isLongScreen: Boolean
+ @Composable get() = calculateWindowSizeClass().heightSizeClass > WindowHeightSizeClass.Compact
+
+/**
+ * Get the window size of the current window in pixels.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+public expect val windowSizePx: IntSize @Composable @ReadOnlyComposable get
+
+/**
+ * Get the window size of the current window in dp.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+public val windowSize: DpSize
+ @Composable @ReadOnlyComposable get() = with(LocalDensity.current) {
+ windowSizePx.toSize()
+ .toDpSize()
+ }
+
+/**
+ * Get the window width of the current window.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+public val windowWidth: Dp @Composable get() = with(LocalDensity.current) { windowSizePx.width.toDp() }
+
+/**
+ * Get the window height of the current window.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+public val windowHeight: Dp @Composable get() = with(LocalDensity.current) { windowSizePx.height.toDp() }
diff --git a/compose/src/iosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.ios.kt b/compose/src/iosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.ios.kt
new file mode 100644
index 0000000..5d894af
--- /dev/null
+++ b/compose/src/iosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.ios.kt
@@ -0,0 +1,19 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import platform.UIKit.UIApplication
+
+/**
+ * Keeps the device screen on while the current composable is in the composition.
+ *
+ * Currently supported only on iOS and Android.
+ * No-op on other platforms
+ */
+@Composable
+public actual fun KeepScreenOn(enabled: Boolean): Unit = DisposableEffect(enabled) {
+ if (!enabled) return@DisposableEffect onDispose { }
+ val previous = UIApplication.sharedApplication.isIdleTimerDisabled()
+ UIApplication.sharedApplication.idleTimerDisabled = true
+ onDispose { UIApplication.sharedApplication.idleTimerDisabled = previous }
+}
diff --git a/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.jvm.kt b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.jvm.kt
new file mode 100644
index 0000000..d2678f6
--- /dev/null
+++ b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.jvm.kt
@@ -0,0 +1,13 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Keeps the device screen on while the current composable is in the composition.
+ *
+ * Currently supported only on iOS and Android.
+ * No-op on other platforms
+ */
+// requires win/macos api usage, so not really possible to implement w/o 1st party support
+@Composable
+public actual fun KeepScreenOn(enabled: Boolean): Unit = Unit
diff --git a/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.jvm.kt b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.jvm.kt
new file mode 100644
index 0000000..52a5005
--- /dev/null
+++ b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.jvm.kt
@@ -0,0 +1,16 @@
+package pro.respawn.kmmutils.compose.windowsize
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * Get the window size of the current window in pixels.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+public actual val windowSizePx: IntSize
+ @ReadOnlyComposable
+ @Composable get() = LocalWindowInfo.current.containerSize
diff --git a/compose/src/macosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.macos.kt b/compose/src/macosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.macos.kt
new file mode 100644
index 0000000..9ac452a
--- /dev/null
+++ b/compose/src/macosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.macos.kt
@@ -0,0 +1,29 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Keeps the device screen on while the current composable is in the composition.
+ *
+ * Currently supported only on iOS and Android.
+ * No-op on other platforms
+ */
+@Composable
+public actual fun KeepScreenOn(enabled: Boolean) {
+ // val key = currentCompositeKeyHash
+ // DisposableEffect(enabled) {
+ // val ref = CFBridgingRetain(key)
+ // if (!enabled) return@DisposableEffect onDispose { }
+ // IOPMAssertionCreate(
+ // CFBridgingRetain(kIOPMAssertPreventUserIdleDisplaySleep) as CFStringRef,
+ // kIOPMAssertionLevelOn,
+ // ref
+ // )
+ // onDispose {
+ //
+ // }
+ // }
+
+ // TODO:
+ // currently huge pain and really unsafe to call
+}
diff --git a/compose/src/nativeMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.native.kt b/compose/src/nativeMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.native.kt
new file mode 100644
index 0000000..52a5005
--- /dev/null
+++ b/compose/src/nativeMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.native.kt
@@ -0,0 +1,16 @@
+package pro.respawn.kmmutils.compose.windowsize
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * Get the window size of the current window in pixels.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+public actual val windowSizePx: IntSize
+ @ReadOnlyComposable
+ @Composable get() = LocalWindowInfo.current.containerSize
diff --git a/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.web.kt b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.web.kt
new file mode 100644
index 0000000..8c90d5a
--- /dev/null
+++ b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.web.kt
@@ -0,0 +1,12 @@
+package pro.respawn.kmmutils.compose
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Keeps the device screen on while the current composable is in the composition.
+ *
+ * Currently supported only on iOS and Android.
+ * No-op on other platforms
+ */
+@Composable
+public actual fun KeepScreenOn(enabled: Boolean): Unit = Unit
diff --git a/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.web.kt b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.web.kt
new file mode 100644
index 0000000..52a5005
--- /dev/null
+++ b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.web.kt
@@ -0,0 +1,16 @@
+package pro.respawn.kmmutils.compose.windowsize
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * Get the window size of the current window in pixels.
+ * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+public actual val windowSizePx: IntSize
+ @ReadOnlyComposable
+ @Composable get() = LocalWindowInfo.current.containerSize
diff --git a/detekt.yml b/detekt.yml
index 3d33b36..e8d20da 100644
--- a/detekt.yml
+++ b/detekt.yml
@@ -68,17 +68,17 @@ comments:
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
UndocumentedPublicClass:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**','**/app/**' ]
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
UndocumentedPublicFunction:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**', '**/app/**' ]
UndocumentedPublicProperty:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**', '**/app/**' ]
complexity:
active: true
@@ -141,19 +141,19 @@ complexity:
active: false
StringLiteralDuplication:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
threshold: 5
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
- thresholdInFiles: 22
- thresholdInClasses: 22
- thresholdInInterfaces: 22
- thresholdInObjects: 22
- thresholdInEnums: 22
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**','**/app/**' ]
+ thresholdInFiles: 50
+ thresholdInClasses: 11
+ thresholdInInterfaces: 11
+ thresholdInObjects: 11
+ thresholdInEnums: 11
ignoreDeprecated: true
ignorePrivate: true
ignoreOverridden: true
@@ -221,7 +221,7 @@ exceptions:
- 'finalize'
InstanceOfCheckForException:
active: false
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
NotImplementedDeclaration:
active: true
ObjectExtendsThrowable:
@@ -247,7 +247,7 @@ exceptions:
active: true
ThrowingExceptionsWithoutMessageOrCause:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptions:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
@@ -261,7 +261,7 @@ exceptions:
active: true
TooGenericExceptionCaught:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
@@ -421,46 +421,46 @@ naming:
active: true
ClassNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
classPattern: '[A-Z][a-zA-Z0-9]*'
ConstructorParameterNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
forbiddenName: [ ]
FunctionMaxLength:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
maximumFunctionNameLength: 40
FunctionMinLength:
active: false
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
minimumFunctionNameLength: 3
FunctionNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
functionPattern: '([a-zA-Z][a-zA-Z0-9]*)|(`.*`)'
excludeClassPattern: '$^'
ignoreAnnotated:
- 'Composable'
FunctionParameterNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
parameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: true
excludes: [ '**/*.kts' ]
- rootPackage: 'com.nek12.respawn'
+ rootPackage: 'pro.respawn.kmmutils'
MatchingDeclarationName:
active: false
mustBeFirst: true
@@ -471,34 +471,34 @@ naming:
active: true
NonBooleanPropertyPrefixedWithIs:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
ObjectPropertyNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
packagePattern: '[a-z_]+(\.[a-z][A-Za-z0-9_]*)*'
TopLevelPropertyNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
constantPattern: '[A-Z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
maximumVariableNameLength: 64
VariableMinLength:
active: false
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
minimumVariableNameLength: 1
VariableNaming:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
variablePattern: '[a-z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
@@ -513,10 +513,10 @@ performance:
active: true
ForEachOnRange:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
SpreadOperator:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
UnnecessaryTemporaryInstantiation:
active: true
@@ -573,7 +573,7 @@ potential-bugs:
active: true
LateinitUsage:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
ignoreAnnotated: [ ]
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
@@ -688,7 +688,7 @@ style:
maxJumpCount: 1
MagicNumber:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
ignoreAnnotated:
- 'androidx.compose.ui.tooling.preview.Preview'
ignoreNumbers:
@@ -797,7 +797,8 @@ style:
UnusedPrivateMember:
active: true
allowedNames: '(_|ignored|expected|serialVersionUID)'
- ignoreAnnotated: [ 'androidx.compose.ui.tooling.preview.Preview' ]
+ excludes: [ '**/*test*/**', '**/*.kts' ]
+ ignoreAnnotated: [ 'androidx.compose.ui.tooling.preview.Preview', 'androidx.compose.desktop.ui.tooling.preview.Preview' ]
UseArrayLiteralsInAnnotations:
active: true
UseCheckNotNull:
@@ -830,7 +831,7 @@ style:
active: true
WildcardImport:
active: true
- excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
+ excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ]
excludeImports:
- 'java.util.*'
- 'kotlinx.android.synthetic.*'
diff --git a/gradle.properties b/gradle.properties
index cd3e798..ed26c10 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,6 @@
-org.gradle.jvmargs=-Xms3g -Xmx6g -XX:+UseParallelGC -XX:+UseStringDeduplication -Dkotlin.daemon.jvm.options=-Xmx2g -Dfile.encoding=UTF-8
+# suppress inspection "UnusedProperty" for whole file
+org.gradle.jvmargs=-Xmx6g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -Dfile.encoding=UTF-8
+kotlin.daemon.jvmargs=-Xmx6g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -XX:MaxMetaspaceSize=2g
android.useAndroidX=true
kotlin.code.style=official
org.gradle.caching=true
@@ -7,16 +9,22 @@ android.enableR8.fullMode=true
org.gradle.configureondemand=true
android.enableJetifier=false
kotlin.incremental.usePreciseJavaTracking=true
+org.gradle.configuration-cache.problems=warn
android.nonTransitiveRClass=true
android.experimental.enableSourceSetPathsMap=true
android.experimental.cacheCompileLibResources=true
kotlin.mpp.enableCInteropCommonization=true
kotlin.mpp.stability.nowarn=true
+kotlin.mpp.androidGradlePluginCompatibility.nowarn=true
org.gradle.unsafe.configuration-cache=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.disableResourceValidation=true
+org.gradle.daemon=true
android.nonFinalResIds=true
-kotlin.mpp.androidGradlePluginCompatibility.nowarn=true
kotlin.native.ignoreIncorrectDependencies=true
kotlinx.atomicfu.enableJvmIrTransformation=true
+android.lint.useK2Uast=true
nl.littlerobots.vcu.resolver=true
+org.jetbrains.compose.experimental.macos.enabled=true
+org.jetbrains.compose.experimental.jscanvas.enabled=true
+org.jetbrains.compose.experimental.wasm.enabled=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index be7add9..ae8aca7 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,24 +1,30 @@
[versions]
composeDetektPlugin = "1.3.0"
-coroutines = "1.8.1-Beta"
-datetime = "0.6.0-RC.2"
-dependencyAnalysisPlugin = "1.31.0"
+coroutines = "1.8.1"
+datetime = "0.6.0"
+dependencyAnalysisPlugin = "1.32.0"
detekt = "1.23.6"
detektFormattingPlugin = "1.23.6"
dokka = "1.9.20"
-gradleAndroid = "8.4.0-rc02"
-gradleDoctorPlugin = "0.9.2"
+gradleAndroid = "8.6.0-alpha03"
+gradleDoctorPlugin = "0.10.0"
junit = "4.13.2"
-kotest = "5.8.1"
-kotest-plugin = "5.8.1"
+kotest = "5.9.0"
# @pin
-kotlin = "1.9.23"
-kotlinx-atomicfu = "0.23.2"
+kotlin = "2.0.0"
+kotlinx-atomicfu = "0.24.0"
turbine = "1.1.0"
versionCatalogUpdatePlugin = "0.8.4"
+compose = "1.6.10"
+lifecycle = "2.8.0"
+androidx-core = "1.13.1"
+androidx-activity = "1.9.0"
[libraries]
android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" }
+androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "composeDetektPlugin" }
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektFormattingPlugin" }
detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
@@ -37,6 +43,9 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycle" }
+lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose-android", version.ref = "lifecycle" }
[bundles]
unittest = [
@@ -48,10 +57,15 @@ unittest = [
]
[plugins]
+androidLibrary = { id = "com.android.library", version.ref = "gradleAndroid" }
atomicfu = "kotlinx-atomicfu:0.23.2"
dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysisPlugin" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" }
-kotest = { id = "io.kotest.multiplatform", version.ref = "kotest-plugin" }
+kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" }
version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+compose = { id = "org.jetbrains.compose", version.ref = "compose" }
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index d64cd49..e644113 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradlew.bat b/gradlew.bat
index 93e3f59..25da30d 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -43,11 +43,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 82b8358..5967789 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -12,13 +12,10 @@ pluginManagement {
// TODO: https://github.com/Kotlin/kotlinx-atomicfu/issues/56
resolutionStrategy {
eachPlugin {
- val module = when (requested.id.id) {
+ when (requested.id.id) {
"kotlinx-atomicfu" -> "org.jetbrains.kotlinx:atomicfu-gradle-plugin:${requested.version}"
else -> null
- }
- if (module != null) {
- useModule(module)
- }
+ }?.let(::useModule)
}
}
}
@@ -33,73 +30,19 @@ buildscript {
dependencyResolutionManagement {
// kmm plugin adds "ivy" repo as part of the apply block
- repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
+ repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
- ivyNative()
- node()
mavenCentral()
}
}
-fun RepositoryHandler.node() {
- exclusiveContent {
- forRepository {
- ivy("https://nodejs.org/dist/") {
- name = "Node Distributions at $url"
- patternLayout { artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") }
- metadataSources { artifact() }
- content { includeModule("org.nodejs", "node") }
- }
- }
- filter { includeGroup("org.nodejs") }
- }
-
- exclusiveContent {
- forRepository {
- ivy("https://github.com/yarnpkg/yarn/releases/download") {
- name = "Yarn Distributions at $url"
- patternLayout { artifact("v[revision]/[artifact](-v[revision]).[ext]") }
- metadataSources { artifact() }
- content { includeModule("com.yarnpkg", "yarn") }
- }
- }
- filter { includeGroup("com.yarnpkg") }
- }
-}
-
-fun RepositoryHandler.ivyNative() {
- ivy { url = uri("https://download.jetbrains.com") }
-
- exclusiveContent {
- forRepository {
- this@ivyNative.ivy("https://download.jetbrains.com/kotlin/native/builds") {
- name = "Kotlin Native"
- patternLayout {
- listOf(
- "macos-x86_64",
- "macos-aarch64",
- "osx-x86_64",
- "osx-aarch64",
- "linux-x86_64",
- "windows-x86_64",
- ).forEach { os ->
- listOf("dev", "releases").forEach { stage ->
- artifact("$stage/[revision]/$os/[artifact]-[revision].[ext]")
- }
- }
- }
- metadataSources { artifact() }
- }
- }
- filter { includeModuleByRegex(".*", ".*kotlin-native-prebuilt.*") }
- }
-}
-
rootProject.name = "kmmutils"
include(":common")
include(":datetime")
include(":coroutines")
include(":inputforms")
+include(":compose")
+include(":system")
diff --git a/stability_definitions.txt b/stability_definitions.txt
new file mode 100644
index 0000000..e69de29
diff --git a/system/build.gradle.kts b/system/build.gradle.kts
new file mode 100644
index 0000000..d5ddf92
--- /dev/null
+++ b/system/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ kotlin("multiplatform")
+ id("com.android.library")
+ id("maven-publish")
+ signing
+}
+
+kotlin {
+ configureMultiplatform(
+ this,
+ android = true,
+ jvm = false,
+ linux = false,
+ js = false,
+ tvOs = false,
+ iOs = false,
+ macOs = false,
+ watchOs = false,
+ windows = false,
+ wasmWasi = false,
+ wasmJs = false
+ )
+
+ sourceSets.androidMain.dependencies {
+ api(libs.androidx.core)
+ api(projects.common)
+ api(libs.androidx.activity)
+ }
+}
+
+android {
+ namespace = "${Config.namespace}.system"
+ configureAndroidLibrary(this)
+}
+
+publishMultiplatform()
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/AndroidExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/AndroidExt.kt
new file mode 100644
index 0000000..b751015
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/AndroidExt.kt
@@ -0,0 +1,129 @@
+@file:Suppress("unused")
+
+package pro.respawn.kmmutils.system.android
+
+import android.app.ActivityOptions
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Build.VERSION_CODES
+import android.text.format.DateFormat
+import android.view.autofill.AutofillManager
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.core.app.TaskStackBuilder
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+
+/**
+ * Get the current system [AutofillManager] if present.
+ */
+public val Context.autofillManager: AutofillManager?
+ get() = withApiLevel(VERSION_CODES.O, below = { null }) { getSystemService() }
+
+/**
+ * Restart the current activity in a new task.
+ */
+public fun Context.restartActivity() {
+ // Obtain the startup Intent of the application with the package name of the application
+ val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ startActivity(intent)
+}
+
+/**
+ * Returns whether the system is set to 24-hour time format right now.
+ */
+public val Context.isSystem24Hour: Boolean get() = DateFormat.is24HourFormat(this)
+
+/**
+ * Use this [Uri] as a deeplink intent, i.e. open this uri in a new task.
+ */
+public fun Uri.asDeeplinkIntent(
+ requestCode: Int,
+ context: Context
+): PendingIntent = TaskStackBuilder.create(context).run {
+ addNextIntentWithParentStack(intent().allowBackgroundActivityStart())
+ getPendingIntent(
+ requestCode,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )!!
+}
+
+/**
+ * Get the uri of this app's package.
+ */
+public val Context.packageUri: Uri get() = "package:$packageName".toUri()
+
+/**
+ * Execute [since] if the current sdk version is at least [versionCode]. Version code comes from [Build.VERSION_CODES].
+ */
+@ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
+public inline fun withApiLevel(versionCode: Int, since: () -> Unit) {
+ if (Build.VERSION.SDK_INT >= versionCode) since()
+}
+
+/**
+ * Execute [since] if the current sdk version is at least [versionCode], otherwise execute [below]
+ *
+ * Version code comes from [Build.VERSION_CODES].
+ */
+@ChecksSdkIntAtLeast(parameter = 0, lambda = 2)
+public inline fun withApiLevel(versionCode: Int, below: () -> R, since: () -> R): R =
+ if (Build.VERSION.SDK_INT >= versionCode) since() else below()
+
+/**
+ * Execute [ifGranted] if the [permission] has been granted, otherwise execute [ifDenied].
+ *
+ * This function does not request the permission.
+ *
+ * [permission] parameter is defined in [android.Manifest.permission]
+ */
+public inline fun Context.withPermission(
+ permission: String,
+ ifDenied: Context.(String) -> T,
+ ifGranted: Context.(String) -> T
+): T = if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
+ ifGranted(permission) else ifDenied(permission)
+
+/**
+ * Execute [ifGranted] if the [permission] has been granted, otherwise do nothing.
+ *
+ * This function does not request the permission.
+ *
+ * [permission] parameter is defined in [android.Manifest.permission]
+ */
+public inline fun Context.withPermission(
+ permission: String,
+ ifGranted: Context.(String) -> Unit,
+) {
+ if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) ifGranted(permission)
+}
+
+/**
+ * Create a view intent from this uri.
+ */
+public fun Uri.intent(): Intent = Intent(Intent.ACTION_VIEW, this)
+
+/**
+ * Allows background activity start for this intent starting with SDK 34 (Upside down cake).
+ * On previous versions, does nothing.
+ */
+public fun Intent.allowBackgroundActivityStart(): Intent = apply {
+ withApiLevel(VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ ActivityOptions
+ .makeBasic()
+ .setPendingIntentCreatorBackgroundActivityStartMode(ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED)
+ .toBundle()
+ .let(::putExtras)
+ }
+}
+
+/**
+ * Get an uri to a google play store page of this app.
+ */
+public fun Context.getGooglePlayUri(): Uri =
+ "https://play.google.com/store/apps/details?id=${applicationInfo.packageName}".toUri()
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ArgsExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ArgsExt.kt
new file mode 100644
index 0000000..769ab83
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ArgsExt.kt
@@ -0,0 +1,86 @@
+package pro.respawn.kmmutils.system.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import java.io.Serializable
+import kotlin.reflect.typeOf
+
+/**
+ * Creates an intent to launch a given class. The type must be of an Android system component, such as an Activity.
+ */
+public inline fun intentFor(context: Context): Intent = Intent(context, T::class.java)
+
+/**
+ * Obtain a serializable object of type [T] from this [Bundle]
+ * @see requireSerializable
+ */
+@Suppress("DEPRECATION")
+public inline fun Bundle.serializable(
+ key: String
+): T? = withApiLevel(
+ Build.VERSION_CODES.TIRAMISU,
+ below = { getSerializable(key) as? T? },
+) { getSerializable(key, T::class.java) }
+
+/**
+ * Obtain a serializable object of type [T] from this [Intent]'s extras
+ * @see requireSerializable
+ */
+public inline fun Intent.serializable(key: String): T? = extras?.serializable(key)
+
+/**
+ * Obtain a serializable object of type [T] from this [Bundle]
+ * @see serializable
+ */
+public inline fun Bundle.requireSerializable(
+ key: String
+): T = requireNotNull(serializable(key)) { "Bundle contained no Serializable of type ${typeOf()}" }
+
+/**
+ * Obtain a serializable object of type [T] from this [Intent]'s extras
+ * @see serializable
+ */
+public inline fun Intent.requireSerializable(
+ key: String
+): T = requireNotNull(serializable(key)) { "Bundle contained no Serializable of type ${typeOf()}" }
+
+/**
+ * Obtain a parcelable object of type [T] from this [Bundle].
+ *
+ * @see requireParcelable
+ */
+@Suppress("DEPRECATION")
+public inline fun Bundle.parcelable(
+ key: String
+): T? = withApiLevel(
+ Build.VERSION_CODES.TIRAMISU,
+ below = { getParcelable(key) as? T? }
+) { getParcelable(key, T::class.java) }
+
+/**
+ * Obtain a parcelable object of type [T] from this [Intent]'s extras
+ *
+ * @see requireParcelable
+ */
+public inline fun Intent.parcelable(key: String): T? = extras?.parcelable(key)
+
+/**
+ * Obtain a parcelable object of type [T] from this [Bundle].
+ *
+ * @see parcelable
+ */
+public inline fun Bundle.requireParcelable(key: String): T = requireNotNull(parcelable(key)) {
+ "Bundle contained no Parcelable of type ${typeOf()}"
+}
+
+/**
+ * Obtain a parcelable object of type [T] from this [Intent]'s extras.
+ *
+ * @see parcelable
+ */
+public inline fun Intent.requireParcelable(key: String): T = requireNotNull(parcelable(key)) {
+ "Bundle contained no Parcelable of type ${typeOf()}"
+}
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CompatExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CompatExt.kt
new file mode 100644
index 0000000..d8c5e55
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CompatExt.kt
@@ -0,0 +1,115 @@
+package pro.respawn.kmmutils.system.android
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.os.Build
+import android.os.VibrationAttributes
+import android.os.VibrationEffect
+import android.os.Vibrator
+import androidx.annotation.RequiresPermission
+
+/**
+ * Vibrates using this [Vibrator] for [lengthMs] milliseconds.
+ *
+ * * Requires a [Manifest.permission.VIBRATE] to function.
+ * * Works on versions of android down to 21.
+ *
+ * @param usage required which is defined in [VibrationAttributes].
+ * @param amplitude is defined in [VibrationEffect]. When null, [VibrationEffect.DEFAULT_AMPLITUDE] will be used.
+ */
+@Suppress("DEPRECATION")
+@RequiresPermission(Manifest.permission.VIBRATE)
+public fun Vibrator.vibrateCompat(
+ lengthMs: Long,
+ usage: Int,
+ amplitude: Int? = null,
+): Unit = when (Build.VERSION.SDK_INT) {
+ Build.VERSION_CODES.TIRAMISU -> vibrate(
+ VibrationEffect.createOneShot(lengthMs, amplitude ?: VibrationEffect.DEFAULT_AMPLITUDE),
+ VibrationAttributes.createForUsage(usage)
+ )
+ Build.VERSION_CODES.Q -> vibrate(VibrationEffect.createOneShot(lengthMs, VibrationEffect.DEFAULT_AMPLITUDE))
+ else -> vibrate(lengthMs)
+}
+
+/**
+ * Vibrates using this [Vibrator] using provided [waveform].
+ *
+ * In effect, the timings array represents the number of milliseconds before turning the vibrator on,
+ * followed by the number of milliseconds to keep the vibrator on, then the number of milliseconds turned off,
+ * and so on. Consequently, the first timing value will often be 0, so that the effect will start vibrating immediately.
+ *
+ * * Requires a [Manifest.permission.VIBRATE] to function.
+ * * Works on versions of android down to 21.
+ *
+ * @param usage required which is defined in [VibrationAttributes].
+ * @param repeat the index into the [waveform], at which to start indefinite repetitions of the vibration,
+ * where -1 means no repeat. If you pass anything but -1 there, you **must** cancel the vibration manually by calling
+ * [Vibrator.cancel]
+ */
+@Suppress("DEPRECATION")
+@RequiresPermission(Manifest.permission.VIBRATE)
+public fun Vibrator.vibrateCompat(
+ waveform: LongArray,
+ usage: Int,
+ repeat: Int = -1,
+): Unit = when (Build.VERSION.SDK_INT) {
+ Build.VERSION_CODES.TIRAMISU -> vibrate(
+ VibrationEffect.createWaveform(waveform, repeat),
+ VibrationAttributes.createForUsage(usage)
+ )
+ Build.VERSION_CODES.Q -> vibrate(VibrationEffect.createWaveform(waveform, repeat))
+ else -> vibrate(waveform, repeat)
+}
+
+/**
+ *
+ * This function will query the [ApplicationInfo] of the [packageName] provided.
+ *
+ * Will work only if [Manifest.permission.QUERY_ALL_PACKAGES] permission is present
+ *
+ * @param flags are [PackageManager.ApplicationInfoFlags] and are listed as members of the [PackageManager] class.
+ */
+@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
+public fun PackageManager.getAppInfoCompat(packageName: String, flags: Int): ApplicationInfo = withApiLevel(
+ versionCode = Build.VERSION_CODES.TIRAMISU,
+ below = { getApplicationInfo(packageName, flags) },
+ since = { getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong())) }
+)
+
+/**
+ * This function will query the resolvable (exported) activities of the target [intent] using this [PackageManager].
+ * This is useful when you want to start another app.
+ *
+ * Will work only if [Manifest.permission.QUERY_ALL_PACKAGES] permission is present.
+ *
+ * @param flags are [PackageManager.ResolveInfoFlags] and are listed as members of the [PackageManager] class.
+ * @return the [List] of [ResolveInfo]s for launchable activities.
+ */
+@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
+public fun PackageManager.queryIntentActivitiesCompat(
+ intent: Intent,
+ flags: Int = 0,
+): List = withApiLevel(
+ versionCode = Build.VERSION_CODES.TIRAMISU,
+ below = { queryIntentActivities(intent, flags) },
+ since = { queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(flags.toLong())) },
+)
+
+/**
+ * Will get a full list of userspace apps installed on the device + some of the system apps that can be resolved.
+ *
+ * @param flags are [PackageManager.PackageInfoFlags] listed in the [PackageManager] class.
+ */
+@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
+public fun PackageManager.getInstalledPackagesCompat(
+ flags: Int = 0,
+): List = withApiLevel(
+ versionCode = Build.VERSION_CODES.TIRAMISU,
+ below = { getInstalledPackages(flags) },
+ since = { getInstalledPackages(PackageManager.PackageInfoFlags.of(flags.toLong())) }
+)
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CoroutineReceiver.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CoroutineReceiver.kt
new file mode 100644
index 0000000..41bb3fa
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CoroutineReceiver.kt
@@ -0,0 +1,55 @@
+package pro.respawn.kmmutils.system.android
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * A [BroadcastReceiver] that calls [goAsync] and launches a coroutine to execute long-running tasks.
+ * The [receive] method is still executed on main thread, move to another thread as needed.
+ *
+ * According to Android limitations, you still have about **10 seconds** to finish the execution before
+ * the system forcibly kills the receiver. For longer tasks, use WorkManager or a Service
+ */
+public abstract class CoroutineReceiver : BroadcastReceiver() {
+
+ protected val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ if (intent == null) return
+
+ val result = goAsync()
+ scope.launch {
+ try {
+ withTimeout(MaxDuration) { receive(context, intent) }
+ } catch (e: TimeoutCancellationException) {
+ throw IllegalStateException(Message, e)
+ } finally {
+ result.finish()
+ }
+ }
+ }
+
+ /**
+ * Still executed on main thread.
+ * Maximum duration for execution is about 10 seconds, after which the system will kill the broadcast receiver.
+ */
+ protected abstract suspend fun receive(context: Context, intent: Intent)
+
+ private companion object {
+
+ private val MaxDuration = 10.seconds
+ private val Message = """
+ CoroutineReceiver has been suspended for more than ${MaxDuration.inWholeSeconds}
+ You cannot execute CoroutineReceiver for a long time because the system will kill it.
+ If you want to execute long-running tasks, launch a worker from your receiver.
+ """.trimIndent()
+ }
+}
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/NetworkExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/NetworkExt.kt
new file mode 100644
index 0000000..d174c91
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/NetworkExt.kt
@@ -0,0 +1,102 @@
+@file:Suppress("unused")
+
+package pro.respawn.kmmutils.system.android
+
+import android.net.Uri
+import androidx.core.net.MailTo
+
+/**
+ * If this uri is an http uri, will convert it to an https uri, otherwise do nothing
+ */
+public val Uri.asHttps: Uri get() = if (scheme == "http") buildUpon().scheme("https").build() else this
+
+/**
+ * Whether this uri is an http uri
+ */
+public val Uri.isHttp: Boolean get() = scheme?.startsWith("http", true) == true
+
+/**
+ * Get the [LinkType] of the current URI
+ */
+public val Uri.linkType: LinkType
+ get() = when (this.scheme) {
+ null -> LinkType.Unknown
+ "http", "https" -> LinkType.Web
+ "mailto" -> LinkType.Mail
+ "tel" -> LinkType.Tel
+ "blob" -> LinkType.Blob
+ "content" -> LinkType.ContentProvider
+ "dns" -> LinkType.Dns
+ "drm" -> LinkType.Drm
+ "fax" -> LinkType.Fax
+ "geo" -> LinkType.Geo
+ "magnet" -> LinkType.Magnet
+ "maps" -> LinkType.Map
+ "market" -> LinkType.GooglePlay
+ "messgage" -> LinkType.AppleMail
+ "mms", "sms" -> LinkType.TextMessage
+ "query" -> LinkType.FilesystemQuery
+ "resource" -> LinkType.Resource
+ "skype", "callto" -> LinkType.Skype
+ "ssh" -> LinkType.Ssh
+ "webcal" -> LinkType.Calendar
+ "file" -> LinkType.File
+ else -> LinkType.Other
+ }
+
+/**
+ * Type of the [Uri]'s scheme
+ */
+public enum class LinkType {
+ Web,
+ Mail,
+ Tel,
+ Other,
+ Unknown,
+ Blob,
+ ContentProvider,
+ Dns,
+ Drm,
+ File,
+ Calendar,
+ Ssh,
+ Skype,
+ Resource,
+ FilesystemQuery,
+ TextMessage,
+ AppleMail,
+ GooglePlay,
+ Map,
+ Magnet,
+ Geo,
+ Fax
+}
+
+/**
+ * An object representing an email about to be senta with [sendEmail].
+ *
+ * @param recipients the list of recipient emails, or null to leave blank
+ * @param subject an optional subject of the email
+ * @param body the body of the email
+ */
+public data class Email(
+ val recipients: List? = null,
+ val subject: String? = null,
+ val body: String? = null,
+) {
+
+ public companion object {
+
+ /**
+ * Create an [Email] from the [Uri]
+ */
+ public operator fun invoke(uri: Uri): Email {
+ val mail = MailTo.parse(uri)
+ return Email(
+ recipients = mail.to?.split(", "),
+ subject = mail.subject,
+ body = mail.body
+ )
+ }
+ }
+}
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/PickRingtoneContract.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/PickRingtoneContract.kt
new file mode 100644
index 0000000..6a01f94
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/PickRingtoneContract.kt
@@ -0,0 +1,69 @@
+package pro.respawn.kmmutils.system.android
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.media.RingtoneManager
+import android.net.Uri
+import android.os.Build
+import androidx.activity.result.contract.ActivityResultContract
+
+/**
+ * Contract for picking ringtones.
+ * Returns one of:
+ * * An uri of the picked ringtone,
+ * * a Uri that equals System#DEFAULT_RINGTONE_URI, System#DEFAULT_NOTIFICATION_URI, or System#DEFAULT_ALARM_ALERT_URI
+ * if the default was chosen,
+ * * null if the "Silent" item was picked or if there was an error.
+ */
+public class PickRingtoneContract : ActivityResultContract() {
+
+ override fun createIntent(context: Context, input: RingtoneOptions): Intent =
+ Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
+ putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, input.showDefault)
+ putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, input.allowSilent)
+ putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, input.ringtoneType)
+ input.title?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, it) }
+ input.defaultUri?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, it) }
+ input.existingUri?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, it) }
+ }
+
+ override fun getSynchronousResult(context: Context, input: RingtoneOptions): SynchronousResult? = null
+
+ @Suppress("DEPRECATION")
+ override fun parseResult(resultCode: Int, intent: Intent?): Uri? =
+ intent?.takeIf { resultCode == Activity.RESULT_OK }?.run {
+ data ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
+ } else {
+ intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
+ }
+ }
+
+ /**
+ * Ringtone options
+ * @param ringtoneType one of TYPE_RINGTONE, TYPE_NOTIFICATION, TYPE_ALARM, or TYPE_ALL (bitwise or'd together)
+ * @param title The title of the ringtone picker. Default value is suitable for most cases
+ * @param defaultUri The uri to the sound to play when the user previews "default" sound.
+ * This can be one of System#DEFAULT_RINGTONE_URI,
+ * System#DEFAULT_NOTIFICATION_URI, or System#DEFAULT_ALARM_ALERT_URI
+ * to have the "Default" point to the current sound for the given default sound type.
+ * If you are showing a ringtone picker for some other type of sound, you are free to provide any Uri here.
+ * @param existingUri Given to the ringtone picker as a Uri. The Uri of the current ringtone,
+ * which will be used to show a checkmark next to the item for this Uri.
+ * If showing an item for "Default" (@see EXTRA_RINGTONE_SHOW_DEFAULT),
+ * this can also be one of System#DEFAULT_RINGTONE_URI,
+ * System#DEFAULT_NOTIFICATION_URI, or System#DEFAULT_ALARM_ALERT_URI to have the "Default" item checked.
+ * @param allowSilent Whether to show the "Silent" item in the list.
+ * @param showDefault Whether to show the "Default" item in the list.
+ * @see RingtoneManager
+ */
+ public data class RingtoneOptions(
+ val ringtoneType: Int,
+ val showDefault: Boolean = true,
+ val allowSilent: Boolean = false,
+ val title: String? = null,
+ val defaultUri: Uri? = null,
+ val existingUri: Uri? = null,
+ )
+}
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ResourcesExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ResourcesExt.kt
new file mode 100644
index 0000000..4914d6a
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ResourcesExt.kt
@@ -0,0 +1,82 @@
+@file:Suppress("unused")
+
+package pro.respawn.kmmutils.system.android
+
+import android.content.ContentResolver
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import androidx.core.content.ContextCompat
+import androidx.core.os.ConfigurationCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.Locale
+
+/**
+ * Get dimension dp value from your xml.
+ * When you use [Resources.getDimension] you get the amount of pixels for that dimen.
+ * This function returns a proper dp value just like what you wrote in your dimen.xml
+ */
+public fun Resources.getDimenInDP(id: Int): Int = (getDimension(id) / displayMetrics.density).toInt()
+
+/**
+ * Rescales the bitmap
+ * @param maxSize The maximum size of the longest side of the image (can be either height or width) in pixels
+ * @return scaled bitmap
+ */
+public suspend fun Bitmap.scale(maxSize: Int): Bitmap = withContext(Dispatchers.Default) {
+ val ratio = width.toFloat() / height.toFloat()
+ var newWidth = maxSize
+ var newHeight = maxSize
+ if (ratio > 1) {
+ newHeight = (maxSize / ratio).toInt()
+ } else {
+ newWidth = (maxSize * ratio).toInt()
+ }
+ Bitmap.createScaledBitmap(this@scale, newWidth, newHeight, true)
+}
+
+/**
+ * Uses the value of this int as a **resource id** to parse an [android.graphics.Color] object
+ */
+public fun Int.asColor(context: Context): Int = ContextCompat.getColor(context, this)
+
+/**
+ * Uses this int as a **resource id** to get a drawable
+ */
+public fun Int.asDrawable(context: Context): Drawable? = ContextCompat.getDrawable(context, this)
+
+/**
+ * Returns the currently used locale on the device.
+ * Returns the first locale the user has listed in the system settings.
+ */
+public val Resources.currentLocale: Locale
+ get() = ConfigurationCompat.getLocales(configuration).get(0)!!
+
+/**
+ * Returns an uri to the given [resourceId].
+ * **This uri is NOT safe to store outside of app lifecycle!**
+ *
+ * The uri may change on app update or relaunch.
+ */
+public fun Context.getResourceUri(resourceId: Int): Uri = Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(resources.getResourcePackageName(resourceId))
+ .appendPath(resources.getResourceTypeName(resourceId))
+ .appendPath(resources.getResourceEntryName(resourceId))
+ .build()
+
+/**
+ * Returns an URI to [this] raw resource id.
+ *
+ * **This uri is NOT safe to store outside of app lifecycle!**
+ *
+ * The uri may change on app update or relaunch.
+ */
+public fun Int.raw(context: Context): Uri = Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(context.applicationContext.packageName)
+ .appendPath(toString())
+ .build()
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/Spans.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/Spans.kt
new file mode 100644
index 0000000..72d23ef
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/Spans.kt
@@ -0,0 +1,124 @@
+package pro.respawn.kmmutils.system.android
+
+import android.graphics.Typeface
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.SpannableStringBuilder
+import android.text.style.BackgroundColorSpan
+import android.text.style.ClickableSpan
+import android.text.style.ForegroundColorSpan
+import android.text.style.RelativeSizeSpan
+import android.text.style.StrikethroughSpan
+import android.text.style.StyleSpan
+import android.text.style.SubscriptSpan
+import android.text.style.SuperscriptSpan
+import android.text.style.UnderlineSpan
+import android.view.View
+import androidx.annotation.ColorInt
+
+/**
+ * Create a span with a [clickablePart] of the text, and invokes the [onClickListener] on click.
+ */
+public fun SpannableString.withClickableSpan(
+ clickablePart: String,
+ onClickListener: () -> Unit
+): SpannableString {
+ val clickableSpan = object : ClickableSpan() {
+ override fun onClick(widget: View) = onClickListener.invoke()
+ }
+ val clickablePartStart = indexOf(clickablePart)
+ setSpan(
+ clickableSpan,
+ clickablePartStart,
+ clickablePartStart + clickablePart.length,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ return this
+}
+
+/**
+ * [span] is a ..Span object like a [ForegroundColorSpan] or a [SuperscriptSpan]
+ * Spans this whole string
+ */
+public fun CharSequence.span(span: Any): SpannableString = SpannableString(this).setSpan(span)
+
+/**
+ * Create a [SpannableStringBuilder] from this
+ */
+public fun CharSequence.buildSpan(): SpannableStringBuilder = SpannableStringBuilder(this)
+
+/**
+ * Create a spannable string
+ */
+public val CharSequence.spannable: SpannableString get() = SpannableString(this)
+
+/**
+ * [span] is a ..Span object like a [ForegroundColorSpan] or a [SuperscriptSpan]
+ * Spans this whole string
+ */
+public fun SpannableString.setSpan(span: Any?): SpannableString = apply {
+ setSpan(span, 0, length, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
+}
+
+/**
+ * Changes the color of the text to [color].
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.foregroundColor(@ColorInt color: Int): SpannableString = span(ForegroundColorSpan(color))
+
+/**
+ * Changes the background color of the text to [color].
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.backgroundColor(@ColorInt color: Int): SpannableString = span(BackgroundColorSpan(color))
+
+/**
+ * Sets the size of this string to relative size [size], which is a multiplier fraction larger than 0
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.relativeSize(size: Float): SpannableString = span(RelativeSizeSpan(size))
+
+/**
+ * Sets this string to be drawn as a superscript (smaller font aligned to the top of the text, such as numeric power)
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.superscript(): SpannableString = span(SuperscriptSpan())
+
+/**
+ * Sets this string to be drawn as a superscript (smaller font aligned to the bottom of the text)
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.subscript(): SpannableString = span(SubscriptSpan())
+
+/**
+ * Strikes this string through
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.strike(): SpannableString = span(StrikethroughSpan())
+
+/**
+ * Applies bold style to this string
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.bold(): SpannableString = span(StyleSpan(Typeface.BOLD))
+
+/**
+ * Applies italic style to this string
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.italic(): SpannableString = span(StyleSpan(Typeface.ITALIC))
+
+/**
+ * Adds an underline to this string
+ *
+ * @returns a [SpannableString] that can be applied to XML views
+ */
+public fun CharSequence.underline(): SpannableString = span(UnderlineSpan())
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/StringExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/StringExt.kt
new file mode 100644
index 0000000..1c56b23
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/StringExt.kt
@@ -0,0 +1,51 @@
+@file:Suppress("unused")
+
+package pro.respawn.kmmutils.system.android
+
+import android.graphics.Color
+import android.text.Spanned
+import androidx.core.text.HtmlCompat
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+import java.util.Locale
+
+/**
+ * If this is a valid hex color string representation, returns its R, G and B components
+ * @throws IllegalArgumentException if the color string is invalid
+ *
+ */
+public fun String.hexToRGB(): Triple {
+ var name = this
+ if (!name.startsWith("#")) {
+ name = "#$this"
+ }
+ val color = Color.parseColor(name)
+ val red = Color.red(color)
+ val green = Color.green(color)
+ val blue = Color.blue(color)
+ return Triple(red, green, blue)
+}
+
+/**
+ * If this is a color int, turns it into a hex string.
+ */
+public fun Int.colorToHexString(): String = String.format(Locale.ROOT, "#%06X", -0x1 and this).replace("#FF", "#")
+
+/**
+ * Create an android [Spanned] from this string by parsing it as an html text
+ */
+public val String.asHTML: Spanned
+ get() = HtmlCompat.fromHtml(this, 0)
+
+/**
+ * @param algorithm โ the name of the algorithm requested.
+ * See the [MessageDigest] for information about standard algorithm names and supported API levels
+ */
+public fun ByteArray.hash(algorithm: String): ByteArray? = try {
+ MessageDigest.getInstance(algorithm).run {
+ update(this@hash)
+ digest()
+ }
+} catch (expected: NoSuchAlgorithmException) {
+ null
+}
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/SystemLaunchers.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/SystemLaunchers.kt
new file mode 100644
index 0000000..1ec78c1
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/SystemLaunchers.kt
@@ -0,0 +1,216 @@
+package pro.respawn.kmmutils.system.android
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.DownloadManager
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build.VERSION_CODES
+import android.os.Environment
+import android.provider.Settings
+import android.webkit.CookieManager
+import androidx.activity.result.ActivityResultLauncher
+import androidx.annotation.RequiresApi
+import androidx.core.app.ActivityOptionsCompat
+import androidx.core.content.ContextCompat
+
+/**
+ * Start activity safely and handle errors where an activity is not found.
+ *
+ * Prefer to **always** use this method instead of the default one, as any activity you may want to launch
+ * **may not be found** and the original function will **throw**.
+ *
+ * Even system activities like file pickers, galleries and settings may be missing.
+ * Even your app's activities may be unavailable.
+ */
+public inline fun Context.startActivityCatching(intent: Intent, onNotFound: (ActivityNotFoundException) -> Unit) {
+ try {
+ startActivity(intent)
+ } catch (expected: ActivityNotFoundException) {
+ onNotFound(expected)
+ }
+}
+
+/**
+ * Start activity safely and handle errors where an activity is not found.
+ *
+ * Prefer to **always** use this method instead of the default one, as any activity you may want to launch
+ * **may not be found** and the original function will **throw**.
+ *
+ * Even system activities like file pickers, galleries and settings may be missing.
+ * Even your app's activities may be unavailable.
+ */
+public inline fun ActivityResultLauncher.launchCatching(
+ input: I,
+ options: ActivityOptionsCompat? = null,
+ onNotFound: (ActivityNotFoundException) -> Unit,
+) {
+ try {
+ launch(input, options)
+ } catch (e: ActivityNotFoundException) {
+ onNotFound(e)
+ }
+}
+
+/**
+ * @param numberUri uri of the form tel:+1234567890, containing countryCode
+ */
+public inline fun Context.dialNumber(numberUri: Uri, onNotFound: (e: ActivityNotFoundException) -> Unit) {
+ val intent = Intent(Intent.ACTION_DIAL, numberUri)
+ startActivityCatching(intent, onNotFound)
+}
+
+/**
+ * Saves file using [DownloadManager] to users /sdcard/[directory]
+ *
+ * @param notificationMode an int flag specified in [DownloadManager.Request] outlining the notification visibility.
+ * If enabled, the download manager posts notifications about downloads through the system NotificationManager.
+ * By default, a notification is shown only when the download is in progress.
+ * It can take the following values: VISIBILITY_HIDDEN, VISIBILITY_VISIBLE, VISIBILITY_VISIBLE_NOTIFY_COMPLETED.
+ * If set to `VISIBILITY_HIDDEN`, this requires the permission [Manifest.permission.DOWNLOAD_WITHOUT_NOTIFICATION]
+ *
+ * @param directory - one of the [Environment] `DIRECTORY_*` constants. Not all directories are available without a
+ * permission to write to external storage. Available dirs are:
+ * [Environment.DIRECTORY_DOWNLOADS], [Environment.DIRECTORY_PICTURES], [Environment.DIRECTORY_MOVIES], among others
+ *
+ * @param onFailure is called if there was an exception. Possible exceptions are:
+ * - ActivityNotFoundException
+ * - SecurityException - when permission to write to storage was not granted
+ * - IllegalStateException - when provided parameters are invalid (i.e. download directory can't be created)
+ * */
+public inline fun Context.downloadFile(
+ url: Uri,
+ fileName: String,
+ userAgent: String? = null,
+ title: String = fileName,
+ description: String? = null,
+ mimeType: String? = null,
+ notificationMode: Int = DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED,
+ directory: String = Environment.DIRECTORY_DOWNLOADS,
+ onFailure: (e: Exception) -> Unit,
+) {
+ val request = DownloadManager.Request(url).apply {
+ val cookies = CookieManager.getInstance().getCookie(url.toString())
+ addRequestHeader("cookie", cookies)
+ addRequestHeader("User-Agent", userAgent)
+ if (description != null) setDescription(description)
+ setTitle(title)
+ setMimeType(mimeType)
+ setNotificationVisibility(notificationMode)
+ setDestinationInExternalPublicDir(directory, fileName)
+ }
+ try {
+ ContextCompat.getSystemService(this, DownloadManager::class.java)
+ ?.enqueue(request)
+ ?: throw ActivityNotFoundException("DownloadManager not found")
+ } catch (expected: Exception) {
+ onFailure(expected)
+ return
+ }
+}
+
+/**
+ * Open the system browser for the specified [url].
+ *
+ * **[onAppNotFound] **must** be handled as some phones do not have browsers installed. Display an error message with a
+ * prompt to install a browser in this case.
+ */
+public inline fun Context.openBrowser(url: Uri, onAppNotFound: (e: ActivityNotFoundException) -> Unit) {
+ val intent = Intent(Intent.ACTION_VIEW, url).apply {
+ addCategory(Intent.CATEGORY_BROWSABLE)
+ }
+ startActivityCatching(intent, onAppNotFound)
+}
+
+/**
+ * Open the system share sheet for sharing the specified [text].
+ *
+ * **[onAppNotFound] **must** be handled as some phones do not have any app to share with.
+ * Display an error message with a prompt to install an app in this case.
+ */
+public inline fun Context.shareAsText(text: String, onAppNotFound: (e: ActivityNotFoundException) -> Unit) {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ putExtra(Intent.EXTRA_TEXT, text)
+ type = "text/plain"
+ }
+ val shareIntent = Intent.createChooser(intent, null)
+ startActivityCatching(shareIntent, onAppNotFound)
+}
+
+/**
+ * Open an email app to send the specified [mail].
+ *
+ * **[onAppNotFound] **must** be handled as some phones do not have any email apps.
+ * Display an error message with a prompt to install an app in this case.
+ */
+public inline fun Context.sendEmail(mail: Email, onAppNotFound: (e: ActivityNotFoundException) -> Unit) {
+ // Use SENDTO to avoid showing pickers and letting non-email apps interfere
+ val sendIntent: Intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:")
+ // EXTRA_EMAIL should be array
+ putExtra(Intent.EXTRA_EMAIL, mail.recipients?.toTypedArray())
+ putExtra(Intent.EXTRA_SUBJECT, mail.subject)
+ putExtra(Intent.EXTRA_TEXT, mail.body)
+ }
+ startActivityCatching(sendIntent, onAppNotFound)
+}
+
+/**
+ * Open system notification settings for this app.
+ */
+@RequiresApi(VERSION_CODES.O)
+public fun Context.openNotificationSettings(onError: (ActivityNotFoundException) -> Unit): Unit = startActivityCatching(
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
+ },
+ onNotFound = onError,
+)
+
+/**
+ * Open system notification settings for the current app.
+ */
+public fun Context.openAppDetails(onError: (Exception) -> Unit): Unit = startActivityCatching(
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri),
+ onNotFound = onError,
+)
+
+/**
+ * Open system overlay (SYSTEM_ALERT_WINDOW) settings.
+ *
+ * Due to platform limitations, this will display a simple list of all device apps and the user must scroll and find
+ * your app there. Instruct the user as appropriate.
+ */
+public inline fun Context.openSystemOverlaysSettings(onError: (Exception) -> Unit) {
+ startActivityCatching(
+ Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri),
+ onNotFound = onError,
+ )
+}
+
+/**
+ * Display a prompt to ignore Doze mode optimizations and unrestrict [AlarmManager] APIs for this app.
+ */
+@SuppressLint("BatteryLife")
+public inline fun Context.requestIgnoreBatteryOptimization(onError: (Exception) -> Unit): Unit = startActivityCatching(
+ Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri),
+ onNotFound = onError,
+)
+
+/**
+ * Request a permission too schedule exact alarms from the user.
+ * This will bring the user to a page that prompts them to flip a switch for your app.
+ */
+@RequiresApi(VERSION_CODES.S)
+public inline fun Context.requestExactAlarmPermission(
+ onError: (ActivityNotFoundException) -> Unit
+): Unit = startActivityCatching(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM), onError)
+
+/**
+ * Open the app's play store page.
+ *
+ * **[onNotFound] must be handled because some phones do not have neither play store nor a browser app installed**
+ */
+public inline fun Context.openAppPlayStorePage(onNotFound: (e: Exception) -> Unit): Unit =
+ openBrowser(getGooglePlayUri(), onNotFound)
diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/WebClient.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/WebClient.kt
new file mode 100644
index 0000000..32ec58f
--- /dev/null
+++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/WebClient.kt
@@ -0,0 +1,231 @@
+@file:Suppress("unused")
+
+package pro.respawn.kmmutils.system.android
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Bundle
+import android.webkit.CookieManager
+import android.webkit.DownloadListener
+import android.webkit.URLUtil
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebStorage
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.core.net.toUri
+
+/**
+ * A listener for events that happen in [WebClient]
+ */
+public interface WebClientListener {
+
+ /**
+ * Is triggered when whole page could not be loaded (not triggered for css, js, ads & other errors)
+ */
+ public fun onError(url: Uri?)
+
+ /**
+ * Triggered **once** when the page has finished loading
+ */
+ public fun onSuccess(url: Uri?)
+
+ /**
+ * Triggered when you call [WebClient.load]
+ */
+ public fun onStartedLoading(url: Uri?)
+
+ /**
+ * Triggered in the following situations:
+ * 1. When attempting to load any url that is not in [WebClient.allowedHosts]
+ * 2. When opening a link that is not browser-openable (i.e. mailto:)
+ * 3. When attempting to open an unknown link type
+ */
+ public fun onForeignUrlEncountered(url: Uri, linkType: LinkType)
+
+ /**
+ * Triggered when opening a link pointing to a file, i.e. https://.pdf etc.
+ */
+ public fun onRequestedFileDownload(url: Uri, fileName: String, mimetype: String?)
+}
+
+/**
+ * A web client that solves many problems of [WebViewClient] and provides a nice API to use.
+ * **This client automatically sets JS as enabled**. You can override this behavior by calling
+ * [WebSettings.setJavaScriptEnabled]. Only domains in [allowedHosts] will be opened in the webview, others will
+ * trigger [WebClientListener.onForeignUrlEncountered]. Don't forget to [attach] the client in onViewCreated and
+ * [detach] it in onDestroyView.
+ */
+@SuppressLint("SetJavaScriptEnabled")
+public open class WebClient(
+ private val allowedHosts: List,
+) : WebViewClient(), DownloadListener {
+
+ private var webView: WebView? = null
+ private var listener: WebClientListener? = null
+
+ /**
+ * Current webview URL, or null if nothing is loaded
+ */
+ public val url: String? get() = webView?.url
+
+ /**
+ * Whether the web history is not empty and the user can go back
+ */
+ public open val canGoBack: Boolean
+ get() = webView?.canGoBack() ?: false
+
+ /**
+ * Refresh current page.
+ */
+ public open fun reload() {
+ webView?.reload()
+ }
+
+ /**
+ * Load an [uri]
+ */
+ public open fun load(uri: Uri) {
+ webView?.loadUrl(uri.toString())
+ }
+
+ /**
+ * Call this in [androidx.appcompat.app.AppCompatActivity.onSaveInstanceState] state or
+ * [androidx.fragment.app.Fragment.onViewStateRestored], **if you do not save state, webView will not save it for
+ * you!**
+ */
+ public open fun restoreState(inState: Bundle) {
+ webView?.restoreState(inState)
+ }
+
+ /**
+ * Call this in [androidx.fragment.app.Fragment.onSaveInstanceState] to **save webview state**. If you do not
+ * call this, **webview won't save it for you!**
+ */
+ public open fun saveState(outState: Bundle) {
+ webView?.saveState(outState)
+ // TODO: Restore and save client-specific fields
+ }
+
+ /**
+ * Call this in [androidx.fragment.app.Fragment.onViewCreated]
+ */
+ public open fun attach(
+ webView: WebView,
+ listener: WebClientListener? = null,
+ userAgent: String? = null,
+ javaScriptEnabled: Boolean = true,
+ ): WebClient {
+ this.webView = webView.apply {
+ settings.apply {
+ setJavaScriptEnabled(javaScriptEnabled)
+ loadsImagesAutomatically = true
+ useWideViewPort = true
+ userAgent?.let { userAgentString = it }
+ loadWithOverviewMode = true
+ javaScriptCanOpenWindowsAutomatically = false
+ setDownloadListener(this@WebClient)
+ }
+ webChromeClient = WebChromeClient()
+ webViewClient = this@WebClient
+ }
+ this.listener = listener
+ return this
+ }
+
+ /**
+ * Call this in [androidx.fragment.app.Fragment.onDestroyView]. If you do not call this, you will get crashes in
+ * runtime and a memory leak!
+ */
+ public open fun detach() {
+ webView = null
+ listener = null
+ }
+
+ /**
+ * Go back through the browser history if possible
+ */
+ public open fun goBack() {
+ if (canGoBack) webView?.goBack()
+ }
+
+ /**
+ * Clear the navigation history
+ */
+ public open fun clearHistory() {
+ webView?.clearHistory()
+ }
+
+ /**
+ * Clear all data associated with web views on device.
+ * Clears cookies and localstorage as well if [forAllWebViews] is true
+ */
+ public open fun clearAllData(forAllWebViews: Boolean = false) {
+ webView?.clearHistory()
+ webView?.clearCache(true)
+ webView?.clearFormData()
+ if (forAllWebViews) {
+ WebStorage.getInstance().deleteAllData()
+ CookieManager.getInstance().removeAllCookies(null)
+ CookieManager.getInstance().flush()
+ }
+ }
+
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean {
+ val uri = request.url ?: return false
+ return when {
+ uri.host in allowedHosts && uri.linkType == LinkType.Web -> false
+ else -> {
+ listener?.onForeignUrlEncountered(uri, uri.linkType)
+ true
+ }
+ }
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ // workaround bug when onPageFinished is triggered 3 times, last one is for 100%
+ if (view?.progress == 100) {
+ listener?.onSuccess(url?.toUri())
+ }
+ }
+
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ super.onPageStarted(view, url?.toUri()?.asHttps.toString(), favicon)
+ listener?.onStartedLoading(url?.toUri())
+ }
+
+ // OnReceivedError indicates no connection
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?
+ ) {
+ super.onReceivedError(view, request, error)
+ // only handle the main frame because we get a bunch of errors from images, ads etc.
+ if (request?.isForMainFrame == true) {
+ if (request.url.scheme == "http") {
+ // Try to redirect to https links if policy does not allow http.
+ load(request.url.asHttps)
+ } else {
+ listener?.onError(request.url)
+ }
+ }
+ }
+
+ // when we encounter a link to a file, just open it in the browser, let the system handle it
+ override fun onDownloadStart(
+ url: String?,
+ userAgent: String?,
+ contentDisposition: String?,
+ mimetype: String?,
+ contentLength: Long
+ ) {
+ val fileName: String? = URLUtil.guessFileName(url, contentDisposition, mimetype)
+ if (url?.toUri() != null && fileName != null) {
+ listener?.onRequestedFileDownload(url.toUri(), fileName, mimetype)
+ }
+ }
+}