├── .editorconfig ├── .github ├── pull_request_template.md ├── renovate.json5 └── workflows │ ├── .java-version │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── build.gradle ├── gradle.properties ├── gradle ├── libs.versions.toml ├── license-header.txt └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kotlin-js-store └── yarn.lock ├── molecule-runtime ├── api │ ├── android │ │ └── molecule-runtime.api │ ├── jvm │ │ └── molecule-runtime.api │ └── molecule-runtime.klib.api ├── build.gradle ├── gradle.properties └── src │ ├── androidInstrumentedTest │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── AndroidUiFrameClockTest.kt │ ├── androidMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ ├── AndroidUiDispatcher.kt │ │ ├── AndroidUiFrameClock.kt │ │ └── timeSource.kt │ ├── browserMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ ├── WindowAnimationFrameClock.kt │ │ ├── browser.kt │ │ └── timeSource.kt │ ├── commonMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ ├── GatedFrameClock.kt │ │ ├── RecompositionMode.kt │ │ ├── SnapshotNotifier.kt │ │ ├── molecule.kt │ │ ├── platform.kt │ │ └── timeSource.kt │ ├── commonTest │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ ├── GatedFrameClockTest.kt │ │ ├── MoleculeStateFlowTest.kt │ │ ├── MoleculeTest.kt │ │ └── RecordingExceptionHandler.kt │ ├── darwinMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── timeSource.kt │ ├── displayLinkMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── DisplayLinkClock.kt │ ├── displayLinkTest │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── DisplayLinkClockTest.kt │ ├── javaMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── platform.kt │ ├── javaTest │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── DefaultSnapshotNotifierPropertyTest.kt │ ├── jsTest │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── WindowAnimationFrameClockTest.kt │ ├── jvmMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── timeSource.kt │ ├── jvmTest │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── MoleculeConcurrentTest.kt │ ├── linuxMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── timeSource.kt │ ├── macosMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── DisplayLinkClock.kt │ ├── mingwMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── timeSource.kt │ ├── nonJavaMain │ └── kotlin │ │ └── app │ │ └── cash │ │ └── molecule │ │ └── platform.kt │ └── quartzCoreMain │ └── kotlin │ └── app │ └── cash │ └── molecule │ └── DisplayLinkClock.kt ├── sample-viewmodel ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── example │ │ └── molecule │ │ └── viewmodel │ │ ├── MainActivity.kt │ │ ├── MoleculeViewModel.kt │ │ ├── data.kt │ │ ├── presentationLogic.kt │ │ └── ui.kt │ └── test │ └── java │ └── com │ └── example │ └── molecule │ └── viewmodel │ └── PupperPicsPresenterTest.kt ├── sample ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── molecule │ │ │ ├── CounterActivity.kt │ │ │ ├── data.kt │ │ │ ├── presenter.kt │ │ │ └── view.kt │ └── res │ │ └── layout │ │ └── counter.xml │ └── test │ └── java │ └── com │ └── example │ └── molecule │ ├── CounterPresenterTest.kt │ └── LocalRandomService.kt └── settings.gradle /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{kt,kts}] 11 | ij_kotlin_allow_trailing_comma=true 12 | ij_kotlin_allow_trailing_comma_on_call_site=true 13 | ij_kotlin_imports_layout=* 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - [ ] `CHANGELOG.md`'s "Unreleased" section has been updated, if applicable. 4 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'config:recommended', 5 | ], 6 | ignorePresets: [ 7 | // Ensure we get the latest version and are not pinned to old versions. 8 | 'workarounds:javaLTSVersions', 9 | ], 10 | customManagers: [ 11 | // Update .java-version file with the latest JDK version. 12 | { 13 | customType: 'regex', 14 | fileMatch: [ 15 | '\\.java-version$', 16 | ], 17 | matchStrings: [ 18 | '(?.*)\\n', 19 | ], 20 | datasourceTemplate: 'java-version', 21 | depNameTemplate: 'java', 22 | // Only write the major version. 23 | extractVersionTemplate: '^(?\\d+)', 24 | }, 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/.java-version: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: {} 5 | workflow_dispatch: {} 6 | push: 7 | branches: 8 | - 'trunk' 9 | tags-ignore: 10 | - '**' 11 | 12 | env: 13 | GRADLE_OPTS: "-Dkotlin.incremental=false -Dorg.gradle.logging.stacktrace=full" 14 | 15 | jobs: 16 | build: 17 | runs-on: macos-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-java@v4 21 | with: 22 | distribution: 'zulu' 23 | java-version-file: .github/workflows/.java-version 24 | - uses: gradle/actions/setup-gradle@v4 25 | - run: ./gradlew build 26 | 27 | emulator: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | # https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/ 32 | - name: Enable KVM group perms 33 | run: | 34 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 35 | sudo udevadm control --reload-rules 36 | sudo udevadm trigger --name-match=kvm 37 | ls /dev/kvm 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-java@v4 40 | with: 41 | distribution: 'zulu' 42 | java-version-file: .github/workflows/.java-version 43 | - uses: gradle/actions/setup-gradle@v4 44 | 45 | - name: Run Tests 46 | uses: reactivecircus/android-emulator-runner@v2 47 | with: 48 | api-level: 24 49 | script: ./gradlew connectedCheck 50 | 51 | publish: 52 | runs-on: macos-latest 53 | if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/molecule' }} 54 | needs: 55 | - build 56 | - emulator 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: actions/setup-java@v4 60 | with: 61 | distribution: 'zulu' 62 | java-version-file: .github/workflows/.java-version 63 | - uses: gradle/actions/setup-gradle@v4 64 | 65 | - run: ./gradlew publish dokkaHtml 66 | env: 67 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME_APP_CASH }} 68 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD_APP_CASH }} 69 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} 70 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} 71 | 72 | - name: Deploy docs to website 73 | uses: JamesIves/github-pages-deploy-action@releases/v3 74 | with: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | BRANCH: site 77 | FOLDER: molecule-runtime/build/dokka/html 78 | TARGET_FOLDER: docs/latest/ 79 | CLEAN: true 80 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-java@v4 15 | with: 16 | distribution: 'zulu' 17 | java-version-file: .github/workflows/.java-version 18 | - uses: gradle/actions/setup-gradle@v4 19 | - run: ./gradlew publish dokkaHtml 20 | env: 21 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME_APP_CASH }} 22 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD_APP_CASH }} 23 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} 24 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} 25 | 26 | - name: Extract release notes 27 | id: release_notes 28 | uses: ffurrer2/extract-release-notes@v2 29 | 30 | - name: Create release 31 | uses: softprops/action-gh-release@v2 32 | with: 33 | body: ${{ steps.release_notes.outputs.release_notes }} 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Deploy docs to website 38 | uses: JamesIves/github-pages-deploy-action@releases/v3 39 | with: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | BRANCH: site 42 | FOLDER: molecule-runtime/build/dokka/html 43 | TARGET_FOLDER: docs/2.x/ 44 | CLEAN: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle 3 | build 4 | /reports 5 | 6 | # Android 7 | local.properties 8 | 9 | # Kotlin 10 | .kotlin 11 | 12 | # IntelliJ 13 | /.idea 14 | *.iml 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased] 4 | [Unreleased]: https://github.com/cashapp/molecule/compare/2.0.0...HEAD 5 | 6 | Nothing yet! 7 | 8 | 9 | ## [2.1.0] - 2025-04-11 10 | [2.1.0]: https://github.com/cashapp/molecule/releases/tag/2.1.0 11 | 12 | New: 13 | - Add `SnapshotNotifier` enum to control whether Molecule automatically sends snapshot apply notifications. 14 | If you are using Molecule with another Compose-based library in a single application, you may want to disable our snapshot notification. 15 | See the enum for details on when that is appropriate. 16 | Additionally, the `app.cash.molecule.snapshotNotifier` system property can be set to one of the enum entry names to control the default process-wide. 17 | 18 | Changed: 19 | - Any specified additional coroutine context elements will now be honored in the coroutine used internally with `RecompositionMode.Immediate` to send frames and cause recomposition to occur. This is observable, most notably, when a `CoroutineDispatcher` is included, as now recompositions which occur after the first, synchronous one will occur on that dispatcher. 20 | 21 | Fixed: 22 | - Correct calculation of frame nano time for native Windows and native Linux targets. 23 | 24 | 25 | ## [2.0.0] - 2024-05-28 26 | [2.0.0]: https://github.com/cashapp/molecule/releases/tag/2.0.0 27 | 28 | New: 29 | - Support for Kotlin 2.0.0! 30 | 31 | Changed: 32 | - Remove our Gradle plugin in favor of JetBrains' (see below for more). 33 | 34 | Fixed: 35 | - Mac OS `DisplayLinkClock` was updated to correctly use a "static" function for pointer-passing to `CVDisplayLink`, as newly-enforced by Kotlin 2.0. This should not cause a behavior change. 36 | 37 | Note: This release is otherwise binary-compatible with the 1.x versions. The major version bump is due to the build change only. 38 | 39 | 40 | ### Gradle plugin removed 41 | 42 | This version of Molecule removes the custom Gradle plugin in favor of [the official JetBrains Compose compiler plugin](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compiler.html) which ships as part of Kotlin itself. 43 | Each module in which you had previously applied the `app.cash.molecule` plugin should be changed to apply `org.jetbrains.kotlin.plugin.compose` instead. 44 | The Molecule runtime will no longer be added as a result of the plugin change, and so any module which references Molecule APIs like `launchMolecule` should apply the `app.cash.molecule:molecule-runtime` dependency. 45 | 46 | For posterity, the Kotlin version compatibility table and compiler version customization for our old Molecule Gradle plugin will be archived here: 47 | 48 |
49 | Molecule 1.x Gradle plugin Kotlin compatibility table 50 |

51 | 52 | Since Kotlin compiler plugins are an unstable API, certain versions of Molecule only work with 53 | certain versions of Kotlin. 54 | 55 | | Kotlin | Molecule | 56 | |--------|----------------| 57 | | 1.9.24 | 1.4.3 | 58 | | 1.9.23 | 1.4.2 | 59 | | 1.9.22 | 1.3.2 - 1.4.1 | 60 | | 1.9.21 | 1.3.1 | 61 | | 1.9.20 | 1.3.0 | 62 | | 1.9.10 | 1.2.1 | 63 | | 1.9.0 | 1.1.0 - 1.2.0 | 64 | | 1.8.22 | 0.11.0 - 1.0.0 | 65 | | 1.8.21 | 0.10.0 | 66 | | 1.8.20 | 0.9.0 | 67 | | 1.8.10 | 0.8.0 | 68 | | 1.8.0 | 0.7.0 - 0.7.1 | 69 | | 1.7.20 | 0.6.0 - 0.6.1 | 70 | | 1.7.10 | 0.4.0 - 0.5.0 | 71 | | 1.7.0 | 0.3.0 - 0.3.1 | 72 | | 1.6.10 | 0.2.0 | 73 | | 1.5.31 | 0.1.0 | 74 | 75 |

76 |
77 | 78 |
79 | Molecule 1.x Gradle plugin Compose compiler customization instructions 80 |

81 | 82 | Each version of Molecule ships with a specific JetBrains Compose compiler version which works with 83 | a single version of Kotlin (see version table above). Newer versions of the Compose 84 | compiler or alternate Compose compilers can be specified using the Gradle extension. 85 | 86 | To use a new version of the JetBrains Compose compiler version: 87 | ```kotlin 88 | molecule { 89 | kotlinCompilerPlugin.set("1.4.8") 90 | } 91 | ``` 92 | 93 | To use an alternate Compose compiler dependency: 94 | ```kotlin 95 | molecule { 96 | kotlinCompilerPlugin.set("com.example:custom-compose-compiler:1.0.0") 97 | } 98 | ``` 99 | 100 |

101 |
102 | 103 | 104 | ## [1.4.3] - 2024-05-15 105 | 106 | New: 107 | - Support for Kotlin 1.9.24 108 | 109 | This version works with Kotlin 1.9.24 by default. 110 | 111 | 112 | ## [1.4.2] - 2024-03-27 113 | 114 | New: 115 | - Support for Kotlin 1.9.23 116 | 117 | Changed: 118 | - Disable klib signature clash checks for JS compilations. These occasionally occur as a result of Compose compiler behavior, and are safe to disable (the first-party JetBrains Compose Gradle plugin also disables them). 119 | 120 | This version works with Kotlin 1.9.23 by default. 121 | 122 | 123 | ## [1.4.1] - 2024-02-28 124 | 125 | New: 126 | - Support for `linuxArm64` and `wasmJs` targets. 127 | 128 | 129 | ## [1.4.0] - 2024-02-27 130 | 131 | Changed: 132 | - Disable decoy generation for JS target to make compatible with JetBrains Compose 1.6. This is an ABI-breaking change, so all Compose-based libraries targeting JS will also need to have been recompiled. 133 | 134 | This version works with Kotlin 1.9.22 by default. 135 | 136 | 137 | ## [1.3.2] - 2024-01-02 138 | 139 | New: 140 | - Support for Kotlin 1.9.22 141 | 142 | This version works with Kotlin 1.9.22 by default. 143 | 144 | 145 | ## [1.3.1] - 2023-11-25 146 | 147 | New: 148 | - Support for Kotlin 1.9.21 149 | 150 | This version works with Kotlin 1.9.21 by default. 151 | 152 | 153 | ## [1.3.0] - 2023-10-31 🎃 154 | 155 | New: 156 | - Add `CoroutineContext` parameter to `launchMolecule` to contribute elements to the combined 157 | context that is used for running Compose. 158 | - Support for Kotlin 1.9.20 159 | 160 | Changed: 161 | - Removed now-unsupported `watchosX86` target. 162 | 163 | This version works with Kotlin 1.9.20 by default. 164 | 165 | 166 | ## [1.2.1] - 2023-09-14 167 | 168 | New: 169 | - Support for Kotlin 1.9.10 170 | - Switch to JetBrains Compose compiler 1.5.2 (based on AndroidX Compose compiler 1.5.3) 171 | 172 | This version works with Kotlin 1.9.10 by default. 173 | 174 | 175 | ## [1.2.0] - 2023-08-09 176 | 177 | New: 178 | - Support for specifying custom Compose compiler versions. This will allow you to use the latest 179 | version of Molecule with newer versions of Kotlin than it explicitly supports. 180 | 181 | See [the README](https://github.com/cashapp/molecule/#custom-compose-compiler) for more information. 182 | 183 | Fixed: 184 | - Ensure frame times sent by `RecompositionMode.Immediate` always increase. Previously, 185 | when targeting JS, the same frame time could be seen since the clock only has millisecond 186 | precision. Since the frame time is in nanoseconds, synthetic nanosecond offsets will be added to 187 | ensure each timestamp is strictly greater than the last. 188 | - Perform teardown of the composition on cancellation within an existing coroutine rather than in 189 | a job completion listener. This ensures it executes on the same dispatcher as the rest of the 190 | system, rather than on the canceling caller's thread. 191 | 192 | 193 | ## [1.1.0] - 2023-07-20 194 | 195 | New: 196 | - Support for Kotlin 1.9.0 197 | - Switch to JetBrains Compose compiler 1.5.0 (based on AndroidX Compose compiler 1.5.0) 198 | 199 | 200 | ## [1.0.0] - 2023-07-19 201 | 202 | Changed: 203 | - `RecompositionClock` is now named `RecompositionMode` to better reflect that it is not itself the clock, 204 | but the mode by which Molecule will perform recomposition. A clock is always used internally as that is the 205 | underlying mechanism of Compose. 206 | - Darwin frame clock and the internal frame clock used with `RecompositionMode.Immediate` now correctly 207 | send actual frame times. 208 | 209 | 210 | ## [0.11.0] - 2023-06-30 211 | 212 | New: 213 | - Support for Kotlin 1.8.22 214 | - Switch to JetBrains Compose compiler 1.4.8 (AndroidX Compose compiler 1.4.8) 215 | 216 | 217 | ## [0.10.0] - 2023-06-26 218 | 219 | New: 220 | - Support for Kotlin 1.8.21 221 | - Update to JetBrains Compose runtime 1.4.1 (AndroidX Compose runtime 1.4.3). 222 | - Switch to JetBrains Compose compiler 1.4.7 (AndroidX Compose compiler 1.4.7) 223 | 224 | 225 | ## [0.9.0] - 2023-04-12 226 | 227 | New: 228 | - Support for Kotlin 1.8.20 229 | - Update to JetBrains Compose runtime 1.4.0 (AndroidX Compose runtime 1.4.0). 230 | - Switch to JetBrains Compose compiler 1.4.5 (as yet unreleased AndroidX Compose compiler, probably 1.4.5) 231 | 232 | 233 | ## [0.8.0] - 2023-03-09 234 | 235 | New: 236 | - Support for Kotlin 1.8.10 237 | - Update to JetBrains Compose runtime 1.3.1 (AndroidX Compose runtime 1.2.1). 238 | - Switch to JetBrains Compose compiler 1.3.2.1 (AndroidX Compose compiler 1.3.2 + JS fix) 239 | 240 | 241 | ## [0.7.1] - 2023-02-20 242 | 243 | New: 244 | - Add `WindowAnimationFrameClock` for use in browser-based JS environments. 245 | 246 | Changed: 247 | - Switch to JetBrains Compose compiler which has better support for JS and native targets. 248 | 249 | 250 | ## [0.7.0] - 2023-01-17 251 | 252 | New: 253 | - Support for Kotlin 1.8.0 254 | - Switch (back) to AndroidX Compose compiler 1.4.0 255 | 256 | 257 | ## [0.6.1] - 2022-11-16 258 | 259 | New: 260 | - Add support for `watchosArm32` and `watchosX86` native targets. 261 | 262 | 263 | ## [0.6.0] - 2022-11-08 264 | 265 | New: 266 | - Support for Kotlin 1.7.20 267 | - Update to JetBrains Compose runtime 1.2.1 (AndroidX Compose runtime 1.2.1). 268 | - Switch to JetBrains Compose compiler 1.3.2.1 (AndroidX Compose compiler 1.3.2 + JS fix) 269 | 270 | Fixed: 271 | - When applying the Compose compiler plugin to Kotlin/JS targets, ensure decoys are used. 272 | - Add `cacheKind=none` Gradle configuration which ensures downstream Kotlin/Native projects can link. 273 | 274 | 275 | ## [0.5.0] - 2022-10-13 276 | 277 | New: 278 | 279 | - Update to JetBrains Compose runtime 1.2.0 (this uses AndroidX Compose runtime 1.2.1). 280 | - Add iOS, MacOS, tvOS, watchOS, linux, and windows targets for Kotlin/Native. 281 | 282 | Changed: 283 | 284 | - The 'molecule-testing' artifact has been removed. 285 | 286 | 287 | ## [0.5.0-beta01] - 2022-09-16 288 | 289 | New: 290 | 291 | - Update to JetBrains Compose runtime 1.2.0-beta01 (this uses AndroidX Compose runtime 1.2.1). 292 | - Add iOS, MacOS, tvOS, watchOS, linux, and windows targets for Kotlin/Native. 293 | 294 | Changed: 295 | 296 | - The 'molecule-testing' artifact has been removed. 297 | 298 | 299 | ## [0.4.0] - 2022-08-10 300 | 301 | New: 302 | 303 | - Update to Compose compiler 1.3.0 which supports Kotlin 1.7.10. 304 | 305 | Fixed: 306 | 307 | - Prevent "Trying to call 'getOrThrow' on a failed channel result: Failed" exceptions when using the immediate recompose clock. 308 | 309 | 310 | ## [0.4.0-beta01] - 2022-07-27 311 | 312 | New: 313 | 314 | - Update to Compose compiler 1.3.0-beta01 which supports Kotlin 1.7.10. 315 | 316 | 317 | ## [0.3.1] - 2022-08-10 318 | 319 | Fixed: 320 | 321 | - Prevent "Trying to call 'getOrThrow' on a failed channel result: Failed" exceptions when using the immediate recompose clock. 322 | 323 | 324 | ## [0.3.0] - 2022-07-27 325 | 326 | New: 327 | 328 | - Enable Kotlin multiplatform usage on JVM and JS targets (in addition to Android). All native targets are blocked on JetBrains' Compose runtime supporting them (with a stable release). 329 | - Update to Compose compiler 1.2.0 which supports Kotlin 1.7.0. 330 | - Add `RecomposeClock` parameter to both `moleculeFlow` and `launchMolecule` which allows choosing between a frame-based clock for recomposition or a clock which immediately recomposes for any change. 331 | - The 'molecule-testing' library is deprecated. The recommendation is to use the new immediate clock mode and [Turbine](https://github.com/cashapp/turbine/). If you have a use case which cannot be handled by this change please comment on [this issue](https://github.com/cashapp/molecule/issues/97). 332 | 333 | 334 | ## [0.2.0] - 2022-02-09 335 | 336 | New: 337 | 338 | - Update to Compose 1.1.0 which supports Kotlin 1.6.10 339 | 340 | Fixed: 341 | 342 | - Explicitly dispose internal `Composition` allowing `DisposableEffect`s to fire. 343 | 344 | 345 | ## [0.1.0] - 2021-11-10 346 | 347 | Initial release 348 | 349 | 350 | 351 | [1.4.3]: https://github.com/cashapp/molecule/releases/tag/1.4.3 352 | [1.4.2]: https://github.com/cashapp/molecule/releases/tag/1.4.2 353 | [1.4.1]: https://github.com/cashapp/molecule/releases/tag/1.4.1 354 | [1.4.0]: https://github.com/cashapp/molecule/releases/tag/1.4.0 355 | [1.3.2]: https://github.com/cashapp/molecule/releases/tag/1.3.2 356 | [1.3.1]: https://github.com/cashapp/molecule/releases/tag/1.3.1 357 | [1.3.0]: https://github.com/cashapp/molecule/releases/tag/1.3.0 358 | [1.2.1]: https://github.com/cashapp/molecule/releases/tag/1.2.1 359 | [1.2.0]: https://github.com/cashapp/molecule/releases/tag/1.2.0 360 | [1.1.0]: https://github.com/cashapp/molecule/releases/tag/1.1.0 361 | [1.0.0]: https://github.com/cashapp/molecule/releases/tag/1.0.0 362 | [0.11.0]: https://github.com/cashapp/molecule/releases/tag/0.11.0 363 | [0.10.0]: https://github.com/cashapp/molecule/releases/tag/0.10.0 364 | [0.9.0]: https://github.com/cashapp/molecule/releases/tag/0.9.0 365 | [0.8.0]: https://github.com/cashapp/molecule/releases/tag/0.8.0 366 | [0.7.1]: https://github.com/cashapp/molecule/releases/tag/0.7.1 367 | [0.7.0]: https://github.com/cashapp/molecule/releases/tag/0.7.0 368 | [0.6.1]: https://github.com/cashapp/molecule/releases/tag/0.6.1 369 | [0.6.0]: https://github.com/cashapp/molecule/releases/tag/0.6.0 370 | [0.5.0]: https://github.com/cashapp/molecule/releases/tag/0.5.0 371 | [0.5.0-beta01]: https://github.com/cashapp/molecule/releases/tag/0.5.0-beta01 372 | [0.4.0]: https://github.com/cashapp/molecule/releases/tag/0.4.0 373 | [0.4.0-beta01]: https://github.com/cashapp/molecule/releases/tag/0.4.0-beta01 374 | [0.3.1]: https://github.com/cashapp/molecule/releases/tag/0.3.1 375 | [0.3.0]: https://github.com/cashapp/molecule/releases/tag/0.3.0 376 | [0.2.0]: https://github.com/cashapp/molecule/releases/tag/0.2.0 377 | [0.1.0]: https://github.com/cashapp/molecule/releases/tag/0.1.0 378 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Molecule 2 | 3 | Build a `StateFlow` or `Flow` stream using Jetpack Compose[^1]. 4 | 5 | ```kotlin 6 | fun CoroutineScope.launchCounter(): StateFlow = launchMolecule(mode = ContextClock) { 7 | var count by remember { mutableStateOf(0) } 8 | 9 | LaunchedEffect(Unit) { 10 | while (true) { 11 | delay(1_000) 12 | count++ 13 | } 14 | } 15 | 16 | count 17 | } 18 | ``` 19 | 20 | [^1]: …and NOT Jetpack Compose UI! 21 | 22 | 23 | ## Introduction 24 | 25 | Jetpack Compose UI makes it easy to build declarative UI with logic. 26 | 27 | ```kotlin 28 | val userFlow = db.userObservable() 29 | val balanceFlow = db.balanceObservable() 30 | 31 | @Composable 32 | fun Profile() { 33 | val user by userFlow.subscribeAsState(null) 34 | val balance by balanceFlow.subscribeAsState(0L) 35 | 36 | if (user == null) { 37 | Text("Loading…") 38 | } else { 39 | Text("${user.name} - $balance") 40 | } 41 | } 42 | ``` 43 | 44 | Unfortunately, we are mixing business logic with display logic which makes testing harder than if it were separated. 45 | The display layer is also interacting directly with the storage layer which creates undesirable coupling. 46 | Additionally, if we want to power a different display with the same logic (potentially on another platform) we cannot. 47 | 48 | Extracting the business logic to a presenter-like object fixes these three things. 49 | 50 | In Cash App our presenter objects traditionally expose a single stream of display models through Kotlin coroutine's `Flow` or RxJava `Observable`. 51 | 52 | ```kotlin 53 | sealed interface ProfileModel { 54 | object Loading : ProfileModel 55 | data class Data( 56 | val name: String, 57 | val balance: Long, 58 | ) : ProfileModel 59 | } 60 | 61 | class ProfilePresenter( 62 | private val db: Db, 63 | ) { 64 | fun transform(): Flow { 65 | return combine( 66 | db.users().onStart { emit(null) }, 67 | db.balances().onStart { emit(0L) }, 68 | ) { user, balance -> 69 | if (user == null) { 70 | Loading 71 | } else { 72 | Data(user.name, balance) 73 | } 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | This code is okay, but the ceremony of combining reactive streams will scale non-linearly. 80 | This means the more sources of data which are used and the more complex the logic the harder to understand the reactive code becomes. 81 | 82 | Despite emitting the `Loading` state synchronously, Compose UI [requires an initial value](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.Flow).collectAsState(kotlin.Any,kotlin.coroutines.CoroutineContext)) be specified for all `Flow` or `Observable` usage. 83 | This is a layering violation as the view layer is not in the position to dictate a reasonable default since the presenter layer controls the model object. 84 | 85 | Molecule lets us fix both of these problems. 86 | Our presenter can return a `StateFlow` whose initial state can be read synchronously at the view layer by Compose UI. 87 | And by using Compose we also can build our model objects using imperative code built on features of the Kotlin language rather than reactive code consisting of RxJava library APIs. 88 | 89 | ```kotlin 90 | @Composable 91 | fun ProfilePresenter( 92 | userFlow: Flow, 93 | balanceFlow: Flow, 94 | ): ProfileModel { 95 | val user by userFlow.collectAsState(null) 96 | val balance by balanceFlow.collectAsState(0L) 97 | 98 | return if (user == null) { 99 | Loading 100 | } else { 101 | Data(user.name, balance) 102 | } 103 | } 104 | ``` 105 | 106 | This model-producing composable function can be run with `launchMolecule`. 107 | 108 | ```kotlin 109 | val userFlow = db.users() 110 | val balanceFlow = db.balances() 111 | val models: StateFlow = scope.launchMolecule(mode = ContextClock) { 112 | ProfilePresenter(userFlow, balanceFlow) 113 | } 114 | ``` 115 | 116 | A coroutine that runs `ProfilePresenter` and shares its output with the `StateFlow` is launched into the provided `CoroutineScope`. 117 | 118 | At the view-layer, consuming the `StateFlow` of our model objects becomes trivial. 119 | 120 | ```kotlin 121 | @Composable 122 | fun Profile(models: StateFlow) { 123 | val model by models.collectAsState() 124 | when (model) { 125 | is Loading -> Text("Loading…") 126 | is Data -> Text("${model.name} - ${model.balance}") 127 | } 128 | } 129 | ``` 130 | 131 | For more information see [the `launchMolecule` documentation](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/launch-molecule.html). 132 | 133 | ### Flow 134 | 135 | In addition to `StateFlow`s, Molecule can create regular `Flow`s. 136 | 137 | Here is the presenter example updated to use a regular `Flow`: 138 | ```kotlin 139 | val userFlow = db.users() 140 | val balanceFlow = db.balances() 141 | val models: Flow = moleculeFlow(mode = Immediate) { 142 | ProfilePresenter(userFlow, balanceFlow) 143 | } 144 | ``` 145 | 146 | And the counter example: 147 | ```kotlin 148 | fun counter(): Flow = moleculeFlow(mode = Immediate) { 149 | var count by remember { mutableStateOf(0) } 150 | 151 | LaunchedEffect(Unit) { 152 | while (true) { 153 | delay(1_000) 154 | count++ 155 | } 156 | } 157 | 158 | count 159 | } 160 | ``` 161 | 162 | For more information see [the `moleculeFlow` documentation](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/molecule-flow.html). 163 | 164 | ## Usage 165 | 166 | Molecule is a library for Compose, and it relies on JetBrains' Kotlin Compose plugin to be present for use. 167 | Any module which wants to call `launchMolecule` or define `@Composable` functions for use with Molecule must have this plugin applied. 168 | For more information, see [the JetBrains Compose compiler documentation](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compiler.html). 169 | 170 | Molecule itself can then be added like any other dependency: 171 | 172 | ```groovy 173 | dependencies { 174 | implementation("app.cash.molecule:molecule-runtime:2.1.0") 175 | } 176 | ``` 177 | 178 |
179 | Snapshots of the development version are available in Sonatype's snapshots repository. 180 |

181 | 182 | ```groovy 183 | repositories { 184 | mavenCentral() 185 | maven { 186 | url "https://oss.sonatype.org/content/repositories/snapshots/" 187 | } 188 | } 189 | 190 | dependencies { 191 | implementation("app.cash.molecule:molecule-runtime:2.2.0-SNAPSHOT") 192 | } 193 | ``` 194 | 195 |

196 |
197 | 198 | ### Frame Clock 199 | 200 | Whenever Jetpack Compose recomposes, it always waits for the next frame before beginning its work. 201 | It is dependent on a `MonotonicFrameClock` in its `CoroutineContext` to know when a new frame is sent. 202 | Molecule is just Jetpack Compose under the hood, so it also requires a frame clock: values won't be produced until a frame is sent and recomposition occurs. 203 | 204 | Unlike Jetpack Compose, however, Molecule will sometimes be run in circumstances that do not provide a `MonotonicFrameClock`. 205 | So all Molecule APIs require you to specify your preferred clock behavior: 206 | 207 | * `RecompositionMode.ContextClock` behaves like Jetpack Compose: it will fish the `MonotonicFrameClock` out of the calling `coroutineContext` and use it for recomposition. 208 | If there is no `MonotonicFrameClock`, it will throw an exception. 209 | `ContextClock` is useful with Android's [`AndroidUiDispatcher.Main`](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/-android-ui-dispatcher/-companion/-main.html). 210 | `Main` has a built-in `MonotonicFrameClock` that is synchronized with the frame rate of the device. 211 | So a Molecule run on `Main` with `ContextClock` will run in lock step with the frame rate, too. 212 | Nifty! 213 | You can also provide your own `BroadcastFrameClock` to implement your own frame rate. 214 | * `RecompositionMode.Immediate` will construct an immediate clock. 215 | This clock will produce a frame whenever the enclosing flow is ready to emit an item. 216 | (This is always the case for a `StateFlow`.) 217 | `Immediate` can be used where no clock is available at all without any additional wiring. 218 | It may be used for unit testing, or for running molecules off the main thread. 219 | 220 | ### Testing 221 | 222 | Use `moleculeFlow(mode = Immediate)` and test using [Turbine](https://github.com/cashapp/turbine/). Your `moleculeFlow` will run just like any other flow does in Turbine. 223 | 224 | ```kotlin 225 | @Test fun counter() = runTest { 226 | moleculeFlow(RecompositionMode.Immediate) { 227 | Counter() 228 | }.test { 229 | assertEquals(0, awaitItem()) 230 | assertEquals(1, awaitItem()) 231 | assertEquals(2, awaitItem()) 232 | cancel() 233 | } 234 | } 235 | ``` 236 | 237 | 238 | If you're unit testing Molecule on the JVM in an Android module, please set below in your project's AGP config. 239 | 240 | ```gradle 241 | android { 242 | ... 243 | testOptions { 244 | unitTests.returnDefaultValues = true 245 | } 246 | ... 247 | } 248 | ``` 249 | 250 | 251 | ## License 252 | 253 | Copyright 2021 Square, Inc. 254 | 255 | Licensed under the Apache License, Version 2.0 (the "License"); 256 | you may not use this file except in compliance with the License. 257 | You may obtain a copy of the License at 258 | 259 | http://www.apache.org/licenses/LICENSE-2.0 260 | 261 | Unless required by applicable law or agreed to in writing, software 262 | distributed under the License is distributed on an "AS IS" BASIS, 263 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 264 | See the License for the specific language governing permissions and 265 | limitations under the License. 266 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Update the `VERSION_NAME` in `gradle.properties` to the release version. 4 | 5 | 2. Update the `CHANGELOG.md`: 6 | 1. Change the `Unreleased` header to the release version. 7 | 2. Add a link URL to ensure the header link works. 8 | 3. Add a new `Unreleased` section to the top. 9 | 10 | 3. Commit 11 | 12 | ``` 13 | $ git commit -am "Prepare version X.Y.Z" 14 | ``` 15 | 16 | 4. Tag 17 | 18 | ``` 19 | $ git tag -am "Version X.Y.Z" X.Y.Z 20 | ``` 21 | 22 | 5. Update the `VERSION_NAME` in `gradle.properties` to the next "SNAPSHOT" version. 23 | 24 | 6. Commit 25 | 26 | ``` 27 | $ git commit -am "Prepare next development version" 28 | ``` 29 | 30 | 7. Push! 31 | 32 | ``` 33 | $ git push && git push --tags 34 | ``` 35 | 36 | This will trigger a GitHub Action workflow which will create a GitHub release and upload the 37 | release artifacts to Maven Central. 38 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | dependencies { 3 | classpath libs.android.plugin 4 | classpath libs.kotlin.plugin.core 5 | classpath libs.kotlin.plugin.compose 6 | classpath libs.kotlin.plugin.serialization 7 | classpath libs.maven.publish.plugin 8 | classpath libs.dokka.plugin 9 | classpath libs.spotless.plugin 10 | classpath libs.kotlinx.binaryCompatibilityValidator 11 | } 12 | 13 | repositories { 14 | mavenCentral() 15 | google() 16 | gradlePluginPortal() 17 | } 18 | } 19 | 20 | allprojects { 21 | version = property("VERSION_NAME") as String 22 | 23 | repositories { 24 | mavenCentral() 25 | google() 26 | } 27 | } 28 | 29 | subprojects { 30 | tasks.withType(Test).configureEach { 31 | testLogging { 32 | if (System.getenv("CI") == "true") { 33 | events = ["failed", "skipped", "passed"] 34 | } 35 | exceptionFormat "full" 36 | } 37 | } 38 | 39 | tasks.withType(JavaCompile).configureEach { task -> 40 | task.sourceCompatibility = '11' 41 | task.targetCompatibility = '11' 42 | } 43 | 44 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile).configureEach { task -> 45 | task.kotlinOptions { 46 | jvmTarget = '11' 47 | } 48 | } 49 | 50 | plugins.withType(com.android.build.gradle.BasePlugin).configureEach { 51 | def android = extensions.getByName("android") as com.android.build.gradle.BaseExtension 52 | android.compileSdkVersion libs.versions.compileSdk.get().toInteger() 53 | android.defaultConfig { 54 | minSdkVersion libs.versions.minSdk.get().toInteger() 55 | targetSdkVersion libs.versions.compileSdk.get().toInteger() 56 | } 57 | android.compileOptions { 58 | sourceCompatibility JavaVersion.VERSION_11 59 | targetCompatibility JavaVersion.VERSION_11 60 | } 61 | android.lintOptions { 62 | checkDependencies true 63 | checkReleaseBuilds false // Full lint runs as part of 'build' task. 64 | } 65 | } 66 | 67 | apply plugin: 'com.diffplug.spotless' 68 | spotless { 69 | kotlin { 70 | target("src/**/*.kt") 71 | ktlint(libs.ktlint.core.get().version) 72 | .editorConfigOverride([ 73 | 'ktlint_standard_filename': 'disabled', 74 | // Making something an expression body should be a choice around readability. 75 | 'ktlint_standard_function-expression-body': 'disabled', 76 | 'ktlint_function_naming_ignore_when_annotated_with': 'Composable', 77 | ]) 78 | .customRuleSets([ 79 | libs.ktlint.composeRules.get().toString(), 80 | ]) 81 | licenseHeaderFile(rootProject.file('gradle/license-header.txt')) 82 | } 83 | } 84 | 85 | plugins.withId('maven-publish') { 86 | publishing { 87 | repositories { 88 | maven { 89 | name = "installLocally" 90 | url = "${rootProject.buildDir}/localMaven" 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | GROUP=app.cash.molecule 2 | 3 | # HEY! If you change the major version here be sure to update release.yaml doc target folder! 4 | VERSION_NAME=2.2.0-SNAPSHOT 5 | 6 | SONATYPE_AUTOMATIC_RELEASE=true 7 | SONATYPE_HOST=DEFAULT 8 | RELEASE_SIGNING_ENABLED=true 9 | 10 | POM_DESCRIPTION=Build a Flow or Observable stream using Jetpack Compose. 11 | 12 | POM_URL=https://github.com/cashapp/molecule/ 13 | POM_SCM_URL=https://github.com/cashapp/molecule/ 14 | POM_SCM_CONNECTION=scm:git:git://github.com/cashapp/molecule.git 15 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/cashapp/molecule.git 16 | 17 | POM_LICENCE_NAME=Apache-2.0 18 | POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0 19 | POM_LICENCE_DIST=repo 20 | 21 | POM_DEVELOPER_ID=cashapp 22 | POM_DEVELOPER_NAME=CashApp 23 | POM_DEVELOPER_URL=https://github.com/cashapp 24 | 25 | org.gradle.jvmargs=-Xmx4096m 26 | 27 | android.useAndroidX=true 28 | android.enableJetifier=false 29 | android.defaults.buildfeatures.buildconfig=false 30 | android.defaults.buildfeatures.aidl=false 31 | android.defaults.buildfeatures.renderscript=false 32 | android.defaults.buildfeatures.resvalues=false 33 | android.defaults.buildfeatures.shaders=false 34 | 35 | kotlin.mpp.stability.nowarn=true 36 | 37 | # This is needed for the JB Compose runtime to link on native targets. They also use this flag 38 | # in their samples. Over time it should be removed once they figure out why it was needed. 39 | kotlin.native.cacheKind=none 40 | 41 | systemProp.org.gradle.internal.http.socketTimeout=120000 42 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | compileSdk = "35" 3 | minSdk = "21" 4 | 5 | coroutine = "1.10.2" 6 | kotlin = "2.1.21" 7 | jetbrains-compose = "1.8.1" 8 | serialization = "1.8.1" 9 | squareup-okhttp = "4.12.0" 10 | squareup-retrofit = "3.0.0" 11 | 12 | [libraries] 13 | android-plugin = { module = "com.android.tools.build:gradle", version = "8.10.1" } 14 | androidx-core = { module = "androidx.core:core-ktx", version = "1.16.0" } 15 | androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" } 16 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.1" } 17 | 18 | androidx-compose-material3 = "androidx.compose.material3:material3:1.3.2" 19 | 20 | coil-compose = "io.coil-kt:coil-compose:2.7.0" 21 | 22 | jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrains-compose" } 23 | 24 | dokka-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "2.0.0" } 25 | 26 | junit = { module = "junit:junit", version = "4.13.2" } 27 | 28 | kotlin-plugin-core = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 29 | kotlin-plugin-compose = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } 30 | kotlin-plugin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } 31 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 32 | 33 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutine" } 34 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutine" } 35 | kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } 36 | 37 | kotlinx-binaryCompatibilityValidator = "org.jetbrains.kotlinx:binary-compatibility-validator:0.17.0" 38 | 39 | ktlint-core = "com.pinterest.ktlint:ktlint-cli:1.6.0" 40 | ktlint-composeRules = "io.nlopez.compose.rules:ktlint:0.4.22" 41 | 42 | maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.32.0" } 43 | 44 | spotless-plugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version = "7.0.4" } 45 | 46 | squareup-okhttp-client = { module = "com.squareup.okhttp3:okhttp", version.ref = "squareup-okhttp" } 47 | squareup-okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "squareup-okhttp" } 48 | squareup-retrofit-client = { module = "com.squareup.retrofit2:retrofit", version.ref = "squareup-retrofit" } 49 | squareup-retrofit-converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "squareup-retrofit" } 50 | squareup-retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "squareup-retrofit" } 51 | 52 | assertk = "com.willowtreeapps.assertk:assertk:0.28.1" 53 | turbine = { module = "app.cash.turbine:turbine", version = "1.2.0" } 54 | -------------------------------------------------------------------------------- /gradle/license-header.txt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) $YEAR Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/molecule/2f144abb06ba97b906411d703942ea97661d69ce/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /molecule-runtime/api/android/molecule-runtime.api: -------------------------------------------------------------------------------- 1 | public final class app/cash/molecule/AndroidUiDispatcher : kotlinx/coroutines/CoroutineDispatcher { 2 | public static final field $stable I 3 | public static final field Companion Lapp/cash/molecule/AndroidUiDispatcher$Companion; 4 | public fun dispatch (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V 5 | public final fun getChoreographer ()Landroid/view/Choreographer; 6 | public final fun getFrameClock ()Landroidx/compose/runtime/MonotonicFrameClock; 7 | } 8 | 9 | public final class app/cash/molecule/AndroidUiDispatcher$Companion { 10 | public final fun getMain ()Lkotlin/coroutines/CoroutineContext; 11 | } 12 | 13 | public final class app/cash/molecule/AndroidUiFrameClock : androidx/compose/runtime/MonotonicFrameClock { 14 | public static final field $stable I 15 | public fun (Landroid/view/Choreographer;)V 16 | public fun fold (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object; 17 | public fun get (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element; 18 | public fun minusKey (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext; 19 | public fun plus (Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; 20 | public fun withFrameNanos (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 21 | } 22 | 23 | public final class app/cash/molecule/MoleculeKt { 24 | public static final fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; 25 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; 26 | public static final fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;)V 27 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V 28 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V 29 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; 30 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; 31 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; 32 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V 33 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V 34 | public static final fun moleculeFlow (Lapp/cash/molecule/RecompositionMode;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; 35 | public static final synthetic fun moleculeFlow (Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; 36 | public static synthetic fun moleculeFlow$default (Lapp/cash/molecule/RecompositionMode;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; 37 | } 38 | 39 | public final class app/cash/molecule/RecompositionMode : java/lang/Enum { 40 | public static final field ContextClock Lapp/cash/molecule/RecompositionMode; 41 | public static final field Immediate Lapp/cash/molecule/RecompositionMode; 42 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 43 | public static fun valueOf (Ljava/lang/String;)Lapp/cash/molecule/RecompositionMode; 44 | public static fun values ()[Lapp/cash/molecule/RecompositionMode; 45 | } 46 | 47 | public final class app/cash/molecule/SnapshotNotifier : java/lang/Enum { 48 | public static final field External Lapp/cash/molecule/SnapshotNotifier; 49 | public static final field WhileActive Lapp/cash/molecule/SnapshotNotifier; 50 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 51 | public static fun valueOf (Ljava/lang/String;)Lapp/cash/molecule/SnapshotNotifier; 52 | public static fun values ()[Lapp/cash/molecule/SnapshotNotifier; 53 | } 54 | 55 | -------------------------------------------------------------------------------- /molecule-runtime/api/jvm/molecule-runtime.api: -------------------------------------------------------------------------------- 1 | public final class app/cash/molecule/MoleculeKt { 2 | public static final fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; 3 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; 4 | public static final fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;)V 5 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V 6 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V 7 | public static final synthetic fun launchMolecule (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; 8 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; 9 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; 10 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V 11 | public static synthetic fun launchMolecule$default (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V 12 | public static final fun moleculeFlow (Lapp/cash/molecule/RecompositionMode;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; 13 | public static final synthetic fun moleculeFlow (Lapp/cash/molecule/RecompositionMode;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; 14 | public static synthetic fun moleculeFlow$default (Lapp/cash/molecule/RecompositionMode;Lapp/cash/molecule/SnapshotNotifier;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; 15 | } 16 | 17 | public final class app/cash/molecule/RecompositionMode : java/lang/Enum { 18 | public static final field ContextClock Lapp/cash/molecule/RecompositionMode; 19 | public static final field Immediate Lapp/cash/molecule/RecompositionMode; 20 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 21 | public static fun valueOf (Ljava/lang/String;)Lapp/cash/molecule/RecompositionMode; 22 | public static fun values ()[Lapp/cash/molecule/RecompositionMode; 23 | } 24 | 25 | public final class app/cash/molecule/SnapshotNotifier : java/lang/Enum { 26 | public static final field External Lapp/cash/molecule/SnapshotNotifier; 27 | public static final field WhileActive Lapp/cash/molecule/SnapshotNotifier; 28 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 29 | public static fun valueOf (Ljava/lang/String;)Lapp/cash/molecule/SnapshotNotifier; 30 | public static fun values ()[Lapp/cash/molecule/SnapshotNotifier; 31 | } 32 | 33 | -------------------------------------------------------------------------------- /molecule-runtime/api/molecule-runtime.klib.api: -------------------------------------------------------------------------------- 1 | // Klib ABI Dump 2 | // Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, wasmJs, watchosArm32, watchosArm64, watchosSimulatorArm64, watchosX64] 3 | // Alias: ios => [iosArm64, iosSimulatorArm64, iosX64] 4 | // Alias: macos => [macosArm64, macosX64] 5 | // Alias: tvos => [tvosArm64, tvosSimulatorArm64, tvosX64] 6 | // Rendering settings: 7 | // - Signature version: 2 8 | // - Show manifest properties: true 9 | // - Show declarations: true 10 | 11 | // Library unique name: 12 | final enum class app.cash.molecule/RecompositionMode : kotlin/Enum { // app.cash.molecule/RecompositionMode|null[0] 13 | enum entry ContextClock // app.cash.molecule/RecompositionMode.ContextClock|null[0] 14 | enum entry Immediate // app.cash.molecule/RecompositionMode.Immediate|null[0] 15 | 16 | final val entries // app.cash.molecule/RecompositionMode.entries|#static{}entries[0] 17 | final fun (): kotlin.enums/EnumEntries // app.cash.molecule/RecompositionMode.entries.|#static(){}[0] 18 | 19 | final fun valueOf(kotlin/String): app.cash.molecule/RecompositionMode // app.cash.molecule/RecompositionMode.valueOf|valueOf#static(kotlin.String){}[0] 20 | final fun values(): kotlin/Array // app.cash.molecule/RecompositionMode.values|values#static(){}[0] 21 | } 22 | 23 | final enum class app.cash.molecule/SnapshotNotifier : kotlin/Enum { // app.cash.molecule/SnapshotNotifier|null[0] 24 | enum entry External // app.cash.molecule/SnapshotNotifier.External|null[0] 25 | enum entry WhileActive // app.cash.molecule/SnapshotNotifier.WhileActive|null[0] 26 | 27 | final val entries // app.cash.molecule/SnapshotNotifier.entries|#static{}entries[0] 28 | final fun (): kotlin.enums/EnumEntries // app.cash.molecule/SnapshotNotifier.entries.|#static(){}[0] 29 | 30 | final fun valueOf(kotlin/String): app.cash.molecule/SnapshotNotifier // app.cash.molecule/SnapshotNotifier.valueOf|valueOf#static(kotlin.String){}[0] 31 | final fun values(): kotlin/Array // app.cash.molecule/SnapshotNotifier.values|values#static(){}[0] 32 | } 33 | 34 | final val app.cash.molecule/app_cash_molecule_GatedFrameClock$stableprop // app.cash.molecule/app_cash_molecule_GatedFrameClock$stableprop|#static{}app_cash_molecule_GatedFrameClock$stableprop[0] 35 | 36 | final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).app.cash.molecule/launchMolecule(app.cash.molecule/RecompositionMode, kotlin.coroutines/CoroutineContext = ..., app.cash.molecule/SnapshotNotifier = ..., kotlin/Function2): kotlinx.coroutines.flow/StateFlow<#A> // app.cash.molecule/launchMolecule|launchMolecule@kotlinx.coroutines.CoroutineScope(app.cash.molecule.RecompositionMode;kotlin.coroutines.CoroutineContext;app.cash.molecule.SnapshotNotifier;kotlin.Function2){0§}[0] 37 | final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).app.cash.molecule/launchMolecule(app.cash.molecule/RecompositionMode, kotlin.coroutines/CoroutineContext = ..., kotlin/Function2): kotlinx.coroutines.flow/StateFlow<#A> // app.cash.molecule/launchMolecule|launchMolecule@kotlinx.coroutines.CoroutineScope(app.cash.molecule.RecompositionMode;kotlin.coroutines.CoroutineContext;kotlin.Function2){0§}[0] 38 | final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).app.cash.molecule/launchMolecule(app.cash.molecule/RecompositionMode, kotlin/Function1<#A, kotlin/Unit>, kotlin.coroutines/CoroutineContext = ..., app.cash.molecule/SnapshotNotifier = ..., kotlin/Function2) // app.cash.molecule/launchMolecule|launchMolecule@kotlinx.coroutines.CoroutineScope(app.cash.molecule.RecompositionMode;kotlin.Function1<0:0,kotlin.Unit>;kotlin.coroutines.CoroutineContext;app.cash.molecule.SnapshotNotifier;kotlin.Function2){0§}[0] 39 | final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).app.cash.molecule/launchMolecule(app.cash.molecule/RecompositionMode, kotlin/Function1<#A, kotlin/Unit>, kotlin.coroutines/CoroutineContext = ..., kotlin/Function2) // app.cash.molecule/launchMolecule|launchMolecule@kotlinx.coroutines.CoroutineScope(app.cash.molecule.RecompositionMode;kotlin.Function1<0:0,kotlin.Unit>;kotlin.coroutines.CoroutineContext;kotlin.Function2){0§}[0] 40 | final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).app.cash.molecule/launchMolecule(app.cash.molecule/RecompositionMode, kotlin/Function1<#A, kotlin/Unit>, kotlin/Function2) // app.cash.molecule/launchMolecule|launchMolecule@kotlinx.coroutines.CoroutineScope(app.cash.molecule.RecompositionMode;kotlin.Function1<0:0,kotlin.Unit>;kotlin.Function2){0§}[0] 41 | final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).app.cash.molecule/launchMolecule(app.cash.molecule/RecompositionMode, kotlin/Function2): kotlinx.coroutines.flow/StateFlow<#A> // app.cash.molecule/launchMolecule|launchMolecule@kotlinx.coroutines.CoroutineScope(app.cash.molecule.RecompositionMode;kotlin.Function2){0§}[0] 42 | final fun <#A: kotlin/Any?> app.cash.molecule/moleculeFlow(app.cash.molecule/RecompositionMode, app.cash.molecule/SnapshotNotifier = ..., kotlin/Function2): kotlinx.coroutines.flow/Flow<#A> // app.cash.molecule/moleculeFlow|moleculeFlow(app.cash.molecule.RecompositionMode;app.cash.molecule.SnapshotNotifier;kotlin.Function2){0§}[0] 43 | final fun <#A: kotlin/Any?> app.cash.molecule/moleculeFlow(app.cash.molecule/RecompositionMode, kotlin/Function2): kotlinx.coroutines.flow/Flow<#A> // app.cash.molecule/moleculeFlow|moleculeFlow(app.cash.molecule.RecompositionMode;kotlin.Function2){0§}[0] 44 | final fun app.cash.molecule/app_cash_molecule_GatedFrameClock$stableprop_getter(): kotlin/Int // app.cash.molecule/app_cash_molecule_GatedFrameClock$stableprop_getter|app_cash_molecule_GatedFrameClock$stableprop_getter(){}[0] 45 | 46 | // Targets: [ios, macos, tvos] 47 | final object app.cash.molecule/DisplayLinkClock : androidx.compose.runtime/MonotonicFrameClock { // app.cash.molecule/DisplayLinkClock|null[0] 48 | final suspend fun <#A1: kotlin/Any?> withFrameNanos(kotlin/Function1): #A1 // app.cash.molecule/DisplayLinkClock.withFrameNanos|withFrameNanos(kotlin.Function1){0§}[0] 49 | } 50 | 51 | // Targets: [ios, macos, tvos] 52 | final val app.cash.molecule/app_cash_molecule_DisplayLinkClock$stableprop // app.cash.molecule/app_cash_molecule_DisplayLinkClock$stableprop|#static{}app_cash_molecule_DisplayLinkClock$stableprop[0] 53 | 54 | // Targets: [ios, macos, tvos] 55 | final fun app.cash.molecule/app_cash_molecule_DisplayLinkClock$stableprop_getter(): kotlin/Int // app.cash.molecule/app_cash_molecule_DisplayLinkClock$stableprop_getter|app_cash_molecule_DisplayLinkClock$stableprop_getter(){}[0] 56 | 57 | // Targets: [js, wasmJs] 58 | final object app.cash.molecule/WindowAnimationFrameClock : androidx.compose.runtime/MonotonicFrameClock { // app.cash.molecule/WindowAnimationFrameClock|null[0] 59 | final suspend fun <#A1: kotlin/Any?> withFrameNanos(kotlin/Function1): #A1 // app.cash.molecule/WindowAnimationFrameClock.withFrameNanos|withFrameNanos(kotlin.Function1){0§}[0] 60 | } 61 | 62 | // Targets: [js, wasmJs] 63 | final val app.cash.molecule/app_cash_molecule_WindowAnimationFrameClock$stableprop // app.cash.molecule/app_cash_molecule_WindowAnimationFrameClock$stableprop|#static{}app_cash_molecule_WindowAnimationFrameClock$stableprop[0] 64 | 65 | // Targets: [js, wasmJs] 66 | final fun app.cash.molecule/app_cash_molecule_WindowAnimationFrameClock$stableprop_getter(): kotlin/Int // app.cash.molecule/app_cash_molecule_WindowAnimationFrameClock$stableprop_getter|app_cash_molecule_WindowAnimationFrameClock$stableprop_getter(){}[0] 67 | -------------------------------------------------------------------------------- /molecule-runtime/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'org.jetbrains.kotlin.multiplatform' 3 | apply plugin: 'org.jetbrains.kotlin.plugin.compose' 4 | apply plugin: 'com.vanniktech.maven.publish' 5 | apply plugin: 'org.jetbrains.dokka' 6 | apply plugin: 'org.jetbrains.kotlinx.binary-compatibility-validator' 7 | 8 | kotlin { 9 | explicitApi() 10 | 11 | androidTarget { 12 | publishLibraryVariants('release') 13 | } 14 | 15 | iosArm64() 16 | iosSimulatorArm64() 17 | iosX64() 18 | 19 | js { 20 | browser() 21 | } 22 | 23 | jvm() 24 | 25 | linuxArm64() 26 | linuxX64() 27 | 28 | macosArm64() 29 | macosX64() 30 | 31 | mingwX64() 32 | 33 | tvosArm64() 34 | tvosSimulatorArm64() 35 | tvosX64() 36 | 37 | wasmJs { 38 | browser() 39 | } 40 | 41 | watchosArm32() 42 | watchosArm64() 43 | watchosSimulatorArm64() 44 | watchosX64() 45 | 46 | applyDefaultHierarchyTemplate { 47 | it.group("common") { 48 | it.group("darwin") { 49 | it.group("displayLink") { 50 | it.group("quartzCore") { 51 | it.group("ios") {} 52 | it.group("tvos") {} 53 | } 54 | it.group("macos") {} 55 | } 56 | it.group("watchos") {} 57 | } 58 | it.group('nonJava') { 59 | it.group('native') {} 60 | it.withJs() 61 | it.withWasmJs() 62 | } 63 | } 64 | } 65 | 66 | sourceSets { 67 | configureEach { 68 | languageSettings.optIn("kotlinx.cinterop.BetaInteropApi") 69 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") 70 | } 71 | 72 | commonMain { 73 | dependencies { 74 | api libs.jetbrains.compose.runtime 75 | api libs.kotlinx.coroutines.core 76 | } 77 | } 78 | commonTest { 79 | dependencies { 80 | implementation libs.kotlin.test 81 | implementation libs.assertk 82 | implementation libs.kotlinx.coroutines.test 83 | } 84 | } 85 | 86 | androidMain { 87 | dependencies { 88 | implementation libs.androidx.core 89 | } 90 | } 91 | 92 | // We use a common folder instead of a common source set because there is no commonizer 93 | // which exposes the Java APIs across these two targets. 94 | androidMain { kotlin.srcDir('src/javaMain/kotlin') } 95 | androidTest { kotlin.srcDir('src/javaTest/kotlin') } 96 | jvmMain { kotlin.srcDir('src/javaMain/kotlin') } 97 | jvmTest { kotlin.srcDir('src/javaTest/kotlin') } 98 | 99 | // We use a common folder instead of a common source set because there is no commonizer 100 | // which exposes the browser APIs across these two targets. 101 | jsMain { kotlin.srcDir('src/browserMain/kotlin') } 102 | wasmJsMain { kotlin.srcDir('src/browserMain/kotlin') } 103 | } 104 | } 105 | 106 | dependencies { 107 | androidTestImplementation libs.androidx.test.runner 108 | androidTestImplementation libs.junit 109 | androidTestImplementation libs.assertk 110 | androidTestImplementation libs.kotlinx.coroutines.test 111 | 112 | // The kotlin.test library provides JVM variants for multiple testing frameworks. When used 113 | // as a test dependency this selection is transparent. But since we are using from an Android 114 | // configuration we have to select the desired variant via an explicit capability. 115 | add("androidTestImplementation", libs.kotlin.test) { 116 | capabilities { 117 | requireCapability( 118 | "org.jetbrains.kotlin:kotlin-test-framework-junit:${libs.versions.kotlin.get()}") 119 | } 120 | } 121 | } 122 | 123 | android { 124 | namespace 'app.cash.molecule' 125 | 126 | sourceSets { 127 | androidTest { 128 | java.srcDirs += 'src/commonTest/kotlin' 129 | java.srcDirs += 'src/javaTest/kotlin' 130 | } 131 | } 132 | 133 | defaultConfig { 134 | testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' 135 | } 136 | 137 | testOptions { 138 | unitTests.returnDefaultValues = true 139 | } 140 | 141 | packagingOptions { 142 | exclude 'META-INF/AL2.0' 143 | exclude 'META-INF/LGPL2.1' 144 | } 145 | } 146 | 147 | apiValidation { 148 | klib.enabled = true 149 | } 150 | 151 | spotless { 152 | kotlin { 153 | targetExclude( 154 | // Apache 2-licensed files from AOSP. 155 | "src/androidMain/kotlin/app/cash/molecule/AndroidUiDispatcher.kt", 156 | "src/androidMain/kotlin/app/cash/molecule/AndroidUiFrameClock.kt", 157 | ) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /molecule-runtime/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=molecule-runtime 2 | POM_NAME=Molecule 3 | -------------------------------------------------------------------------------- /molecule-runtime/src/androidInstrumentedTest/kotlin/app/cash/molecule/AndroidUiFrameClockTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.MonotonicFrameClock 19 | import assertk.all 20 | import assertk.assertThat 21 | import assertk.assertions.isLessThan 22 | import assertk.assertions.isPositive 23 | import kotlinx.coroutines.test.runTest 24 | import org.junit.Test 25 | 26 | class AndroidUiFrameClockTest { 27 | @Test fun ticksWithTime() = runTest { 28 | val frameClock = AndroidUiDispatcher.Main[MonotonicFrameClock]!! 29 | val frameTimeA = frameClock.withFrameNanos { it } 30 | val frameTimeB = frameClock.withFrameNanos { it } 31 | assertThat(frameTimeA).all { 32 | isPositive() 33 | isLessThan(frameTimeB) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /molecule-runtime/src/androidMain/kotlin/app/cash/molecule/AndroidUiDispatcher.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // This file copied from Compose since it lives inside Compose UI but is general-purpose. 18 | package app.cash.molecule 19 | 20 | import android.os.Looper 21 | import android.view.Choreographer 22 | import androidx.compose.runtime.MonotonicFrameClock 23 | import androidx.core.os.HandlerCompat 24 | import app.cash.molecule.AndroidUiDispatcher.Companion.Main 25 | import kotlin.coroutines.CoroutineContext 26 | import kotlinx.coroutines.CoroutineDispatcher 27 | import kotlinx.coroutines.Dispatchers 28 | import kotlinx.coroutines.runBlocking 29 | 30 | /** 31 | * A [CoroutineDispatcher] that will perform dispatch during a [handler] callback or 32 | * [choreographer]'s animation frame stage, whichever comes first. Use [Main] to obtain 33 | * a dispatcher for the process's main thread (i.e. the activity thread). 34 | */ 35 | // Implementation note: the constructor is private to direct users toward the companion object 36 | // accessors for the main/current threads. A choreographer must be obtained from its current 37 | // thread as per the only public API surface for obtaining one as of this writing, and the 38 | // choreographer and handler must match. Constructing an AndroidUiDispatcher with a handler 39 | // not marked as async will adversely affect dispatch behavior but not to the point of 40 | // incorrectness; more operations would be deferred to the choreographer frame as racing handler 41 | // messages would wait behind a frame barrier. 42 | public class AndroidUiDispatcher private constructor( 43 | public val choreographer: Choreographer, 44 | private val handler: android.os.Handler, 45 | ) : CoroutineDispatcher() { 46 | 47 | // Guards all properties in this class 48 | private val lock = Any() 49 | 50 | private val toRunTrampolined = ArrayDeque() 51 | private var toRunOnFrame = mutableListOf() 52 | private var spareToRunOnFrame = mutableListOf() 53 | private var scheduledTrampolineDispatch = false 54 | private var scheduledFrameDispatch = false 55 | 56 | private val dispatchCallback = object : Choreographer.FrameCallback, Runnable { 57 | override fun run() { 58 | performTrampolineDispatch() 59 | synchronized(lock) { 60 | if (toRunOnFrame.isEmpty()) { 61 | choreographer.removeFrameCallback(this) 62 | scheduledFrameDispatch = false 63 | } 64 | } 65 | } 66 | 67 | override fun doFrame(frameTimeNanos: Long) { 68 | handler.removeCallbacks(this) 69 | performTrampolineDispatch() 70 | performFrameDispatch(frameTimeNanos) 71 | } 72 | } 73 | 74 | private fun nextTask(): Runnable? = synchronized(lock) { 75 | toRunTrampolined.removeFirstOrNull() 76 | } 77 | 78 | private fun performTrampolineDispatch() { 79 | do { 80 | var task = nextTask() 81 | while (task != null) { 82 | task.run() 83 | task = nextTask() 84 | } 85 | } while ( 86 | // We don't dispatch holding the lock so that other tasks can get in on our 87 | // trampolining time slice, but once we're done, make sure nothing added a new task 88 | // before we set scheduledDispatch = false, which would prevent the next dispatch 89 | // from being correctly scheduled. Loop to run these stragglers now. 90 | synchronized(lock) { 91 | if (toRunTrampolined.isEmpty()) { 92 | scheduledTrampolineDispatch = false 93 | false 94 | } else { 95 | true 96 | } 97 | } 98 | ) 99 | } 100 | 101 | private fun performFrameDispatch(frameTimeNanos: Long) { 102 | val toRun = synchronized(lock) { 103 | if (!scheduledFrameDispatch) return 104 | scheduledFrameDispatch = false 105 | val result = toRunOnFrame 106 | toRunOnFrame = spareToRunOnFrame 107 | spareToRunOnFrame = result 108 | result 109 | } 110 | for (i in 0 until toRun.size) { 111 | // This callback will not and must not throw, see AndroidUiFrameClock 112 | toRun[i].doFrame(frameTimeNanos) 113 | } 114 | toRun.clear() 115 | } 116 | 117 | internal fun postFrameCallback(callback: Choreographer.FrameCallback) { 118 | synchronized(lock) { 119 | toRunOnFrame.add(callback) 120 | if (!scheduledFrameDispatch) { 121 | scheduledFrameDispatch = true 122 | choreographer.postFrameCallback(dispatchCallback) 123 | } 124 | } 125 | } 126 | 127 | internal fun removeFrameCallback(callback: Choreographer.FrameCallback) { 128 | synchronized(lock) { 129 | toRunOnFrame.remove(callback) 130 | } 131 | } 132 | 133 | /** 134 | * A [MonotonicFrameClock] associated with this [AndroidUiDispatcher]'s [choreographer] 135 | * that may be used to await [Choreographer] frame dispatch. 136 | */ 137 | public val frameClock: MonotonicFrameClock = AndroidUiFrameClock(choreographer) 138 | 139 | override fun dispatch(context: CoroutineContext, block: Runnable) { 140 | synchronized(lock) { 141 | toRunTrampolined.addLast(block) 142 | if (!scheduledTrampolineDispatch) { 143 | scheduledTrampolineDispatch = true 144 | handler.post(dispatchCallback) 145 | if (!scheduledFrameDispatch) { 146 | scheduledFrameDispatch = true 147 | choreographer.postFrameCallback(dispatchCallback) 148 | } 149 | } 150 | } 151 | } 152 | 153 | public companion object { 154 | /** 155 | * The [CoroutineContext] containing the [AndroidUiDispatcher] and its [frameClock] for the 156 | * process's main thread. 157 | */ 158 | public val Main: CoroutineContext by lazy { 159 | val dispatcher = AndroidUiDispatcher( 160 | if (isMainThread()) { 161 | Choreographer.getInstance() 162 | } else { 163 | runBlocking(Dispatchers.Main) { Choreographer.getInstance() } 164 | }, 165 | HandlerCompat.createAsync(Looper.getMainLooper()), 166 | ) 167 | 168 | dispatcher + dispatcher.frameClock 169 | } 170 | } 171 | } 172 | 173 | private fun isMainThread() = Looper.myLooper() === Looper.getMainLooper() 174 | -------------------------------------------------------------------------------- /molecule-runtime/src/androidMain/kotlin/app/cash/molecule/AndroidUiFrameClock.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // This file copied from Compose since it lives inside Compose UI but is general-purpose. 18 | package app.cash.molecule 19 | 20 | import android.view.Choreographer 21 | import androidx.compose.runtime.MonotonicFrameClock 22 | import kotlin.coroutines.ContinuationInterceptor 23 | import kotlin.coroutines.coroutineContext 24 | import kotlinx.coroutines.suspendCancellableCoroutine 25 | 26 | public class AndroidUiFrameClock( 27 | private val choreographer: Choreographer, 28 | ) : MonotonicFrameClock { 29 | override suspend fun withFrameNanos( 30 | onFrame: (Long) -> R, 31 | ): R { 32 | val uiDispatcher = coroutineContext[ContinuationInterceptor] as? AndroidUiDispatcher 33 | return suspendCancellableCoroutine { co -> 34 | // Important: this callback won't throw, and AndroidUiDispatcher counts on it. 35 | val callback = Choreographer.FrameCallback { frameTimeNanos -> 36 | co.resumeWith(runCatching { onFrame(frameTimeNanos) }) 37 | } 38 | 39 | // If we're on an AndroidUiDispatcher then we post callback to happen *after* 40 | // the greedy trampoline dispatch is complete. 41 | // This means that onFrame will run on the current choreographer frame if one is 42 | // already in progress, but withFrameNanos will *not* resume until the frame 43 | // is complete. This prevents multiple calls to withFrameNanos immediately dispatching 44 | // on the same frame. 45 | 46 | if (uiDispatcher != null && uiDispatcher.choreographer == choreographer) { 47 | uiDispatcher.postFrameCallback(callback) 48 | co.invokeOnCancellation { uiDispatcher.removeFrameCallback(callback) } 49 | } else { 50 | choreographer.postFrameCallback(callback) 51 | co.invokeOnCancellation { choreographer.removeFrameCallback(callback) } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /molecule-runtime/src/androidMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | @Suppress("NOTHING_TO_INLINE") 19 | internal actual inline fun nanoTime(): Long = System.nanoTime() 20 | -------------------------------------------------------------------------------- /molecule-runtime/src/browserMain/kotlin/app/cash/molecule/WindowAnimationFrameClock.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.MonotonicFrameClock 19 | import kotlin.coroutines.resume 20 | import kotlin.coroutines.suspendCoroutine 21 | 22 | public object WindowAnimationFrameClock : MonotonicFrameClock { 23 | override suspend fun withFrameNanos( 24 | onFrame: (Long) -> R, 25 | ): R = suspendCoroutine { continuation -> 26 | window.requestAnimationFrame { 27 | val durationMillis = it.toLong() 28 | val durationNanos = durationMillis * 1_000_000 29 | val result = onFrame(durationNanos) 30 | continuation.resume(result) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /molecule-runtime/src/browserMain/kotlin/app/cash/molecule/browser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | // This file contains a subset of browser APIs normally provided by the Kotlin stdlib. 19 | // However, these were recently removed in favor of kotlinx.browser which is not stable. 20 | // Thus, we duplicate them for both JS and Wasm JS since this is a shared source set. 21 | 22 | internal external val window: Window 23 | 24 | internal external interface Window { 25 | val performance: Performance 26 | fun requestAnimationFrame(callback: (Double) -> Unit) 27 | } 28 | 29 | internal external interface Performance { 30 | fun now(): Double 31 | } 32 | -------------------------------------------------------------------------------- /molecule-runtime/src/browserMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | @Suppress("NOTHING_TO_INLINE") 19 | internal actual inline fun nanoTime(): Long = window.performance.now().toLong() * 1_000_000 20 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonMain/kotlin/app/cash/molecule/GatedFrameClock.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.BroadcastFrameClock 19 | import androidx.compose.runtime.MonotonicFrameClock 20 | import kotlin.coroutines.CoroutineContext 21 | import kotlinx.coroutines.CoroutineScope 22 | import kotlinx.coroutines.channels.Channel 23 | import kotlinx.coroutines.channels.Channel.Factory.CONFLATED 24 | import kotlinx.coroutines.launch 25 | 26 | /** 27 | * A [MonotonicFrameClock] that is either running, or not. 28 | * 29 | * While running, any request for a frame immediately succeeds. If stopped, requests for a frame wait until 30 | * the clock is set to run again. 31 | */ 32 | internal class GatedFrameClock( 33 | scope: CoroutineScope, 34 | context: CoroutineContext, 35 | ) : MonotonicFrameClock { 36 | private val frameSends = Channel(CONFLATED) 37 | 38 | init { 39 | scope.launch(context) { 40 | for (send in frameSends) sendFrame() 41 | } 42 | } 43 | 44 | var isRunning: Boolean = true 45 | set(value) { 46 | val started = value && !field 47 | field = value 48 | if (started) { 49 | sendFrame() 50 | } 51 | } 52 | 53 | private var lastNanos = 0L 54 | private var lastOffset = 0 55 | 56 | private fun sendFrame() { 57 | val timeNanos = nanoTime() 58 | 59 | // Since we only have millisecond resolution, ensure the nanos form always increases by 60 | // incrementing a nano offset if we collide with the previous timestamp. 61 | val offset = if (timeNanos == lastNanos) { 62 | lastOffset + 1 63 | } else { 64 | lastNanos = timeNanos 65 | 0 66 | } 67 | lastOffset = offset 68 | 69 | clock.sendFrame(timeNanos + offset) 70 | } 71 | 72 | private val clock = BroadcastFrameClock { 73 | if (isRunning) frameSends.trySend(Unit).getOrThrow() 74 | } 75 | 76 | override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { 77 | return clock.withFrameNanos(onFrame) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonMain/kotlin/app/cash/molecule/RecompositionMode.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.MonotonicFrameClock 19 | import kotlin.coroutines.CoroutineContext 20 | 21 | /** The different recomposition modes of Molecule. */ 22 | public enum class RecompositionMode { 23 | /** 24 | * When a recomposition is needed, use a [MonotonicFrameClock] pulled from the calling [CoroutineContext] 25 | * to determine when to run. If no clock is found in the context, an exception is thrown. 26 | * 27 | * Use this option to drive Molecule with a built-in frame clock or a custom one. 28 | * 29 | * After the initial, synchronous recomposition, future recompositions will run on a thread 30 | * managed by the external clock. 31 | */ 32 | ContextClock, 33 | 34 | /** 35 | * Run recomposition eagerly whenever one is needed. 36 | * Molecule will emit a new item every time the snapshot state is invalidated. 37 | * 38 | * After the initial, synchronous recomposition, future recompositions will run on a thread 39 | * managed by the dispatcher from the coroutine context. 40 | */ 41 | Immediate, 42 | } 43 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonMain/kotlin/app/cash/molecule/SnapshotNotifier.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import app.cash.molecule.SnapshotNotifier.WhileActive 19 | 20 | /** 21 | * The different snapshot notification modes of Molecule. 22 | * 23 | * Compose uses snapshots to provide a consistent view of state. Mutations to state outside of the 24 | * composition are not automatically observed, and notifications of changes must be manually sent. 25 | * This can be achieved by registering a global write observer, for which you need at-minimum one 26 | * per process. Applications which use other Compose-based systems like Compose UI likely already 27 | * have one in place, whereas applications that only use Molecule need its automatic registering 28 | * of this notifier. 29 | * 30 | * On the JVM and Android, Molecule will read the `app.cash.molecule.snapshotNotifier` system 31 | * property in order to determine the default mechanism. The value is parsed with [enumValueOf], 32 | * or else defaults to [WhileActive] if not set or the property does not parse to a value. 33 | * 34 | * @see androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications 35 | */ 36 | public enum class SnapshotNotifier { 37 | /** 38 | * Rely on some other external system for sending snapshot change notifications. 39 | * 40 | * This should only be used if you can guarantee that someone else is listening to global 41 | * snapshot writes and sending apply notifications. Usually this means that some other 42 | * Compose-based system is being used in your application, and that it will always be 43 | * initialized prior to Molecule or at the same time. 44 | * 45 | * Failure to ensure someone else is sending apply notifications will result in state writes 46 | * not triggering additional recomposition. 47 | * 48 | * Some examples where this policy can be used: 49 | * - On Android, using Compose UI _before_ Molecule (e.g., even just calling `setContent { }` on 50 | * an `Activity` or `ComposeView`) will start a singleton snapshot write listener and applier. 51 | * If you are sure that this will _always_ happen, specifying this policy for all Molecule 52 | * launches is valid. 53 | * - On JetBrains' Compose UI for Desktop, calling the `application { }` function is enough to 54 | * ensure their singleton snapshot write listener and applier is started (you do not have to 55 | * even show a window). 56 | */ 57 | External, 58 | 59 | /** 60 | * Register a global snapshot write observer and send apply notifications when new writes occur 61 | * using a coroutine launched on the same scope as the composition. This coroutine will be 62 | * canceled and observer unregistered when that scope is canceled. 63 | */ 64 | WhileActive, 65 | } 66 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonMain/kotlin/app/cash/molecule/molecule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.AbstractApplier 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.Composition 21 | import androidx.compose.runtime.Recomposer 22 | import androidx.compose.runtime.snapshots.ObserverHandle 23 | import androidx.compose.runtime.snapshots.Snapshot 24 | import kotlin.DeprecationLevel.HIDDEN 25 | import kotlin.coroutines.CoroutineContext 26 | import kotlin.coroutines.EmptyCoroutineContext 27 | import kotlinx.coroutines.CoroutineScope 28 | import kotlinx.coroutines.CoroutineStart.UNDISPATCHED 29 | import kotlinx.coroutines.channels.Channel 30 | import kotlinx.coroutines.coroutineScope 31 | import kotlinx.coroutines.flow.Flow 32 | import kotlinx.coroutines.flow.MutableStateFlow 33 | import kotlinx.coroutines.flow.StateFlow 34 | import kotlinx.coroutines.flow.channelFlow 35 | import kotlinx.coroutines.flow.flow 36 | import kotlinx.coroutines.launch 37 | 38 | @Deprecated("", level = HIDDEN) // For binary compatibility. 39 | public fun moleculeFlow(mode: RecompositionMode, body: @Composable () -> T): Flow { 40 | return moleculeFlow( 41 | mode = mode, 42 | snapshotNotifier = defaultSnapshotNotifier(), 43 | body = body, 44 | ) 45 | } 46 | 47 | /** 48 | * Create a [Flow] which will continually recompose `body` to produce a stream of [T] values 49 | * when collected. 50 | */ 51 | public fun moleculeFlow( 52 | mode: RecompositionMode, 53 | snapshotNotifier: SnapshotNotifier = defaultSnapshotNotifier(), 54 | body: @Composable () -> T, 55 | ): Flow { 56 | return when (mode) { 57 | RecompositionMode.ContextClock -> contextClockFlow(snapshotNotifier, body) 58 | RecompositionMode.Immediate -> immediateClockFlow(snapshotNotifier, body) 59 | } 60 | } 61 | 62 | private fun contextClockFlow( 63 | snapshotNotifier: SnapshotNotifier, 64 | body: @Composable () -> T, 65 | ) = channelFlow { 66 | launchMolecule( 67 | mode = RecompositionMode.ContextClock, 68 | snapshotNotifier = snapshotNotifier, 69 | emitter = { 70 | trySend(it).getOrThrow() 71 | }, 72 | body = body, 73 | ) 74 | } 75 | 76 | private fun immediateClockFlow( 77 | snapshotNotifier: SnapshotNotifier, 78 | body: @Composable () -> T, 79 | ): Flow = flow { 80 | coroutineScope { 81 | val clock = GatedFrameClock(this, EmptyCoroutineContext) 82 | val outputBuffer = Channel(1) 83 | 84 | launch(clock, start = UNDISPATCHED) { 85 | launchMolecule( 86 | mode = RecompositionMode.ContextClock, 87 | snapshotNotifier = snapshotNotifier, 88 | emitter = { 89 | clock.isRunning = false 90 | outputBuffer.trySend(it).getOrThrow() 91 | }, 92 | body = body, 93 | ) 94 | } 95 | 96 | while (true) { 97 | val result = outputBuffer.tryReceive() 98 | // Per `ReceiveChannel.tryReceive` documentation: isFailure means channel is empty. 99 | val value = if (result.isFailure) { 100 | clock.isRunning = true 101 | outputBuffer.receive() 102 | } else { 103 | result.getOrThrow() 104 | } 105 | emit(value) 106 | } 107 | /* 108 | TODO: Replace the above block with the following once `ReceiveChannel.isEmpty` is stable: 109 | 110 | for (item in outputBuffer) { 111 | emit(item) 112 | if (outputBuffer.isEmpty) { 113 | clock.isRunning = true 114 | } 115 | } 116 | */ 117 | } 118 | } 119 | 120 | @Deprecated("", level = HIDDEN) // For binary compatibility. 121 | public fun CoroutineScope.launchMolecule( 122 | mode: RecompositionMode, 123 | body: @Composable () -> T, 124 | ): StateFlow = launchMolecule( 125 | mode = mode, 126 | context = EmptyCoroutineContext, 127 | body = body, 128 | ) 129 | 130 | @Deprecated("", level = HIDDEN) // For binary compatibility. 131 | public fun CoroutineScope.launchMolecule( 132 | mode: RecompositionMode, 133 | context: CoroutineContext = EmptyCoroutineContext, 134 | body: @Composable () -> T, 135 | ): StateFlow { 136 | return launchMolecule( 137 | mode = mode, 138 | context = context, 139 | snapshotNotifier = defaultSnapshotNotifier(), 140 | body = body, 141 | ) 142 | } 143 | 144 | /** 145 | * Launch a coroutine into this [CoroutineScope] which will continually recompose `body` 146 | * to produce a [StateFlow] stream of [T] values. 147 | * 148 | * The coroutine context is inherited from the [CoroutineScope]. 149 | * Additional context elements can be specified with [context] argument. 150 | */ 151 | public fun CoroutineScope.launchMolecule( 152 | mode: RecompositionMode, 153 | context: CoroutineContext = EmptyCoroutineContext, 154 | snapshotNotifier: SnapshotNotifier = defaultSnapshotNotifier(), 155 | body: @Composable () -> T, 156 | ): StateFlow { 157 | var flow: MutableStateFlow? = null 158 | 159 | launchMolecule( 160 | context = context, 161 | mode = mode, 162 | snapshotNotifier = snapshotNotifier, 163 | emitter = { value -> 164 | val outputFlow = flow 165 | if (outputFlow != null) { 166 | outputFlow.value = value 167 | } else { 168 | flow = MutableStateFlow(value) 169 | } 170 | }, 171 | body = body, 172 | ) 173 | 174 | return flow!! 175 | } 176 | 177 | @Deprecated("", level = HIDDEN) // For binary compatibility. 178 | public fun CoroutineScope.launchMolecule( 179 | mode: RecompositionMode, 180 | emitter: (value: T) -> Unit, 181 | body: @Composable () -> T, 182 | ) { 183 | launchMolecule( 184 | mode = mode, 185 | emitter = emitter, 186 | context = EmptyCoroutineContext, 187 | body = body, 188 | ) 189 | } 190 | 191 | @Deprecated("", level = HIDDEN) // For binary compatibility. 192 | public fun CoroutineScope.launchMolecule( 193 | mode: RecompositionMode, 194 | emitter: (value: T) -> Unit, 195 | context: CoroutineContext = EmptyCoroutineContext, 196 | body: @Composable () -> T, 197 | ) { 198 | launchMolecule( 199 | mode = mode, 200 | emitter = emitter, 201 | context = context, 202 | snapshotNotifier = defaultSnapshotNotifier(), 203 | body = body, 204 | ) 205 | } 206 | 207 | /** 208 | * Launch a coroutine into this [CoroutineScope] which will continually recompose `body` 209 | * in the optional [context] to invoke [emitter] with each returned [T] value. 210 | * 211 | * [launchMolecule]'s [emitter] is always free-running and will not respect backpressure. 212 | * Use [moleculeFlow] to create a backpressure-capable flow. 213 | * 214 | * The coroutine context is inherited from the [CoroutineScope]. 215 | * Additional context elements can be specified with [context] argument. 216 | */ 217 | public fun CoroutineScope.launchMolecule( 218 | mode: RecompositionMode, 219 | emitter: (value: T) -> Unit, 220 | context: CoroutineContext = EmptyCoroutineContext, 221 | snapshotNotifier: SnapshotNotifier = defaultSnapshotNotifier(), 222 | body: @Composable () -> T, 223 | ) { 224 | val clockContext = when (mode) { 225 | RecompositionMode.ContextClock -> EmptyCoroutineContext 226 | RecompositionMode.Immediate -> GatedFrameClock(this, context) 227 | } 228 | val finalContext = coroutineContext + context + clockContext 229 | 230 | val recomposer = Recomposer(finalContext) 231 | val composition = Composition(UnitApplier, recomposer) 232 | 233 | var snapshotHandle: ObserverHandle? = null 234 | launch(finalContext, start = UNDISPATCHED) { 235 | try { 236 | recomposer.runRecomposeAndApplyChanges() 237 | } finally { 238 | composition.dispose() 239 | snapshotHandle?.dispose() 240 | } 241 | } 242 | 243 | when (snapshotNotifier) { 244 | SnapshotNotifier.External -> {} 245 | SnapshotNotifier.WhileActive -> { 246 | var applyScheduled = false 247 | snapshotHandle = Snapshot.registerGlobalWriteObserver { 248 | if (!applyScheduled) { 249 | applyScheduled = true 250 | launch(finalContext) { 251 | applyScheduled = false 252 | Snapshot.sendApplyNotifications() 253 | } 254 | } 255 | } 256 | } 257 | } 258 | 259 | composition.setContent { 260 | emitter(body()) 261 | } 262 | } 263 | 264 | private object UnitApplier : AbstractApplier(Unit) { 265 | override fun insertBottomUp(index: Int, instance: Unit) {} 266 | override fun insertTopDown(index: Int, instance: Unit) {} 267 | override fun move(from: Int, to: Int, count: Int) {} 268 | override fun remove(index: Int, count: Int) {} 269 | override fun onClear() {} 270 | } 271 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonMain/kotlin/app/cash/molecule/platform.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | internal expect fun defaultSnapshotNotifier(): SnapshotNotifier 19 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | internal expect fun nanoTime(): Long 19 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonTest/kotlin/app/cash/molecule/GatedFrameClockTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import assertk.all 19 | import assertk.assertThat 20 | import assertk.assertions.isLessThan 21 | import assertk.assertions.isPositive 22 | import kotlin.coroutines.EmptyCoroutineContext 23 | import kotlin.test.Test 24 | import kotlinx.coroutines.test.runTest 25 | 26 | class GatedFrameClockTest { 27 | @Test 28 | fun ticksWithTime() = runTest { 29 | val frameClock = GatedFrameClock(backgroundScope, EmptyCoroutineContext) 30 | val frameTimeA = frameClock.withFrameNanos { it } 31 | val frameTimeB = frameClock.withFrameNanos { it } 32 | assertThat(frameTimeA).all { 33 | isPositive() 34 | isLessThan(frameTimeB) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeStateFlowTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.BroadcastFrameClock 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableIntStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.setValue 24 | import app.cash.molecule.RecompositionMode.ContextClock 25 | import app.cash.molecule.RecompositionMode.Immediate 26 | import assertk.assertFailure 27 | import assertk.assertThat 28 | import assertk.assertions.isEqualTo 29 | import assertk.assertions.isSameInstanceAs 30 | import assertk.assertions.isTrue 31 | import kotlin.test.Test 32 | import kotlinx.coroutines.CoroutineScope 33 | import kotlinx.coroutines.CoroutineStart 34 | import kotlinx.coroutines.ExperimentalCoroutinesApi 35 | import kotlinx.coroutines.Job 36 | import kotlinx.coroutines.cancelAndJoin 37 | import kotlinx.coroutines.delay 38 | import kotlinx.coroutines.flow.StateFlow 39 | import kotlinx.coroutines.job 40 | import kotlinx.coroutines.launch 41 | import kotlinx.coroutines.plus 42 | import kotlinx.coroutines.supervisorScope 43 | import kotlinx.coroutines.test.advanceTimeBy 44 | import kotlinx.coroutines.test.runCurrent 45 | import kotlinx.coroutines.test.runTest 46 | 47 | @ExperimentalCoroutinesApi 48 | class MoleculeStateFlowTest { 49 | @Test fun items() = runTest { 50 | val job = Job() 51 | val clock = BroadcastFrameClock() 52 | val scope = CoroutineScope(coroutineContext + job + clock) 53 | 54 | val flow = scope.launchMolecule(ContextClock) { 55 | var count by remember { mutableIntStateOf(0) } 56 | LaunchedEffect(Unit) { 57 | while (true) { 58 | delay(100) 59 | count++ 60 | } 61 | } 62 | 63 | count 64 | } 65 | 66 | assertThat(flow.value).isEqualTo(0) 67 | 68 | clock.sendFrame(0) 69 | assertThat(flow.value).isEqualTo(0) 70 | 71 | advanceTimeBy(99) 72 | runCurrent() 73 | clock.sendFrame(0) 74 | assertThat(flow.value).isEqualTo(0) 75 | 76 | advanceTimeBy(1) 77 | runCurrent() 78 | clock.sendFrame(0) 79 | assertThat(flow.value).isEqualTo(1) 80 | 81 | advanceTimeBy(100) 82 | runCurrent() 83 | clock.sendFrame(0) 84 | assertThat(flow.value).isEqualTo(2) 85 | 86 | job.cancelAndJoin() 87 | } 88 | 89 | @Test fun errorImmediately() = runTest { 90 | val job = Job() 91 | val clock = BroadcastFrameClock() 92 | val scope = CoroutineScope(coroutineContext + job + clock) 93 | 94 | // Use a custom subtype to prevent coroutines from breaking referential equality. 95 | val runtimeException = object : RuntimeException() {} 96 | assertFailure { 97 | scope.launchMolecule(ContextClock) { 98 | throw runtimeException 99 | } 100 | }.isSameInstanceAs(runtimeException) 101 | 102 | // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. 103 | job.cancelAndJoin() 104 | } 105 | 106 | @Test fun errorDelayed() = runTest { 107 | val job = Job() 108 | val clock = BroadcastFrameClock() 109 | val exceptionHandler = RecordingExceptionHandler() 110 | val scope = CoroutineScope(coroutineContext + job + clock + exceptionHandler) 111 | 112 | // Use a custom subtype to prevent coroutines from breaking referential equality. 113 | val runtimeException = object : RuntimeException() {} 114 | var count by mutableIntStateOf(0) 115 | val flow = scope.launchMolecule(ContextClock) { 116 | println("Sup $count") 117 | if (count == 1) { 118 | throw runtimeException 119 | } 120 | count 121 | } 122 | 123 | assertThat(flow.value).isEqualTo(0) 124 | 125 | count++ 126 | runCurrent() 127 | clock.sendFrame(0) 128 | runCurrent() 129 | assertThat(exceptionHandler.exceptions.single()).isSameInstanceAs(runtimeException) 130 | 131 | // Verify `runRecomposeAndApplyChanges` is no longer active. 132 | assertThat(job.isCompleted).isTrue() 133 | } 134 | 135 | @Test fun errorInEffect() = runTest { 136 | val job = Job() 137 | val clock = BroadcastFrameClock() 138 | val exceptionHandler = RecordingExceptionHandler() 139 | val scope = CoroutineScope(coroutineContext + job + clock + exceptionHandler) 140 | 141 | // Use a custom subtype to prevent coroutines from breaking referential equality. 142 | val runtimeException = object : RuntimeException() {} 143 | val flow = scope.launchMolecule(ContextClock) { 144 | LaunchedEffect(Unit) { 145 | delay(50) 146 | throw runtimeException 147 | } 148 | 0 149 | } 150 | 151 | assertThat(flow.value).isEqualTo(0) 152 | 153 | advanceTimeBy(50) 154 | runCurrent() 155 | clock.sendFrame(0) 156 | assertThat(exceptionHandler.exceptions.single()).isSameInstanceAs(runtimeException) 157 | 158 | // Verify `runRecomposeAndApplyChanges` is no longer active. 159 | assertThat(job.isCompleted).isTrue() 160 | } 161 | 162 | @Test 163 | fun itemsImmediate() = runTest { 164 | val job = Job(coroutineContext.job) 165 | val scope = this + job 166 | 167 | val flow = scope.launchMolecule(Immediate) { 168 | var count by remember { mutableIntStateOf(0) } 169 | LaunchedEffect(Unit) { 170 | while (true) { 171 | delay(100) 172 | count++ 173 | } 174 | } 175 | 176 | count 177 | } 178 | 179 | assertThat(flow.value).isEqualTo(0) 180 | 181 | advanceTimeBy(99) 182 | runCurrent() 183 | assertThat(flow.value).isEqualTo(0) 184 | 185 | advanceTimeBy(1) 186 | runCurrent() 187 | assertThat(flow.value).isEqualTo(1) 188 | 189 | advanceTimeBy(100) 190 | runCurrent() 191 | assertThat(flow.value).isEqualTo(2) 192 | 193 | job.cancelAndJoin() 194 | } 195 | 196 | @Test fun errorDelayedImmediate() = runTest { 197 | // Use a custom subtype to prevent coroutines from breaking referential equality. 198 | val runtimeException = object : RuntimeException() {} 199 | val exceptionHandler = RecordingExceptionHandler() 200 | 201 | var count by mutableIntStateOf(0) 202 | supervisorScope { 203 | var flow: StateFlow? = null 204 | 205 | val job = launch(start = CoroutineStart.UNDISPATCHED, context = exceptionHandler) { 206 | flow = launchMolecule(Immediate) { 207 | if (count == 1) { 208 | throw runtimeException 209 | } 210 | count 211 | } 212 | } 213 | 214 | assertThat(flow!!.value).isEqualTo(0) 215 | 216 | count++ 217 | 218 | job.join() 219 | 220 | assertThat(exceptionHandler.exceptions.single()).isSameInstanceAs(runtimeException) 221 | // Verify `runRecomposeAndApplyChanges` is no longer active. 222 | assertThat(job.isCompleted).isTrue() 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.BroadcastFrameClock 19 | import androidx.compose.runtime.DisposableEffect 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.MonotonicFrameClock 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableIntStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberCoroutineScope 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.runtime.snapshots.Snapshot 28 | import app.cash.molecule.MoleculeTest.DisposableEffectState.DISPOSED 29 | import app.cash.molecule.MoleculeTest.DisposableEffectState.LAUNCHED 30 | import app.cash.molecule.MoleculeTest.DisposableEffectState.NOT_LAUNCHED 31 | import app.cash.molecule.RecompositionMode.ContextClock 32 | import app.cash.molecule.RecompositionMode.Immediate 33 | import app.cash.molecule.SnapshotNotifier.External 34 | import app.cash.molecule.SnapshotNotifier.WhileActive 35 | import assertk.assertFailure 36 | import assertk.assertThat 37 | import assertk.assertions.isEqualTo 38 | import assertk.assertions.isNotSameInstanceAs 39 | import assertk.assertions.isSameInstanceAs 40 | import assertk.assertions.isTrue 41 | import kotlin.test.Test 42 | import kotlin.test.fail 43 | import kotlinx.coroutines.CoroutineName 44 | import kotlinx.coroutines.CoroutineScope 45 | import kotlinx.coroutines.ExperimentalCoroutinesApi 46 | import kotlinx.coroutines.Job 47 | import kotlinx.coroutines.cancelAndJoin 48 | import kotlinx.coroutines.channels.Channel 49 | import kotlinx.coroutines.delay 50 | import kotlinx.coroutines.flow.collect 51 | import kotlinx.coroutines.launch 52 | import kotlinx.coroutines.test.advanceTimeBy 53 | import kotlinx.coroutines.test.runCurrent 54 | import kotlinx.coroutines.test.runTest 55 | import kotlinx.coroutines.withTimeout 56 | 57 | @ExperimentalCoroutinesApi 58 | class MoleculeTest { 59 | @Test fun items() = runTest { 60 | val job = Job() 61 | val clock = BroadcastFrameClock() 62 | val scope = CoroutineScope(coroutineContext + job + clock) 63 | var value: Int? = null 64 | 65 | scope.launchMolecule(ContextClock, emitter = { value = it }) { 66 | var count by remember { mutableIntStateOf(0) } 67 | LaunchedEffect(Unit) { 68 | while (true) { 69 | delay(100) 70 | count++ 71 | } 72 | } 73 | 74 | count 75 | } 76 | 77 | assertThat(value).isEqualTo(0) 78 | 79 | clock.sendFrame(0) 80 | assertThat(value).isEqualTo(0) 81 | 82 | advanceTimeBy(99) 83 | runCurrent() 84 | clock.sendFrame(0) 85 | assertThat(value).isEqualTo(0) 86 | 87 | advanceTimeBy(1) 88 | runCurrent() 89 | clock.sendFrame(0) 90 | assertThat(value).isEqualTo(1) 91 | 92 | advanceTimeBy(100) 93 | runCurrent() 94 | clock.sendFrame(0) 95 | assertThat(value).isEqualTo(2) 96 | 97 | job.cancelAndJoin() 98 | } 99 | 100 | @Test fun errorImmediately() = runTest { 101 | val job = Job() 102 | val clock = BroadcastFrameClock() 103 | val scope = CoroutineScope(coroutineContext + job + clock) 104 | 105 | // Use a custom subtype to prevent coroutines from breaking referential equality. 106 | val runtimeException = object : RuntimeException() {} 107 | assertFailure { 108 | scope.launchMolecule(ContextClock, emitter = { fail() }) { 109 | throw runtimeException 110 | } 111 | }.isSameInstanceAs(runtimeException) 112 | 113 | // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. 114 | job.cancelAndJoin() 115 | } 116 | 117 | @Test fun errorDelayed() = runTest { 118 | val job = Job() 119 | val clock = BroadcastFrameClock() 120 | val exceptionHandler = RecordingExceptionHandler() 121 | val scope = CoroutineScope(coroutineContext + job + clock + exceptionHandler) 122 | var value: Int? = null 123 | 124 | // Use a custom subtype to prevent coroutines from breaking referential equality. 125 | val runtimeException = object : RuntimeException() {} 126 | var count by mutableIntStateOf(0) 127 | scope.launchMolecule(ContextClock, emitter = { value = it }) { 128 | if (count == 1) { 129 | throw runtimeException 130 | } 131 | count 132 | } 133 | 134 | assertThat(value).isEqualTo(0) 135 | 136 | count++ 137 | runCurrent() 138 | clock.sendFrame(0) 139 | runCurrent() 140 | assertThat(exceptionHandler.exceptions.single()).isSameInstanceAs(runtimeException) 141 | 142 | // Verify `runRecomposeAndApplyChanges` is no longer active. 143 | assertThat(job.isCompleted).isTrue() 144 | } 145 | 146 | @Test fun errorInEffect() = runTest { 147 | val job = Job() 148 | val clock = BroadcastFrameClock() 149 | val exceptionHandler = RecordingExceptionHandler() 150 | val scope = CoroutineScope(coroutineContext + job + clock + exceptionHandler) 151 | var value: Int? = null 152 | 153 | // Use a custom subtype to prevent coroutines from breaking referential equality. 154 | val runtimeException = object : RuntimeException() {} 155 | scope.launchMolecule(ContextClock, emitter = { value = it }) { 156 | LaunchedEffect(Unit) { 157 | delay(50) 158 | throw runtimeException 159 | } 160 | 0 161 | } 162 | 163 | assertThat(value).isEqualTo(0) 164 | 165 | advanceTimeBy(50) 166 | runCurrent() 167 | clock.sendFrame(0) 168 | assertThat(exceptionHandler.exceptions.single()).isSameInstanceAs(runtimeException) 169 | 170 | // Verify `runRecomposeAndApplyChanges` is no longer active. 171 | assertThat(job.isCompleted).isTrue() 172 | } 173 | 174 | @Test fun errorInEmitterImmediately() = runTest { 175 | val job = Job() 176 | val clock = BroadcastFrameClock() 177 | val scope = CoroutineScope(coroutineContext + job + clock) 178 | 179 | // Use a custom subtype to prevent coroutines from breaking referential equality. 180 | val runtimeException = object : RuntimeException() {} 181 | assertFailure { 182 | scope.launchMolecule(ContextClock, emitter = { throw runtimeException }) { 183 | 0 184 | } 185 | }.isSameInstanceAs(runtimeException) 186 | 187 | // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. 188 | job.cancelAndJoin() 189 | } 190 | 191 | @Test fun errorInEmitterDelayed() = runTest { 192 | val job = Job() 193 | val clock = BroadcastFrameClock() 194 | val exceptionHandler = RecordingExceptionHandler() 195 | val scope = CoroutineScope(coroutineContext + job + clock + exceptionHandler) 196 | var value: Int? = null 197 | 198 | // Use a custom subtype to prevent coroutines from breaking referential equality. 199 | val runtimeException = object : RuntimeException() {} 200 | var count by mutableIntStateOf(0) 201 | scope.launchMolecule( 202 | ContextClock, 203 | emitter = { 204 | if (it == 1) { 205 | throw runtimeException 206 | } 207 | value = it 208 | }, 209 | ) { 210 | count 211 | } 212 | 213 | assertThat(value).isEqualTo(0) 214 | 215 | count++ 216 | runCurrent() 217 | clock.sendFrame(0) 218 | runCurrent() 219 | assertThat(exceptionHandler.exceptions.single()).isSameInstanceAs(runtimeException) 220 | 221 | // Verify `runRecomposeAndApplyChanges` is no longer active. 222 | assertThat(job.isCompleted).isTrue() 223 | } 224 | 225 | enum class DisposableEffectState { NOT_LAUNCHED, LAUNCHED, DISPOSED } 226 | 227 | @Test fun disposableEffectDisposesWhenScopeIsCancelled() = runTest { 228 | val job = Job() 229 | val clock = BroadcastFrameClock() 230 | val scope = CoroutineScope(coroutineContext + job + clock) 231 | 232 | var state: DisposableEffectState = NOT_LAUNCHED 233 | 234 | scope.launchMolecule(ContextClock) { 235 | DisposableEffect(Unit) { 236 | state = LAUNCHED 237 | 238 | onDispose { 239 | state = DISPOSED 240 | } 241 | } 242 | } 243 | 244 | assertThat(state).isEqualTo(LAUNCHED) 245 | 246 | job.cancelAndJoin() 247 | assertThat(state).isEqualTo(DISPOSED) 248 | } 249 | 250 | @Test 251 | fun itemsImmediate() = runTest { 252 | val values = Channel() 253 | 254 | val job = launch { 255 | moleculeFlow(mode = Immediate) { 256 | var count by remember { mutableIntStateOf(0) } 257 | LaunchedEffect(Unit) { 258 | while (true) { 259 | delay(100) 260 | count++ 261 | } 262 | } 263 | 264 | count 265 | }.collect { values.send(it) } 266 | } 267 | 268 | var value = values.awaitValue() 269 | assertThat(value).isEqualTo(0) 270 | 271 | advanceTimeBy(100) 272 | value = values.awaitValue() 273 | assertThat(value).isEqualTo(1) 274 | 275 | advanceTimeBy(100) 276 | value = values.awaitValue() 277 | assertThat(value).isEqualTo(2) 278 | 279 | advanceTimeBy(300) 280 | 281 | value = values.awaitValue() 282 | assertThat(value).isEqualTo(3) 283 | value = values.awaitValue() 284 | assertThat(value).isEqualTo(5) 285 | 286 | job.cancelAndJoin() 287 | } 288 | 289 | @Test 290 | fun errorsImmediate() = runTest { 291 | // Use a custom subtype to prevent coroutines from breaking referential equality. 292 | val runtimeException = object : RuntimeException() {} 293 | assertFailure { 294 | moleculeFlow(mode = Immediate) { 295 | throw runtimeException 296 | }.collect() 297 | }.isSameInstanceAs(runtimeException) 298 | } 299 | 300 | @Test 301 | fun errorDelayedImmediate() = runTest { 302 | val values = Channel(Channel.UNLIMITED) 303 | 304 | // Use a custom subtype to prevent coroutines from breaking referential equality. 305 | val runtimeException = object : RuntimeException() {} 306 | var count by mutableIntStateOf(0) 307 | val job = launch { 308 | val exception = runCatching { 309 | moleculeFlow(mode = Immediate) { 310 | if (count == 1) { 311 | throw runtimeException 312 | } 313 | count 314 | }.collect { 315 | values.send(it) 316 | } 317 | }.exceptionOrNull() 318 | assertThat(runtimeException).isSameInstanceAs(exception) 319 | } 320 | 321 | assertThat(values.awaitValue()).isEqualTo(0) 322 | 323 | count++ 324 | runCurrent() 325 | // Verify `runRecomposeAndApplyChanges` is no longer active. 326 | assertThat(job.isCompleted).isTrue() 327 | } 328 | 329 | @Test 330 | fun errorInEffectImmediate() = runTest { 331 | val values = Channel(Channel.UNLIMITED) 332 | 333 | // Use a custom subtype to prevent coroutines from breaking referential equality. 334 | val runtimeException = object : RuntimeException() {} 335 | val job = launch { 336 | val exception = runCatching { 337 | moleculeFlow(mode = Immediate) { 338 | LaunchedEffect(Unit) { 339 | delay(50) 340 | throw runtimeException 341 | } 342 | 0 343 | }.collect { 344 | values.send(it) 345 | } 346 | }.exceptionOrNull() 347 | assertThat(runtimeException).isSameInstanceAs(exception) 348 | } 349 | 350 | assertThat(values.awaitValue()).isEqualTo(0) 351 | 352 | advanceTimeBy(50) 353 | runCurrent() 354 | // Verify `runRecomposeAndApplyChanges` is no longer active. 355 | assertThat(job.isCompleted).isTrue() 356 | } 357 | 358 | @Test 359 | fun disposableEffectDisposesWhenScopeIsCancelledImmediate() = runTest { 360 | val values = Channel(Channel.UNLIMITED) 361 | 362 | var state: DisposableEffectState = NOT_LAUNCHED 363 | 364 | val job = launch { 365 | moleculeFlow(mode = Immediate) { 366 | DisposableEffect(Unit) { 367 | state = LAUNCHED 368 | 369 | onDispose { 370 | state = DISPOSED 371 | } 372 | } 373 | 0 374 | }.collect { 375 | values.send(it) 376 | } 377 | } 378 | 379 | assertThat(values.awaitValue()).isEqualTo(0) 380 | 381 | job.cancelAndJoin() 382 | 383 | assertThat(state).isEqualTo(DISPOSED) 384 | } 385 | 386 | @Test fun coroutineContextUsed() = runTest { 387 | val job = Job() 388 | val expectedName = CoroutineName("test_key") 389 | 390 | var actualName: CoroutineName? = null 391 | backgroundScope.launchMolecule(Immediate, job + expectedName) { 392 | actualName = rememberCoroutineScope().coroutineContext[CoroutineName] 393 | } 394 | assertThat(actualName).isEqualTo(expectedName) 395 | job.cancelAndJoin() 396 | } 397 | 398 | @Test fun coroutineContextClockDoesNotOverrideImmediate() = runTest { 399 | val job = Job() 400 | val myClock = BroadcastFrameClock() 401 | 402 | var actualClock: MonotonicFrameClock? = null 403 | backgroundScope.launchMolecule(Immediate, job + myClock) { 404 | actualClock = rememberCoroutineScope().coroutineContext[MonotonicFrameClock] 405 | } 406 | assertThat(actualClock).isNotSameInstanceAs(myClock) 407 | job.cancelAndJoin() 408 | } 409 | 410 | @Test fun snapshotNotifierExternal() = runTest { 411 | val job = Job() 412 | val clock = BroadcastFrameClock() 413 | val scope = CoroutineScope(coroutineContext + job + clock) 414 | var value: Int? = null 415 | 416 | var count by mutableIntStateOf(0) 417 | 418 | scope.launchMolecule(ContextClock, emitter = { value = it }, snapshotNotifier = External) { 419 | count 420 | } 421 | 422 | assertThat(value).isEqualTo(0) 423 | 424 | // Ensure the composition is not automatically notified of state mutation. 425 | count++ 426 | runCurrent() 427 | clock.sendFrame(0) 428 | assertThat(value).isEqualTo(0) 429 | 430 | // But we reflect it once someone does the notification. 431 | Snapshot.sendApplyNotifications() 432 | runCurrent() 433 | clock.sendFrame(0) 434 | assertThat(value).isEqualTo(1) 435 | 436 | job.cancelAndJoin() 437 | } 438 | 439 | @Test fun snapshotNotifierWhileActive() = runTest { 440 | val job = Job() 441 | val clock = BroadcastFrameClock() 442 | val scope = CoroutineScope(coroutineContext + job + clock) 443 | var value: Int? = null 444 | 445 | var count by mutableIntStateOf(0) 446 | 447 | scope.launchMolecule(ContextClock, emitter = { value = it }, snapshotNotifier = WhileActive) { 448 | count 449 | } 450 | 451 | assertThat(value).isEqualTo(0) 452 | 453 | // The composition is automatically notified of state mutation. 454 | count++ 455 | runCurrent() 456 | clock.sendFrame(0) 457 | assertThat(value).isEqualTo(1) 458 | 459 | job.cancelAndJoin() 460 | } 461 | 462 | @Test fun defaultSnapshotNotifierChangeDetector() { 463 | assertThat(defaultSnapshotNotifier()).isEqualTo(WhileActive) 464 | } 465 | 466 | private suspend fun Channel.awaitValue(): T = withTimeout(1000) { receive() } 467 | } 468 | -------------------------------------------------------------------------------- /molecule-runtime/src/commonTest/kotlin/app/cash/molecule/RecordingExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import kotlin.coroutines.CoroutineContext 19 | import kotlinx.coroutines.CoroutineExceptionHandler 20 | 21 | class RecordingExceptionHandler : CoroutineExceptionHandler { 22 | val exceptions = mutableListOf() 23 | 24 | override fun handleException(context: CoroutineContext, exception: Throwable) { 25 | exceptions += exception 26 | } 27 | 28 | override val key get() = CoroutineExceptionHandler 29 | } 30 | -------------------------------------------------------------------------------- /molecule-runtime/src/darwinMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import kotlinx.cinterop.convert 19 | import platform.posix.CLOCK_MONOTONIC_RAW 20 | import platform.posix.clock_gettime_nsec_np 21 | 22 | internal actual inline fun nanoTime(): Long = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW.toUInt()).convert() 23 | -------------------------------------------------------------------------------- /molecule-runtime/src/displayLinkMain/kotlin/app/cash/molecule/DisplayLinkClock.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.MonotonicFrameClock 19 | 20 | public expect object DisplayLinkClock : MonotonicFrameClock { 21 | override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R 22 | } 23 | -------------------------------------------------------------------------------- /molecule-runtime/src/displayLinkTest/kotlin/app/cash/molecule/DisplayLinkClockTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import assertk.all 19 | import assertk.assertThat 20 | import assertk.assertions.isLessThan 21 | import assertk.assertions.isPositive 22 | import kotlin.experimental.ExperimentalNativeApi 23 | import kotlin.test.Test 24 | import kotlinx.coroutines.test.runTest 25 | 26 | @OptIn(ExperimentalNativeApi::class) 27 | class DisplayLinkClockTest { 28 | @Test fun ticksWithTime() = runTest { 29 | if (Platform.osFamily == OsFamily.IOS || Platform.osFamily == OsFamily.TVOS) { 30 | // TODO Link against XCTest in order to get frame pulses on iOS and tvOS. 31 | return@runTest 32 | } 33 | 34 | val frameTimeA = DisplayLinkClock.withFrameNanos { it } 35 | val frameTimeB = DisplayLinkClock.withFrameNanos { it } 36 | assertThat(frameTimeA).all { 37 | isPositive() 38 | isLessThan(frameTimeB) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /molecule-runtime/src/javaMain/kotlin/app/cash/molecule/platform.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | internal actual fun defaultSnapshotNotifier(): SnapshotNotifier { 19 | return System.getProperty("app.cash.molecule.snapshotNotifier") 20 | ?.let { property -> 21 | runCatching { SnapshotNotifier.valueOf(property) }.getOrNull() 22 | } 23 | ?: SnapshotNotifier.WhileActive 24 | } 25 | -------------------------------------------------------------------------------- /molecule-runtime/src/javaTest/kotlin/app/cash/molecule/DefaultSnapshotNotifierPropertyTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import app.cash.molecule.SnapshotNotifier.External 19 | import app.cash.molecule.SnapshotNotifier.WhileActive 20 | import assertk.assertThat 21 | import assertk.assertions.isEqualTo 22 | import kotlin.test.Test 23 | 24 | // Note: We do not share this constant with the production code to verify its value doesn't change. 25 | private const val property = "app.cash.molecule.snapshotNotifier" 26 | 27 | class DefaultSnapshotNotifierPropertyTest { 28 | @Test fun propertyEmpty() { 29 | System.setProperty(property, "") 30 | try { 31 | assertThat(defaultSnapshotNotifier()).isEqualTo(WhileActive) 32 | } finally { 33 | System.clearProperty(property) 34 | } 35 | } 36 | 37 | @Test fun propertyInvalid() { 38 | System.setProperty(property, "sup") 39 | try { 40 | assertThat(defaultSnapshotNotifier()).isEqualTo(WhileActive) 41 | } finally { 42 | System.clearProperty(property) 43 | } 44 | } 45 | 46 | @Test fun propertyValid() { 47 | System.setProperty(property, "External") 48 | try { 49 | assertThat(defaultSnapshotNotifier()).isEqualTo(External) 50 | } finally { 51 | System.clearProperty(property) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /molecule-runtime/src/jsTest/kotlin/app/cash/molecule/WindowAnimationFrameClockTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import assertk.all 19 | import assertk.assertThat 20 | import assertk.assertions.isLessThan 21 | import assertk.assertions.isPositive 22 | import kotlin.test.Test 23 | import kotlinx.coroutines.test.runTest 24 | 25 | class WindowAnimationFrameClockTest { 26 | @Test fun ticksWithTime() = runTest { 27 | val frameTimeA = WindowAnimationFrameClock.withFrameNanos { it } 28 | val frameTimeB = WindowAnimationFrameClock.withFrameNanos { it } 29 | assertThat(frameTimeA).all { 30 | isPositive() 31 | isLessThan(frameTimeB) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /molecule-runtime/src/jvmMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | @Suppress("NOTHING_TO_INLINE") 19 | internal actual inline fun nanoTime(): Long = System.nanoTime() 20 | -------------------------------------------------------------------------------- /molecule-runtime/src/jvmTest/kotlin/app/cash/molecule/MoleculeConcurrentTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.SideEffect 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableIntStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import app.cash.molecule.RecompositionMode.Immediate 24 | import assertk.assertThat 25 | import assertk.assertions.isNotSameInstanceAs 26 | import assertk.assertions.isSameInstanceAs 27 | import kotlin.test.Test 28 | import kotlinx.coroutines.CompletableDeferred 29 | import kotlinx.coroutines.Dispatchers 30 | import kotlinx.coroutines.Job 31 | import kotlinx.coroutines.cancelAndJoin 32 | import kotlinx.coroutines.test.runTest 33 | 34 | // These tests are JVM only because they look at the current thread ID. It could be supported on 35 | // all platforms with threads, but all the code is common, so this just gets us coverage quickly. 36 | class MoleculeConcurrentTest { 37 | @Test fun coroutineContextHonoredByImmediateClock() = runTest { 38 | val testThread = Thread.currentThread() 39 | var firstThread: Thread? = null 40 | var secondThread: Thread? = null 41 | 42 | val job = Job() 43 | val cancelLatch = CompletableDeferred() 44 | backgroundScope.launchMolecule(Immediate, job + Dispatchers.Default) { 45 | var count by remember { mutableIntStateOf(0) } 46 | when (count) { 47 | 0 -> firstThread = Thread.currentThread() 48 | 1 -> secondThread = Thread.currentThread() 49 | } 50 | if (count == 1) { 51 | cancelLatch.complete(Unit) 52 | } 53 | SideEffect { 54 | count++ 55 | } 56 | } 57 | cancelLatch.await() 58 | 59 | assertThat(firstThread).isSameInstanceAs(testThread) 60 | assertThat(secondThread).isNotSameInstanceAs(testThread) 61 | 62 | job.cancelAndJoin() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /molecule-runtime/src/linuxMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import kotlinx.cinterop.alloc 19 | import kotlinx.cinterop.memScoped 20 | import kotlinx.cinterop.ptr 21 | import platform.posix.CLOCK_MONOTONIC_RAW 22 | import platform.posix.clock_gettime 23 | import platform.posix.timespec 24 | 25 | internal actual inline fun nanoTime(): Long = memScoped { 26 | val timespec = alloc() 27 | clock_gettime(CLOCK_MONOTONIC_RAW, timespec.ptr) 28 | timespec.tv_sec * 1_000_000L + timespec.tv_nsec 29 | } 30 | -------------------------------------------------------------------------------- /molecule-runtime/src/macosMain/kotlin/app/cash/molecule/DisplayLinkClock.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.BroadcastFrameClock 19 | import androidx.compose.runtime.MonotonicFrameClock 20 | import kotlinx.cinterop.COpaquePointer 21 | import kotlinx.cinterop.CPointer 22 | import kotlinx.cinterop.StableRef 23 | import kotlinx.cinterop.alloc 24 | import kotlinx.cinterop.asStableRef 25 | import kotlinx.cinterop.nativeHeap 26 | import kotlinx.cinterop.ptr 27 | import kotlinx.cinterop.staticCFunction 28 | import kotlinx.cinterop.value 29 | import platform.CoreVideo.CVDisplayLinkCreateWithActiveCGDisplays 30 | import platform.CoreVideo.CVDisplayLinkRef 31 | import platform.CoreVideo.CVDisplayLinkRefVar 32 | import platform.CoreVideo.CVDisplayLinkSetOutputCallback 33 | import platform.CoreVideo.CVDisplayLinkStart 34 | import platform.CoreVideo.CVDisplayLinkStop 35 | import platform.CoreVideo.CVOptionFlags 36 | import platform.CoreVideo.CVOptionFlagsVar 37 | import platform.CoreVideo.CVTimeStamp 38 | import platform.CoreVideo.kCVReturnSuccess 39 | 40 | public actual object DisplayLinkClock : MonotonicFrameClock { 41 | 42 | private val clock = BroadcastFrameClock { 43 | // One or more awaiters have appeared. Start the DisplayLink clock callback so that awaiters 44 | // get dispatched on the next available frame. 45 | checkDisplayLink(CVDisplayLinkStart(displayLink.value)) 46 | } 47 | private val clockPtr = StableRef.create(clock) 48 | 49 | // We alloc directly to nativeHeap because this singleton object lives for the duration of the 50 | // process. We don't care about cleanup and therefore never free this. 51 | private val displayLink = nativeHeap.alloc() 52 | 53 | init { 54 | checkDisplayLink(CVDisplayLinkCreateWithActiveCGDisplays(displayLink.ptr)) 55 | checkDisplayLink( 56 | CVDisplayLinkSetOutputCallback( 57 | displayLink.value, 58 | staticCFunction(::callback), 59 | clockPtr.asCPointer(), 60 | ), 61 | ) 62 | } 63 | 64 | actual override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { 65 | return clock.withFrameNanos(onFrame) 66 | } 67 | 68 | private fun checkDisplayLink(code: Int) { 69 | check(code == kCVReturnSuccess) { "Could not initialize CVDisplayLink. Error code $code." } 70 | } 71 | } 72 | 73 | @Suppress("UNUSED_PARAMETER") // Signature required by CVDisplayLinkSetOutputCallback. 74 | private fun callback( 75 | displayLink: CVDisplayLinkRef?, 76 | ignored1: CPointer?, 77 | ignored2: CPointer?, 78 | ignored3: CVOptionFlags, 79 | ignored4: CPointer?, 80 | userInfo: COpaquePointer?, 81 | ): Int { 82 | val clock = userInfo!!.asStableRef().get() 83 | clock.sendFrame(nanoTime()) 84 | 85 | // A frame was delivered. Stop the DisplayLink callback. It will get started again 86 | // when new frame awaiters appear. 87 | return CVDisplayLinkStop(displayLink) 88 | } 89 | -------------------------------------------------------------------------------- /molecule-runtime/src/mingwMain/kotlin/app/cash/molecule/timeSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import kotlinx.cinterop.alloc 19 | import kotlinx.cinterop.memScoped 20 | import kotlinx.cinterop.ptr 21 | import platform.posix.CLOCK_MONOTONIC 22 | import platform.posix.clock_gettime 23 | import platform.posix.timespec 24 | 25 | internal actual inline fun nanoTime(): Long = memScoped { 26 | val timespec = alloc() 27 | clock_gettime(CLOCK_MONOTONIC, timespec.ptr) 28 | timespec.tv_sec * 1_000_000L + timespec.tv_nsec 29 | } 30 | -------------------------------------------------------------------------------- /molecule-runtime/src/nonJavaMain/kotlin/app/cash/molecule/platform.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | @Suppress("NOTHING_TO_INLINE") 19 | internal actual inline fun defaultSnapshotNotifier(): SnapshotNotifier { 20 | return SnapshotNotifier.WhileActive 21 | } 22 | -------------------------------------------------------------------------------- /molecule-runtime/src/quartzCoreMain/kotlin/app/cash/molecule/DisplayLinkClock.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.molecule 17 | 18 | import androidx.compose.runtime.BroadcastFrameClock 19 | import androidx.compose.runtime.MonotonicFrameClock 20 | import kotlinx.cinterop.ObjCAction 21 | import platform.Foundation.NSRunLoop 22 | import platform.Foundation.NSSelectorFromString 23 | import platform.QuartzCore.CADisplayLink 24 | import platform.darwin.NSObject 25 | 26 | public actual object DisplayLinkClock : MonotonicFrameClock { 27 | 28 | private val target = SelectorTarget(this) 29 | private val displayLink = CADisplayLink.displayLinkWithTarget( 30 | target = target, 31 | selector = NSSelectorFromString(SelectorTarget::tickClock.name), 32 | ) 33 | 34 | private val clock = BroadcastFrameClock { 35 | // We only want to listen to the DisplayLink run loop if we have frame awaiters. 36 | displayLink.addToRunLoop(NSRunLoop.currentRunLoop, NSRunLoop.currentRunLoop.currentMode) 37 | } 38 | 39 | actual override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { 40 | return clock.withFrameNanos(onFrame) 41 | } 42 | 43 | private fun tickClock() { 44 | clock.sendFrame(nanoTime()) 45 | 46 | // Detach from the run loop. We will re-attach if new frame awaiters appear. 47 | displayLink.removeFromRunLoop(NSRunLoop.currentRunLoop, NSRunLoop.currentRunLoop.currentMode) 48 | } 49 | 50 | /** 51 | * Selectors can only target subtypes of [NSObject] which is why this helper exists. 52 | * We cannot subclass it on [DisplayLinkClock] directly because it implements a Kotlin 53 | * interface, but we also don't want to leak it into public API. The contained function 54 | * must be public for the selector to work. 55 | */ 56 | private class SelectorTarget(private val target: DisplayLinkClock) : NSObject() { 57 | @ObjCAction 58 | fun tickClock() { 59 | target.tickClock() 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /sample-viewmodel/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'org.jetbrains.kotlin.android' 3 | apply plugin: 'org.jetbrains.kotlin.plugin.compose' 4 | apply plugin: 'org.jetbrains.kotlin.plugin.serialization' 5 | 6 | dependencies { 7 | implementation projects.moleculeRuntime 8 | implementation libs.androidx.activity.compose 9 | implementation libs.androidx.compose.material3 10 | implementation libs.coil.compose 11 | implementation libs.squareup.retrofit.client 12 | implementation libs.squareup.retrofit.converter.kotlinx.serialization 13 | implementation libs.kotlinx.serialization 14 | 15 | testImplementation libs.junit 16 | testImplementation libs.kotlinx.coroutines.test 17 | testImplementation libs.turbine 18 | testImplementation libs.assertk 19 | } 20 | 21 | android { 22 | namespace 'com.example.molecule.viewmodel' 23 | 24 | testOptions { 25 | unitTests.returnDefaultValues = true 26 | } 27 | 28 | variantFilter { variant -> 29 | if (variant.buildType.name == 'release') { 30 | setIgnore(true) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample-viewmodel/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule.viewmodel 17 | 18 | import android.app.Activity 19 | import android.graphics.Color 20 | import android.os.Bundle 21 | import androidx.activity.ComponentActivity 22 | import androidx.activity.compose.setContent 23 | import androidx.activity.viewModels 24 | import androidx.compose.foundation.isSystemInDarkTheme 25 | import androidx.compose.material3.MaterialTheme 26 | import androidx.compose.material3.darkColorScheme 27 | import androidx.compose.material3.lightColorScheme 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.runtime.SideEffect 30 | import androidx.compose.runtime.collectAsState 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.ui.platform.LocalView 33 | import androidx.core.view.WindowCompat 34 | 35 | class MainActivity : ComponentActivity() { 36 | override fun onCreate(savedInstanceState: Bundle?) { 37 | super.onCreate(savedInstanceState) 38 | WindowCompat.setDecorFitsSystemWindows(window, false) 39 | val viewModel by viewModels() 40 | setContent { 41 | RootContainer { 42 | val model by viewModel.models.collectAsState() 43 | PupperPicsScreen(model, onEvent = { event -> viewModel.take(event) }) 44 | } 45 | } 46 | } 47 | } 48 | 49 | @Composable 50 | private fun RootContainer(content: @Composable () -> Unit) { 51 | val view = LocalView.current 52 | val window = (view.context as Activity).window 53 | val inLightMode = !isSystemInDarkTheme() 54 | 55 | SideEffect { 56 | window.statusBarColor = Color.TRANSPARENT 57 | window.navigationBarColor = Color.TRANSPARENT 58 | val insetsController = WindowCompat.getInsetsController(window, view) 59 | insetsController.isAppearanceLightStatusBars = inLightMode 60 | insetsController.isAppearanceLightNavigationBars = inLightMode 61 | } 62 | 63 | MaterialTheme(colorScheme = if (inLightMode) lightColorScheme() else darkColorScheme()) { 64 | content() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule.viewmodel 17 | 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.platform.AndroidUiDispatcher 20 | import androidx.lifecycle.ViewModel 21 | import androidx.lifecycle.viewModelScope 22 | import app.cash.molecule.RecompositionMode.ContextClock 23 | import app.cash.molecule.launchMolecule 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.flow.Flow 26 | import kotlinx.coroutines.flow.MutableSharedFlow 27 | import kotlinx.coroutines.flow.StateFlow 28 | 29 | abstract class MoleculeViewModel : ViewModel() { 30 | private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) 31 | 32 | // Events have a capacity large enough to handle simultaneous UI events, but 33 | // small enough to surface issues if they get backed up for some reason. 34 | private val events = MutableSharedFlow(extraBufferCapacity = 20) 35 | 36 | val models: StateFlow by lazy(LazyThreadSafetyMode.NONE) { 37 | scope.launchMolecule(mode = ContextClock) { 38 | models(events) 39 | } 40 | } 41 | 42 | fun take(event: Event) { 43 | if (!events.tryEmit(event)) { 44 | error("Event buffer overflow.") 45 | } 46 | } 47 | 48 | @Composable 49 | protected abstract fun models(events: Flow): Model 50 | } 51 | -------------------------------------------------------------------------------- /sample-viewmodel/src/main/java/com/example/molecule/viewmodel/data.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule.viewmodel 17 | 18 | import kotlinx.serialization.Serializable 19 | import kotlinx.serialization.json.Json 20 | import okhttp3.MediaType.Companion.toMediaType 21 | import retrofit2.Retrofit 22 | import retrofit2.converter.kotlinx.serialization.asConverterFactory 23 | import retrofit2.create 24 | import retrofit2.http.GET 25 | import retrofit2.http.Path 26 | 27 | interface PupperPicsService { 28 | suspend fun listBreeds(): List 29 | suspend fun randomImageUrlFor(breed: String): String 30 | } 31 | 32 | fun PupperPicsService(): PupperPicsService { 33 | val json = Json { 34 | ignoreUnknownKeys = true 35 | } 36 | val api = Retrofit.Builder() 37 | .baseUrl("https://dog.ceo/api/") 38 | .addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType())) 39 | .build() 40 | .create() 41 | 42 | return object : PupperPicsService { 43 | override suspend fun listBreeds(): List { 44 | return api.listBreeds().message.flatMap { (breed, subBreeds) -> 45 | if (subBreeds.isEmpty()) { 46 | listOf(breed) 47 | } else { 48 | subBreeds.map { subBreed -> "$breed/$subBreed" } 49 | } 50 | } 51 | } 52 | 53 | override suspend fun randomImageUrlFor(breed: String): String { 54 | return api.randomImageFor(breed).message 55 | } 56 | } 57 | } 58 | 59 | interface PupperPicsApi { 60 | @GET("breeds/list/all") 61 | suspend fun listBreeds(): ListResponse 62 | 63 | @GET("breed/{breed}/images/random") 64 | suspend fun randomImageFor(@Path("breed", encoded = true) breed: String): ImageResponse 65 | 66 | @Serializable 67 | data class ListResponse(val message: Map>) 68 | 69 | @Serializable 70 | data class ImageResponse(val message: String) 71 | } 72 | -------------------------------------------------------------------------------- /sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule.viewmodel 17 | 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableIntStateOf 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.setValue 25 | import kotlinx.coroutines.flow.Flow 26 | 27 | sealed interface Event { 28 | data class SelectBreed(val breed: String) : Event 29 | data object FetchAgain : Event 30 | } 31 | 32 | data class Model( 33 | val loading: Boolean, 34 | val breeds: List, 35 | val dropdownText: String, 36 | val currentUrl: String?, 37 | ) 38 | 39 | class PupperPicsViewModel : MoleculeViewModel() { 40 | @Composable 41 | override fun models(events: Flow): Model { 42 | return pupperPicsPresenter(events, PupperPicsService()) 43 | } 44 | } 45 | 46 | @Composable 47 | fun pupperPicsPresenter(events: Flow, service: PupperPicsService): Model { 48 | var breeds: List by remember { mutableStateOf(emptyList()) } 49 | var currentBreed: String? by remember { mutableStateOf(null) } 50 | var currentUrl: String? by remember { mutableStateOf(null) } 51 | var fetchId: Int by remember { mutableIntStateOf(0) } 52 | 53 | // Grab the list of breeds and sets the current selection to the first in the list. 54 | // Errors are ignored in this sample. 55 | LaunchedEffect(Unit) { 56 | breeds = service.listBreeds() 57 | currentBreed = breeds.first() 58 | } 59 | 60 | // Load a random URL for the current breed whenever it changes, or the fetchId changes. 61 | LaunchedEffect(currentBreed, fetchId) { 62 | currentUrl = null 63 | currentUrl = currentBreed?.let { service.randomImageUrlFor(it) } 64 | } 65 | 66 | // Handle UI events. 67 | LaunchedEffect(Unit) { 68 | events.collect { event -> 69 | when (event) { 70 | is Event.SelectBreed -> currentBreed = event.breed 71 | Event.FetchAgain -> fetchId++ // Incrementing fetchId will load another random image URL. 72 | } 73 | } 74 | } 75 | 76 | return Model( 77 | loading = currentBreed == null, 78 | breeds = breeds, 79 | dropdownText = currentBreed ?: "Select breed", 80 | currentUrl = currentUrl, 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /sample-viewmodel/src/main/java/com/example/molecule/viewmodel/ui.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule.viewmodel 17 | 18 | import androidx.activity.compose.BackHandler 19 | import androidx.compose.animation.AnimatedVisibility 20 | import androidx.compose.animation.animateContentSize 21 | import androidx.compose.animation.core.animateFloat 22 | import androidx.compose.animation.core.animateFloatAsState 23 | import androidx.compose.animation.core.infiniteRepeatable 24 | import androidx.compose.animation.core.rememberInfiniteTransition 25 | import androidx.compose.animation.core.tween 26 | import androidx.compose.foundation.background 27 | import androidx.compose.foundation.clickable 28 | import androidx.compose.foundation.layout.Arrangement 29 | import androidx.compose.foundation.layout.Box 30 | import androidx.compose.foundation.layout.Column 31 | import androidx.compose.foundation.layout.Row 32 | import androidx.compose.foundation.layout.Spacer 33 | import androidx.compose.foundation.layout.WindowInsets 34 | import androidx.compose.foundation.layout.asPaddingValues 35 | import androidx.compose.foundation.layout.fillMaxSize 36 | import androidx.compose.foundation.layout.fillMaxWidth 37 | import androidx.compose.foundation.layout.height 38 | import androidx.compose.foundation.layout.heightIn 39 | import androidx.compose.foundation.layout.navigationBars 40 | import androidx.compose.foundation.layout.navigationBarsPadding 41 | import androidx.compose.foundation.layout.padding 42 | import androidx.compose.foundation.layout.size 43 | import androidx.compose.foundation.layout.statusBarsPadding 44 | import androidx.compose.foundation.lazy.LazyColumn 45 | import androidx.compose.foundation.lazy.items 46 | import androidx.compose.material.icons.Icons 47 | import androidx.compose.material.icons.rounded.ArrowDropDown 48 | import androidx.compose.material3.Button 49 | import androidx.compose.material3.CardDefaults 50 | import androidx.compose.material3.Icon 51 | import androidx.compose.material3.MaterialTheme 52 | import androidx.compose.material3.OutlinedCard 53 | import androidx.compose.material3.Surface 54 | import androidx.compose.material3.Text 55 | import androidx.compose.runtime.Composable 56 | import androidx.compose.runtime.getValue 57 | import androidx.compose.runtime.mutableStateOf 58 | import androidx.compose.runtime.remember 59 | import androidx.compose.runtime.setValue 60 | import androidx.compose.ui.Alignment 61 | import androidx.compose.ui.Modifier 62 | import androidx.compose.ui.draw.rotate 63 | import androidx.compose.ui.unit.dp 64 | import coil.compose.AsyncImage 65 | import coil.compose.AsyncImagePainter 66 | 67 | @Composable 68 | fun PupperPicsScreen(model: Model, onEvent: (Event) -> Unit, modifier: Modifier = Modifier) { 69 | Box( 70 | modifier = modifier 71 | .fillMaxSize() 72 | .background(MaterialTheme.colorScheme.background), 73 | ) { 74 | Column(modifier = Modifier.fillMaxSize()) { 75 | // Add a space above the content that matches TopBarMinHeight. 76 | // This allows the top bar to expand _over_ the content without moving it. 77 | Spacer(modifier = Modifier.height(TopBarMinHeight).statusBarsPadding()) 78 | Content(model, modifier = Modifier.weight(1f)) 79 | AnimatedVisibility(visible = !model.loading) { BottomBar(onEvent) } 80 | } 81 | 82 | TopBar(model, onEvent) 83 | } 84 | } 85 | 86 | private val TopBarMinHeight = 152.dp 87 | 88 | @Composable 89 | private fun TopBar(model: Model, onEvent: (Event) -> Unit) { 90 | var dropdownExpanded by remember { mutableStateOf(false) } 91 | // Close dropdown on system back button. 92 | BackHandler(enabled = dropdownExpanded) { dropdownExpanded = false } 93 | 94 | Surface( 95 | color = MaterialTheme.colorScheme.surfaceVariant, 96 | modifier = Modifier.fillMaxWidth(), 97 | ) { 98 | Column( 99 | modifier = Modifier 100 | .fillMaxWidth() 101 | .heightIn(TopBarMinHeight) 102 | .animateContentSize() 103 | .statusBarsPadding() 104 | .padding(top = 16.dp), 105 | ) { 106 | Text( 107 | text = "Pupper Pics", 108 | style = MaterialTheme.typography.headlineMedium, 109 | modifier = Modifier.padding(horizontal = 16.dp), 110 | ) 111 | Spacer(modifier = Modifier.size(8.dp)) 112 | CurrentBreedSelection( 113 | text = model.dropdownText, 114 | enabled = !model.loading, 115 | expanded = dropdownExpanded, 116 | onClick = { dropdownExpanded = !dropdownExpanded }, 117 | ) 118 | if (dropdownExpanded) { 119 | BreedSelectionList(model) { breed -> 120 | dropdownExpanded = false 121 | onEvent(Event.SelectBreed(breed)) 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | @Composable 129 | private fun CurrentBreedSelection( 130 | text: String, 131 | enabled: Boolean, 132 | expanded: Boolean, 133 | onClick: () -> Unit, 134 | ) { 135 | val arrowRotation by animateFloatAsState(if (expanded) 180f else 0f) 136 | 137 | OutlinedCard( 138 | colors = CardDefaults.outlinedCardColors( 139 | containerColor = MaterialTheme.colorScheme.surfaceVariant, 140 | ), 141 | modifier = Modifier.padding(horizontal = 16.dp), 142 | ) { 143 | Row( 144 | verticalAlignment = Alignment.CenterVertically, 145 | horizontalArrangement = Arrangement.SpaceBetween, 146 | modifier = Modifier.fillMaxWidth() 147 | .heightIn(48.dp) 148 | .clickable(enabled = enabled, onClick = onClick) 149 | .padding(horizontal = 16.dp), 150 | ) { 151 | Text( 152 | text = text, 153 | style = MaterialTheme.typography.titleMedium, 154 | ) 155 | Icon( 156 | imageVector = Icons.Rounded.ArrowDropDown, 157 | contentDescription = null, 158 | modifier = Modifier.rotate(arrowRotation), 159 | ) 160 | } 161 | } 162 | } 163 | 164 | @Composable 165 | private fun BreedSelectionList(model: Model, onBreedClick: (String) -> Unit) { 166 | LazyColumn(contentPadding = WindowInsets.navigationBars.asPaddingValues()) { 167 | items(model.breeds) { breed -> 168 | Box( 169 | contentAlignment = Alignment.CenterStart, 170 | modifier = Modifier 171 | .fillMaxWidth() 172 | .heightIn(56.dp) 173 | .clickable { onBreedClick(breed) } 174 | .padding(horizontal = 32.dp), 175 | ) { 176 | Text( 177 | text = breed, 178 | style = MaterialTheme.typography.titleMedium, 179 | ) 180 | } 181 | } 182 | } 183 | } 184 | 185 | @Composable 186 | private fun Content(model: Model, modifier: Modifier = Modifier) { 187 | var imageLoading by remember { mutableStateOf(true) } 188 | 189 | Box(modifier) { 190 | AsyncImage( 191 | model.currentUrl, 192 | contentDescription = "A good dog", 193 | // Ignore errors in this sample. 194 | onState = { imageLoading = it !is AsyncImagePainter.State.Success }, 195 | modifier = Modifier.fillMaxSize(), 196 | ) 197 | if (model.loading || imageLoading) { 198 | Loading(Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.background)) 199 | } 200 | } 201 | } 202 | 203 | @Composable 204 | private fun Loading(modifier: Modifier = Modifier) { 205 | val rotation by rememberInfiniteTransition().animateFloat( 206 | initialValue = 0f, 207 | targetValue = 360f, 208 | animationSpec = infiniteRepeatable(tween(durationMillis = 1_000)), 209 | ) 210 | 211 | Row( 212 | verticalAlignment = Alignment.CenterVertically, 213 | horizontalArrangement = Arrangement.Center, 214 | modifier = modifier.fillMaxSize(), 215 | ) { 216 | Text( 217 | text = "🐶", 218 | style = MaterialTheme.typography.headlineLarge, 219 | modifier = Modifier.rotate(rotation), 220 | ) 221 | Spacer(modifier = Modifier.size(8.dp)) 222 | Text( 223 | text = "Fetching…", 224 | style = MaterialTheme.typography.headlineSmall, 225 | ) 226 | } 227 | } 228 | 229 | @Composable 230 | private fun BottomBar(onEvent: (Event) -> Unit) { 231 | Surface( 232 | color = MaterialTheme.colorScheme.surfaceVariant, 233 | modifier = Modifier.fillMaxWidth(), 234 | ) { 235 | Button( 236 | content = { 237 | Text("Fetch again!") 238 | }, 239 | onClick = { onEvent(Event.FetchAgain) }, 240 | modifier = Modifier 241 | .navigationBarsPadding() 242 | .padding(16.dp) 243 | .fillMaxWidth() 244 | .heightIn(48.dp), 245 | ) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule.viewmodel 17 | 18 | import app.cash.molecule.RecompositionMode 19 | import app.cash.molecule.moleculeFlow 20 | import app.cash.turbine.Turbine 21 | import app.cash.turbine.test 22 | import assertk.assertThat 23 | import assertk.assertions.isEqualTo 24 | import kotlinx.coroutines.channels.Channel 25 | import kotlinx.coroutines.flow.distinctUntilChanged 26 | import kotlinx.coroutines.flow.emptyFlow 27 | import kotlinx.coroutines.flow.receiveAsFlow 28 | import kotlinx.coroutines.runBlocking 29 | import org.junit.Assert.assertEquals 30 | import org.junit.Test 31 | 32 | class PupperPicsPresenterTest { 33 | @Test 34 | fun `on launch, breeds are loaded followed by an image url`() = runBlocking { 35 | val picsService = FakePicsService() 36 | moleculeFlow(mode = RecompositionMode.Immediate) { 37 | pupperPicsPresenter(emptyFlow(), picsService) 38 | }.distinctUntilChanged().test { 39 | assertEquals( 40 | Model( 41 | loading = true, 42 | breeds = emptyList(), 43 | dropdownText = "Select breed", 44 | currentUrl = null, 45 | ), 46 | awaitItem(), 47 | ) 48 | 49 | picsService.breeds.add(listOf("akita", "boxer", "corgi")) 50 | assertEquals( 51 | Model( 52 | loading = false, 53 | breeds = listOf("akita", "boxer", "corgi"), 54 | dropdownText = "akita", 55 | currentUrl = null, 56 | ), 57 | awaitItem(), 58 | ) 59 | 60 | // After breeds are loaded, the first item in the list should be used to fetch an image URL. 61 | assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita") 62 | 63 | picsService.urls.add("akita.jpg") 64 | assertEquals( 65 | Model( 66 | loading = false, 67 | breeds = listOf("akita", "boxer", "corgi"), 68 | dropdownText = "akita", 69 | currentUrl = "akita.jpg", 70 | ), 71 | awaitItem(), 72 | ) 73 | } 74 | } 75 | 76 | @Test 77 | fun `selecting breed updates dropdown text and fetches new image`() = runBlocking { 78 | val picsService = FakePicsService() 79 | val events = Channel() 80 | moleculeFlow(mode = RecompositionMode.Immediate) { 81 | pupperPicsPresenter(events.receiveAsFlow(), picsService) 82 | }.distinctUntilChanged().test { 83 | picsService.breeds.add(listOf("akita", "boxer", "corgi")) 84 | picsService.urls.add("akita.jpg") 85 | assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita") 86 | skipItems(3) // Fetching list, fetching fetching url, resolved model. 87 | 88 | events.send(Event.SelectBreed("boxer")) 89 | // Selecting a breed should see two emissions. One where the dropdown text changes, and 90 | // another where the URL is set to null. 91 | assertEquals( 92 | Model( 93 | loading = false, 94 | breeds = listOf("akita", "boxer", "corgi"), 95 | dropdownText = "boxer", 96 | currentUrl = "akita.jpg", 97 | ), 98 | awaitItem(), 99 | ) 100 | assertEquals( 101 | Model( 102 | loading = false, 103 | breeds = listOf("akita", "boxer", "corgi"), 104 | dropdownText = "boxer", 105 | currentUrl = null, 106 | ), 107 | awaitItem(), 108 | ) 109 | 110 | // We should then see a request for a boxer URL, followed by the model updating. 111 | assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("boxer") 112 | picsService.urls.add("boxer.jpg") 113 | assertEquals( 114 | Model( 115 | loading = false, 116 | breeds = listOf("akita", "boxer", "corgi"), 117 | dropdownText = "boxer", 118 | currentUrl = "boxer.jpg", 119 | ), 120 | awaitItem(), 121 | ) 122 | } 123 | } 124 | 125 | @Test 126 | fun `fetching again requests a new image`() = runBlocking { 127 | val picsService = FakePicsService() 128 | val events = Channel() 129 | moleculeFlow(mode = RecompositionMode.Immediate) { 130 | pupperPicsPresenter(events.receiveAsFlow(), picsService) 131 | }.distinctUntilChanged().test { 132 | picsService.breeds.add(listOf("akita", "boxer", "corgi")) 133 | assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita") 134 | picsService.urls.add("akita1.jpg") 135 | skipItems(3) // Fetching list, fetching fetching url, resolved model. 136 | 137 | events.send(Event.FetchAgain) 138 | assertEquals( 139 | Model( 140 | loading = false, 141 | breeds = listOf("akita", "boxer", "corgi"), 142 | dropdownText = "akita", 143 | currentUrl = null, 144 | ), 145 | awaitItem(), 146 | ) 147 | 148 | assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita") 149 | picsService.urls.add("akita2.jpg") 150 | assertEquals( 151 | Model( 152 | loading = false, 153 | breeds = listOf("akita", "boxer", "corgi"), 154 | dropdownText = "akita", 155 | currentUrl = "akita2.jpg", 156 | ), 157 | awaitItem(), 158 | ) 159 | } 160 | } 161 | 162 | private class FakePicsService : PupperPicsService { 163 | val breeds = Turbine>() 164 | val urls = Turbine() 165 | val urlRequestArgs = Turbine() 166 | 167 | override suspend fun listBreeds(): List { 168 | return breeds.awaitItem() 169 | } 170 | 171 | override suspend fun randomImageUrlFor(breed: String): String { 172 | urlRequestArgs.add(breed) 173 | return urls.awaitItem() 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'org.jetbrains.kotlin.android' 3 | apply plugin: 'org.jetbrains.kotlin.plugin.compose' 4 | 5 | dependencies { 6 | implementation projects.moleculeRuntime 7 | implementation libs.squareup.retrofit.client 8 | implementation libs.squareup.retrofit.converter.scalars 9 | implementation libs.squareup.okhttp.client 10 | implementation libs.squareup.okhttp.logging.interceptor 11 | 12 | testImplementation libs.junit 13 | testImplementation libs.kotlinx.coroutines.test 14 | testImplementation libs.turbine 15 | testImplementation libs.assertk 16 | } 17 | 18 | android { 19 | namespace 'com.example.molecule' 20 | 21 | buildFeatures { 22 | viewBinding true 23 | } 24 | 25 | testOptions { 26 | unitTests.returnDefaultValues = true 27 | } 28 | 29 | variantFilter { variant -> 30 | if (variant.buildType.name == 'release') { 31 | setIgnore(true) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /sample/src/main/java/com/example/molecule/CounterActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule 17 | 18 | import android.app.Activity 19 | import android.os.Bundle 20 | import android.util.Log 21 | import app.cash.molecule.AndroidUiDispatcher.Companion.Main 22 | import app.cash.molecule.RecompositionMode 23 | import app.cash.molecule.launchMolecule 24 | import com.example.molecule.databinding.CounterBinding 25 | import kotlinx.coroutines.CoroutineScope 26 | import kotlinx.coroutines.CoroutineStart.UNDISPATCHED 27 | import kotlinx.coroutines.cancel 28 | import kotlinx.coroutines.flow.onEach 29 | import kotlinx.coroutines.launch 30 | 31 | class CounterActivity : Activity() { 32 | private val scope = CoroutineScope(Main) 33 | 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | super.onCreate(savedInstanceState) 36 | 37 | val binding = CounterBinding.inflate(layoutInflater) 38 | setContentView(binding.root) 39 | 40 | val events = binding.events() 41 | .onEach { event -> 42 | Log.d("CounterEvent", event.toString()) 43 | } 44 | 45 | val randomService = RandomService() 46 | val models = scope.launchMolecule(mode = RecompositionMode.ContextClock) { 47 | counterPresenter(events, randomService) 48 | } 49 | 50 | scope.launch(start = UNDISPATCHED) { 51 | models.collect { model -> 52 | Log.d("CounterModel", model.toString()) 53 | binding.bind(model) 54 | } 55 | } 56 | } 57 | 58 | override fun onDestroy() { 59 | super.onDestroy() 60 | scope.cancel() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /sample/src/main/java/com/example/molecule/data.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule 17 | 18 | import android.util.Log 19 | import okhttp3.OkHttpClient 20 | import okhttp3.logging.HttpLoggingInterceptor 21 | import okhttp3.logging.HttpLoggingInterceptor.Level.BASIC 22 | import retrofit2.Retrofit.Builder 23 | import retrofit2.converter.scalars.ScalarsConverterFactory 24 | import retrofit2.create 25 | import retrofit2.http.GET 26 | import retrofit2.http.Query 27 | 28 | interface RandomService { 29 | suspend fun get(min: Int, max: Int): Int 30 | } 31 | 32 | private interface RandomApi { 33 | @GET("integers/?num=1&col=1&base=10&format=plain") 34 | suspend fun get( 35 | @Query("min") min: Int, 36 | @Query("max") max: Int, 37 | ): String 38 | } 39 | 40 | fun RandomService(): RandomService { 41 | val retrofit = Builder() 42 | .baseUrl("https://www.random.org/") 43 | .client( 44 | OkHttpClient.Builder() 45 | .addInterceptor( 46 | HttpLoggingInterceptor { Log.d("HTTP", it) } 47 | .also { it.level = BASIC }, 48 | ) 49 | .build(), 50 | ) 51 | .addConverterFactory(ScalarsConverterFactory.create()) 52 | .build() 53 | val api = retrofit.create() 54 | return object : RandomService { 55 | override suspend fun get(min: Int, max: Int): Int { 56 | return api.get(min, max).trim().toInt() 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /sample/src/main/java/com/example/molecule/presenter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule 17 | 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableIntStateOf 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.runtime.snapshots.Snapshot 26 | import kotlinx.coroutines.flow.Flow 27 | import kotlinx.coroutines.launch 28 | 29 | sealed interface CounterEvent 30 | data class Change(val delta: Int) : CounterEvent 31 | data object Randomize : CounterEvent 32 | 33 | data class CounterModel( 34 | val value: Int, 35 | val loading: Boolean, 36 | ) 37 | 38 | @Composable 39 | fun counterPresenter( 40 | events: Flow, 41 | randomService: RandomService, 42 | ): CounterModel { 43 | var count by remember { mutableIntStateOf(0) } 44 | var loading by remember { mutableStateOf(false) } 45 | 46 | LaunchedEffect(Unit) { 47 | events.collect { event -> 48 | when (event) { 49 | is Change -> { 50 | count += event.delta 51 | } 52 | 53 | Randomize -> { 54 | loading = true 55 | launch { 56 | // We want to observe these two state changes atomically. 57 | Snapshot.withMutableSnapshot { 58 | count = randomService.get(-20, 20) 59 | loading = false 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | return CounterModel(count, loading) 68 | } 69 | -------------------------------------------------------------------------------- /sample/src/main/java/com/example/molecule/view.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.example.molecule 17 | 18 | import com.example.molecule.databinding.CounterBinding 19 | import kotlinx.coroutines.channels.awaitClose 20 | import kotlinx.coroutines.flow.callbackFlow 21 | 22 | fun CounterBinding.events() = callbackFlow { 23 | decreaseTen.setOnClickListener { trySend(Change(-10)) } 24 | decreaseOne.setOnClickListener { trySend(Change(-1)) } 25 | increaseOne.setOnClickListener { trySend(Change(1)) } 26 | increaseTen.setOnClickListener { trySend(Change(10)) } 27 | randomize.setOnClickListener { trySend(Randomize) } 28 | 29 | awaitClose { } 30 | } 31 | 32 | fun CounterBinding.bind(model: CounterModel) { 33 | decreaseTen.isEnabled = !model.loading 34 | decreaseOne.isEnabled = !model.loading 35 | increaseOne.isEnabled = !model.loading 36 | increaseTen.isEnabled = !model.loading 37 | randomize.isEnabled = !model.loading 38 | 39 | count.text = model.value.toString() 40 | } 41 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/counter.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 14 |