├── .codecov.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── proposal.md ├── dependabot.yml ├── images │ ├── hero-light.svg │ ├── kotlin-foundation.png │ └── mobile-native-foundation.png └── workflows │ ├── add_issue_to_project.yml │ ├── ci.yml │ └── create_swift_package.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Images ├── friendly_robot.png ├── friendly_robot_icon.png ├── store-1.jpg ├── store-2.jpg ├── store-3.jpg ├── store-4.jpg └── store-5.jpg ├── LICENSE ├── README.md ├── RELEASING.md ├── build.gradle.kts ├── cache ├── README.md ├── api │ ├── android │ │ └── cache.api │ └── jvm │ │ └── cache.api ├── build.gradle.kts ├── config │ └── ktlint │ │ └── baseline.xml ├── gradle.properties └── src │ ├── androidMain │ └── AndroidManifest.xml │ ├── commonMain │ └── kotlin │ │ └── org │ │ └── mobilenativefoundation │ │ └── store │ │ └── cache5 │ │ ├── Cache.kt │ │ ├── CacheBuilder.kt │ │ ├── LocalCache.kt │ │ ├── MonotonicTicker.kt │ │ ├── RemovalCause.kt │ │ ├── StoreMultiCache.kt │ │ ├── StoreMultiCacheAccessor.kt │ │ ├── Ticker.kt │ │ └── Weigher.kt │ └── commonTest │ └── kotlin │ └── org │ └── mobilenativefoundation │ └── store │ └── cache5 │ └── CacheTests.kt ├── config └── ktlint │ └── baseline.xml ├── core ├── api │ ├── android │ │ └── core.api │ └── jvm │ │ └── core.api ├── build.gradle.kts ├── config │ └── ktlint │ │ └── baseline.xml ├── gradle.properties └── src │ ├── androidMain │ └── AndroidManifest.xml │ └── commonMain │ └── kotlin │ └── org │ └── mobilenativefoundation │ └── store │ └── core5 │ ├── ExperimentalStoreApi.kt │ ├── InsertionStrategy.kt │ ├── KeyProvider.kt │ ├── StoreData.kt │ └── StoreKey.kt ├── gradle.properties ├── gradle ├── jacoco.gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── multicast ├── api │ ├── android │ │ └── multicast.api │ └── jvm │ │ └── multicast.api ├── build.gradle.kts ├── config │ └── ktlint │ │ └── baseline.xml ├── gradle.properties └── src │ ├── androidMain │ └── AndroidManifest.xml │ ├── commonMain │ └── kotlin │ │ └── org │ │ └── mobilenativefoundation │ │ └── store │ │ └── multicast5 │ │ ├── Actor.kt │ │ ├── ChannelManager.kt │ │ ├── Multicaster.kt │ │ ├── SharedFlowProducer.kt │ │ └── StoreRealActor.kt │ └── commonTest │ └── kotlin │ └── org │ └── mobilenativefoundation │ └── store │ └── multicast5 │ └── StoreChannelManagerTests.kt ├── pull_request_template.md ├── renovate.json ├── rx2 ├── api │ └── rx2.api ├── build.gradle.kts ├── config │ └── ktlint │ │ └── baseline.xml ├── gradle.properties └── src │ ├── main │ ├── AndroidManifest.xml │ └── kotlin │ │ └── org │ │ └── mobilenativefoundation │ │ └── store │ │ └── rx2 │ │ ├── RxFetcher.kt │ │ ├── RxSourceOfTruth.kt │ │ ├── RxStore.kt │ │ └── RxStoreBuilder.kt │ └── test │ └── kotlin │ └── org │ └── mobilenativefoundation │ └── store │ └── rx2 │ └── test │ ├── FlowTestExt.kt │ ├── HotRxSingleStoreTest.kt │ ├── RxFlowableStoreTest.kt │ ├── RxSingleStoreExtensionsTest.kt │ └── RxSingleStoreTest.kt ├── settings.gradle ├── store ├── api │ ├── android │ │ └── store.api │ └── jvm │ │ └── store.api ├── build.gradle.kts ├── config │ └── ktlint │ │ └── baseline.xml ├── gradle.properties └── src │ ├── androidMain │ └── AndroidManifest.xml │ ├── commonMain │ └── kotlin │ │ └── org │ │ └── mobilenativefoundation │ │ └── store │ │ └── store5 │ │ ├── Bookkeeper.kt │ │ ├── Clear.kt │ │ ├── Converter.kt │ │ ├── Fetcher.kt │ │ ├── FetcherResult.kt │ │ ├── Logger.kt │ │ ├── MemoryPolicy.kt │ │ ├── MutableStore.kt │ │ ├── MutableStoreBuilder.kt │ │ ├── OnFetcherCompletion.kt │ │ ├── OnUpdaterCompletion.kt │ │ ├── Read.kt │ │ ├── SourceOfTruth.kt │ │ ├── Store.kt │ │ ├── StoreBuilder.kt │ │ ├── StoreDefaults.kt │ │ ├── StoreReadRequest.kt │ │ ├── StoreReadResponse.kt │ │ ├── StoreWriteRequest.kt │ │ ├── StoreWriteResponse.kt │ │ ├── Updater.kt │ │ ├── UpdaterResult.kt │ │ ├── Validator.kt │ │ ├── Write.kt │ │ ├── impl │ │ ├── DefaultLogger.kt │ │ ├── FetcherController.kt │ │ ├── OnStoreWriteCompletion.kt │ │ ├── RealBookkeeper.kt │ │ ├── RealMutableStore.kt │ │ ├── RealMutableStoreBuilder.kt │ │ ├── RealSourceOfTruth.kt │ │ ├── RealStore.kt │ │ ├── RealStoreBuilder.kt │ │ ├── RealStoreWriteRequest.kt │ │ ├── RealValidator.kt │ │ ├── RefCountedResource.kt │ │ ├── SourceOfTruthWithBarrier.kt │ │ ├── extensions │ │ │ ├── clock.kt │ │ │ └── store.kt │ │ └── operators │ │ │ ├── FlowMerge.kt │ │ │ └── MapIndexed.kt │ │ ├── internal │ │ ├── concurrent │ │ │ ├── Lightswitch.kt │ │ │ └── ThreadSafety.kt │ │ ├── definition │ │ │ ├── Timestamp.kt │ │ │ └── WriteRequestQueue.kt │ │ └── result │ │ │ ├── EagerConflictResolutionResult.kt │ │ │ └── StoreDelegateWriteResult.kt │ │ └── storeBuilder.uml │ └── commonTest │ └── kotlin │ └── org │ └── mobilenativefoundation │ └── store │ └── store5 │ ├── ClearAllStoreTests.kt │ ├── ClearStoreByKeyTests.kt │ ├── FallbackTests.kt │ ├── FetcherControllerTests.kt │ ├── FetcherResponseTests.kt │ ├── FlowStoreTests.kt │ ├── HotFlowStoreTests.kt │ ├── KeyTrackerTests.kt │ ├── LocalOnlyTests.kt │ ├── MapIndexedTests.kt │ ├── SourceOfTruthErrorsTests.kt │ ├── SourceOfTruthWithBarrierTests.kt │ ├── StoreReadResponseTests.kt │ ├── StoreWithInMemoryCacheTests.kt │ ├── StreamWithoutSourceOfTruthTests.kt │ ├── UpdaterTests.kt │ ├── ValueFetcherTests.kt │ ├── mutablestore │ ├── RealMutableStoreTest.kt │ └── util │ │ ├── TestCache.kt │ │ ├── TestConverter.kt │ │ ├── TestFetcher.kt │ │ ├── TestInMemoryBookkeeper.kt │ │ ├── TestLogger.kt │ │ ├── TestSourceOfTruth.kt │ │ ├── TestStore.kt │ │ ├── TestUpdater.kt │ │ └── TestValidator.kt │ └── util │ ├── AsFlowable.kt │ ├── FakeFetcher.kt │ ├── InMemoryPersister.kt │ ├── TestApi.kt │ ├── TestStoreExt.kt │ ├── fake │ ├── NoteCollections.kt │ ├── Notes.kt │ ├── NotesApi.kt │ ├── NotesBookkeeping.kt │ ├── NotesConverterProvider.kt │ ├── NotesDatabase.kt │ ├── NotesKey.kt │ ├── NotesUpdaterProvider.kt │ ├── NotesValidator.kt │ └── fallback │ │ ├── HardcodedPages.kt │ │ ├── Page.kt │ │ ├── PagesDatabase.kt │ │ ├── PrimaryPagesApi.kt │ │ └── SecondaryPagesApi.kt │ └── model │ └── NoteData.kt └── tooling ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── plugins ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── org │ └── mobilenativefoundation │ └── store │ └── tooling │ └── plugins │ ├── AndroidConventionPlugin.kt │ └── KotlinMultiplatformConventionPlugin.kt └── settings.gradle.kts /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 70..80 3 | round: down 4 | precision: 2 5 | 6 | comment: 7 | layout: diff, files 8 | 9 | ignore: 10 | - "**/fake" 11 | - "**/commonTest" 12 | - "**/androidTest" 13 | - "**/iOSTest" 14 | - "**/jsTest" 15 | - "**/jvmTest" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. Pixel 3] 28 | - OS: [e.g. Android 10] 29 | - Store Version [e.g. 4.0.0] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Proposal 3 | about: Propose an API change 4 | title: "[Proposal] " 5 | --- 6 | 7 | # Proposal: [Title] 8 | 9 | Author(s): [GitHub username] 10 | 11 | Last updated: [Date] 12 | 13 | ## Abstract 14 | 15 | [A short summary of the proposal.] 16 | 17 | ## Background 18 | 19 | [An introduction of the necessary background and the problem being solved by the proposed change.] 20 | 21 | ## Proposal 22 | 23 | [A precise statement of the proposed change.] 24 | 25 | ## Rationale 26 | 27 | [A discussion of alternate approaches and the trade offs, advantages, and disadvantages of the specified approach.] 28 | 29 | ## Compatibility 30 | 31 | [A discussion of the change with regard to the current version of Store.] 32 | 33 | ## Implementation 34 | 35 | [A description of the steps in the implementation, who will do them, and when.] 36 | 37 | ## Open issues 38 | 39 | [A discussion of open issues relating to this proposal. This section may be omitted if there are none.] -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gradle 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 1 8 | ignore: 9 | update-types: ["version-update:semver-major"] 10 | -------------------------------------------------------------------------------- /.github/images/kotlin-foundation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/.github/images/kotlin-foundation.png -------------------------------------------------------------------------------- /.github/images/mobile-native-foundation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/.github/images/mobile-native-foundation.png -------------------------------------------------------------------------------- /.github/workflows/add_issue_to_project.yml: -------------------------------------------------------------------------------- 1 | name: Add Issue To Project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-issue-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@main 14 | with: 15 | project-url: https://github.com/orgs/MobileNativeFoundation/projects/1 16 | github-token: ${{ secrets.ADD_ISSUE_TO_PROJECT }} 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-and-test: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 30 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | api-level: 19 | - 29 20 | steps: 21 | 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | with: 25 | ref: ${{ github.head_ref || github.ref }} 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - name: Set up JDK 11 30 | uses: actions/setup-java@v4 31 | with: 32 | distribution: 'zulu' 33 | java-version: '11' 34 | 35 | - name: Setup Gradle 36 | uses: gradle/gradle-build-action@v2 37 | 38 | - name: Grant execute permission for Gradlew 39 | run: chmod +x gradlew 40 | 41 | - name: Build and Test with Coverage 42 | run: ./gradlew clean build koverXmlReport --stacktrace 43 | 44 | - name: Upload Coverage to Codecov 45 | uses: codecov/codecov-action@v4 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | files: build/reports/kover/coverage.xml 49 | flags: unittests 50 | name: codecov-umbrella 51 | fail_ci_if_error: true 52 | verbose: true 53 | 54 | publish: 55 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'MobileNativeFoundation/Store' 56 | runs-on: macos-latest 57 | needs: build-and-test 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v3 61 | 62 | - name: Set up JDK 11 63 | uses: actions/setup-java@v4 64 | with: 65 | distribution: 'zulu' 66 | java-version: '11' 67 | 68 | - name: Grant execute permission for Gradlew 69 | run: chmod +x gradlew 70 | 71 | - name: Upload Artifacts to Maven Central 72 | run: ./gradlew publishAllPublicationsToMavenCentralRepository --no-daemon --no-parallel 73 | env: 74 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} 75 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} 76 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} 77 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} 78 | 79 | - name: Retrieve Version 80 | run: | 81 | echo "VERSION_NAME=$(cat gradle.properties | grep -w "VERSION_NAME" | cut -d'=' -f2)" >> $GITHUB_ENV 82 | 83 | - name: Publish Release 84 | run: ./gradlew closeAndReleaseRepository --no-daemon --no-parallel 85 | if: "!endsWith(env.VERSION_NAME, '-SNAPSHOT')" 86 | env: 87 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} 88 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} -------------------------------------------------------------------------------- /.github/workflows/create_swift_package.yml: -------------------------------------------------------------------------------- 1 | name: Create Swift Package 2 | 3 | on: 4 | workflow_dispatch: 5 | jobs: 6 | publish: 7 | uses: touchlab/KMMBridgeGithubWorkflow/.github/workflows/faktorybuildbranches.yml@v0.6 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editor tempfiles 2 | *~ 3 | # MacOS temp files 4 | .DS_Store 5 | 6 | # Built application files 7 | *.apk 8 | *.ap_ 9 | 10 | # Files for the ART/Dalvik VM 11 | *.dex 12 | 13 | # Java class files 14 | *.class 15 | 16 | # Generated files 17 | bin/ 18 | gen/ 19 | out/ 20 | 21 | # Gradle files 22 | .gradle/ 23 | build/ 24 | 25 | # Local configuration file (sdk path, etc) 26 | local.properties 27 | 28 | # Proguard folder generated by Eclipse 29 | proguard/ 30 | 31 | # Log Files 32 | *.log 33 | 34 | # Android Studio Navigation editor temp files 35 | .navigation/ 36 | 37 | # Android Studio captures folder 38 | captures/ 39 | 40 | # Intellij 41 | *.iml 42 | .idea/ 43 | .classpath 44 | .project 45 | .settings 46 | 47 | # Keystore files 48 | *.jks 49 | 50 | **/kover/html/ 51 | *.podspec 52 | .kotlin/ 53 | yarn.lock 54 | 55 | # Ignore coverage reports 56 | **/*/coverage.xml 57 | **/*/build/kover/ 58 | **/*/build/reports/kover/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Store 2 | Thanks for considering contributing to Store. This document provides guidelines and information about how you can contribute. 3 | 4 | ## Getting Started 5 | - **Fork the Repository**: Start by forking the [MobileNativeFoundation/Store](https://github.com/MobileNativeFoundation/Store) repository. 6 | - **Clone the Fork**: Clone your fork to your machine to start working on the changes. 7 | 8 | ## Contribution Workflow 9 | ### Reporting Issues 10 | - **Search Existing Issues**: Before creating a new issue, please do a search in existing issues to see if it has been reported or fixed. 11 | - **Create a Detailed Issue**: If you find a bug or have a feature request, please create an issue with a clear title and a detailed description. 12 | ### Submitting Changes 13 | - **Create a Branch**: Create a branch in your fork for your contribution. 14 | - **Make Your Changes**: Make your changes and commit them to your branch. Make sure to write clear, concise commit messages. 15 | - **Write Tests**: If you are adding new features or fixing bugs, write tests that cover your changes. 16 | - **Run the Tests**: Run the project's existing tests to ensure nothing is broken. 17 | - **Create a Pull Request**: Submit a PR to the main repository for review. Include a clear description of the changes and any relevant issue numbers. 18 | ### Code Review Process 19 | - **Wait for Review**: Maintainers will review your PR and might request changes. 20 | - **Make Requested Changes**: If changes are requested, make them and update your PR. 21 | - **Merge**: Once your PR is approved, a maintainer will merge it into the main codebase. 22 | 23 | ## Community Guidelines 24 | - **Be Respectful**: Treat everyone with respect. We strive to create a welcoming and inclusive environment. 25 | - **Follow the Code of Conduct**: Familiarize yourself with our [Code of Conduct](https://github.com/MobileNativeFoundation/Store/blob/main/CODE_OF_CONDUCT.md). 26 | 27 | ## Getting Help 28 | - **Join the Community**: If you have questions or need help, join our [Slack channel](https://kotlinlang.slack.com/archives/C06007Z01HU). 29 | -------------------------------------------------------------------------------- /Images/friendly_robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/friendly_robot.png -------------------------------------------------------------------------------- /Images/friendly_robot_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/friendly_robot_icon.png -------------------------------------------------------------------------------- /Images/store-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/store-1.jpg -------------------------------------------------------------------------------- /Images/store-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/store-2.jpg -------------------------------------------------------------------------------- /Images/store-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/store-3.jpg -------------------------------------------------------------------------------- /Images/store-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/store-4.jpg -------------------------------------------------------------------------------- /Images/store-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/Images/store-5.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Store5 4 | 5 | [![codecov](https://codecov.io/gh/MobileNativeFoundation/Store/branch/main/graph/badge.svg?token=0UCmG3QHPf)](https://codecov.io/gh/MobileNativeFoundation/Store) 6 | 7 | #### Documentation 8 | 9 | Comprehensive guides, tutorials, and API reference: [store.mobilenativefoundation.org](https://store.mobilenativefoundation.org). 10 | 11 | #### Getting Started 12 | 13 | 1. Start with the [Quickstart](https://store.mobilenativefoundation.org/docs/quickstart) to build your first Store. 14 | 2. Dive into [Store Foundations](https://store.mobilenativefoundation.org/docs/concepts) to learn how Store works. 15 | 3. Check out [Handling CRUD](https://store.mobilenativefoundation.org/docs/use-cases/store5/setting-up-store-for-crud-operations) for an advanced guide on supporting create, read, update, and delete operations. 16 | 17 | #### Getting Help 18 | 19 | Join our community in the [#store](https://kotlinlang.slack.com/archives/C06007Z01HU) channel on the official Kotlin Slack. 20 | 21 | #### Getting Involved 22 | 23 | Store has a vibrant community of contributors. We welcome contributions of all kinds. Please see our [Contributing Guidelines](CONTRIBUTING.md) for more information on how to get involved. 24 | 25 | #### Backed By 26 | 27 |
28 | 29 | 30 |
31 | 32 | #### License 33 | 34 | ```text 35 | Copyright (c) 2024 Mobile Native Foundation. 36 | Licensed under the Apache License, Version 2.0 (the "License"); 37 | you may not use this file except in compliance with the License. 38 | ``` 39 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing 2 | ======== 3 | 4 | 1. Change the version in top level `gradle.properties` to a non-SNAPSHOT version. 5 | 2. Update the `cocoapods` version in `build.gradle.kts` in `:store`. 6 | 3. Modify `create_swift_package.yml` workflow. 7 | * https://github.com/MobileNativeFoundation/Store/blob/e526400cdf51aa2f78b6b7e9e87f4a6845e6dcea/.github/workflows/create_swift_package.yml 8 | 4. Update the `CHANGELOG.md` for the impending release. 9 | 5. Update the `README.md` with the new version. 10 | 6. `git commit -sam "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) 11 | 7. `git tag -a X.Y.X -m "Version X.Y.Z"` (where X.Y.Z is the new version) 12 | * Run `git tag` to verify it. 13 | 8. `git push && git push --tags` 14 | * This should be pushed to your fork. 15 | 9. Create a PR with this commit and merge it. 16 | 10. Update the top level `build.gradle` to the next SNAPSHOT version. 17 | 11. Modify `create_swift_package.yml` workflow to only run manually. 18 | * https://github.com/MobileNativeFoundation/Store/blob/de9ed1764408eeaafe5e58fe602205c875a8b0b0/.github/workflows/create_swift_package.yml 19 | 12. `git commit -am "Prepare next development version."` 20 | 13. Create a PR with this commit and merge it. 21 | 14. Login to Sonatype to promote the artifacts https://central.sonatype.org/pages/releasing-the-deployment.html 22 | * This part is automated. If it fails in CI, follow the steps below. 23 | * Click on Staging Repositories under Build Promotion 24 | * Select all the Repositories that contain the content you want to release 25 | * Click on Close and refresh until the Release button is active 26 | * Click Release and submit 27 | 15. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release) 28 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.ktlint) 3 | id("com.diffplug.spotless") version "6.4.1" 4 | } 5 | 6 | buildscript { 7 | repositories { 8 | mavenCentral() 9 | gradlePluginPortal() 10 | google() 11 | } 12 | 13 | dependencies { 14 | classpath(libs.android.gradle.plugin) 15 | classpath(libs.kotlin.gradle.plugin) 16 | classpath(libs.kotlin.serialization.plugin) 17 | classpath(libs.dokka.gradle.plugin) 18 | classpath(libs.ktlint.gradle.plugin) 19 | classpath(libs.jacoco.gradle.plugin) 20 | classpath(libs.maven.publish.plugin) 21 | classpath(libs.atomic.fu.gradle.plugin) 22 | classpath(libs.kmmBridge.gradle.plugin) 23 | classpath(libs.binary.compatibility.validator) 24 | } 25 | } 26 | 27 | allprojects { 28 | repositories { 29 | mavenCentral() 30 | google() 31 | } 32 | } 33 | 34 | subprojects { 35 | apply(plugin = "org.jlleitschuh.gradle.ktlint") 36 | apply(plugin = "com.diffplug.spotless") 37 | 38 | ktlint { 39 | disabledRules.add("import-ordering") 40 | } 41 | 42 | spotless { 43 | kotlin { 44 | target("src/**/*.kt") 45 | } 46 | } 47 | } 48 | 49 | tasks { 50 | withType { 51 | kotlinOptions { 52 | jvmTarget = "11" 53 | } 54 | } 55 | 56 | withType().configureEach { 57 | sourceCompatibility = JavaVersion.VERSION_11.name 58 | targetCompatibility = JavaVersion.VERSION_11.name 59 | } 60 | } 61 | 62 | // Workaround for https://youtrack.jetbrains.com/issue/KT-62040 63 | tasks.getByName("wrapper") 64 | -------------------------------------------------------------------------------- /cache/README.md: -------------------------------------------------------------------------------- 1 | # Cache 2 | 3 | Store depends on a subset of [Guava](https://github.com/google/guava). 4 | This is a shaded artifact that is Kotlin Multiplatform compatible. 5 | 6 | ## Usage 7 | 8 | ```kotlin 9 | implementation("org.mobilenativefoundation.store:cache:${STORE_VERSION}") 10 | ``` 11 | 12 | ## Implementation 13 | 14 | ### Model the key 15 | 16 | ```kotlin 17 | data class Key( 18 | val id: String 19 | ) 20 | ``` 21 | 22 | ### Model the value 23 | 24 | ```kotlin 25 | data class Post( 26 | val title: String 27 | ) 28 | ``` 29 | 30 | ### Build the cache 31 | 32 | ```kotlin 33 | val cache = CacheBuilder() 34 | .maximumSize(100) 35 | .expireAfterWrite(1.day) 36 | .build() 37 | ``` 38 | 39 | ## See Also 40 | 41 | https://github.com/google/guava/wiki/CachesExplained 42 | 43 | ## License 44 | 45 | ```text 46 | Copyright (c) 2017 The New York Times Company 47 | 48 | Copyright (c) 2010 The Guava Authors 49 | 50 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this library except in 51 | compliance with the License. You may obtain a copy of the License at 52 | 53 | www.apache.org/licenses/LICENSE-2.0 54 | 55 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 56 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific 57 | language governing permissions and limitations under the License. 58 | ``` 59 | -------------------------------------------------------------------------------- /cache/api/android/cache.api: -------------------------------------------------------------------------------- 1 | public final class org/mobilenativefoundation/store/cache/BuildConfig { 2 | public static final field BUILD_TYPE Ljava/lang/String; 3 | public static final field DEBUG Z 4 | public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; 5 | public fun ()V 6 | } 7 | 8 | public abstract interface class org/mobilenativefoundation/store/cache5/Cache { 9 | public abstract fun getAllPresent ()Ljava/util/Map; 10 | public abstract fun getAllPresent (Ljava/util/List;)Ljava/util/Map; 11 | public abstract fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; 12 | public abstract fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; 13 | public abstract fun invalidate (Ljava/lang/Object;)V 14 | public abstract fun invalidateAll ()V 15 | public abstract fun invalidateAll (Ljava/util/List;)V 16 | public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;)V 17 | public abstract fun putAll (Ljava/util/Map;)V 18 | public abstract fun size ()J 19 | } 20 | 21 | public final class org/mobilenativefoundation/store/cache5/Cache$DefaultImpls { 22 | public static fun getAllPresent (Lorg/mobilenativefoundation/store/cache5/Cache;)Ljava/util/Map; 23 | } 24 | 25 | public final class org/mobilenativefoundation/store/cache5/CacheBuilder { 26 | public static final field Companion Lorg/mobilenativefoundation/store/cache5/CacheBuilder$Companion; 27 | public fun ()V 28 | public final fun build ()Lorg/mobilenativefoundation/store/cache5/Cache; 29 | public final fun concurrencyLevel (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 30 | public final fun expireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 31 | public final fun expireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 32 | public final fun maximumSize (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 33 | public final fun ticker (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 34 | public final fun weigher (JLkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 35 | } 36 | 37 | public final class org/mobilenativefoundation/store/cache5/CacheBuilder$Companion { 38 | } 39 | 40 | public final class org/mobilenativefoundation/store/cache5/StoreMultiCache : org/mobilenativefoundation/store/cache5/Cache { 41 | public static final field Companion Lorg/mobilenativefoundation/store/cache5/StoreMultiCache$Companion; 42 | public fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V 43 | public synthetic fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 44 | public fun getAllPresent ()Ljava/util/Map; 45 | public fun getAllPresent (Ljava/util/List;)Ljava/util/Map; 46 | public synthetic fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; 47 | public fun getIfPresent (Lorg/mobilenativefoundation/store/core5/StoreKey;)Lorg/mobilenativefoundation/store/core5/StoreData; 48 | public synthetic fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; 49 | public fun getOrPut (Lorg/mobilenativefoundation/store/core5/StoreKey;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/core5/StoreData; 50 | public synthetic fun invalidate (Ljava/lang/Object;)V 51 | public fun invalidate (Lorg/mobilenativefoundation/store/core5/StoreKey;)V 52 | public fun invalidateAll ()V 53 | public fun invalidateAll (Ljava/util/List;)V 54 | public synthetic fun put (Ljava/lang/Object;Ljava/lang/Object;)V 55 | public fun put (Lorg/mobilenativefoundation/store/core5/StoreKey;Lorg/mobilenativefoundation/store/core5/StoreData;)V 56 | public fun putAll (Ljava/util/Map;)V 57 | public fun size ()J 58 | } 59 | 60 | public final class org/mobilenativefoundation/store/cache5/StoreMultiCache$Companion { 61 | public final fun invalidKeyErrorMessage (Ljava/lang/Object;)Ljava/lang/String; 62 | } 63 | 64 | public final class org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor { 65 | public fun (Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V 66 | public final fun getAllPresent ()Ljava/util/Map; 67 | public final fun getCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; 68 | public final fun getSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Lorg/mobilenativefoundation/store/core5/StoreData$Single; 69 | public final fun invalidateAll ()V 70 | public final fun invalidateCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Z 71 | public final fun invalidateSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Z 72 | public final fun putCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Collection;)Z 73 | public final fun putSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Z 74 | public final fun size ()J 75 | } 76 | 77 | -------------------------------------------------------------------------------- /cache/api/jvm/cache.api: -------------------------------------------------------------------------------- 1 | public abstract interface class org/mobilenativefoundation/store/cache5/Cache { 2 | public abstract fun getAllPresent ()Ljava/util/Map; 3 | public abstract fun getAllPresent (Ljava/util/List;)Ljava/util/Map; 4 | public abstract fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; 5 | public abstract fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; 6 | public abstract fun invalidate (Ljava/lang/Object;)V 7 | public abstract fun invalidateAll ()V 8 | public abstract fun invalidateAll (Ljava/util/List;)V 9 | public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;)V 10 | public abstract fun putAll (Ljava/util/Map;)V 11 | public abstract fun size ()J 12 | } 13 | 14 | public final class org/mobilenativefoundation/store/cache5/Cache$DefaultImpls { 15 | public static fun getAllPresent (Lorg/mobilenativefoundation/store/cache5/Cache;)Ljava/util/Map; 16 | } 17 | 18 | public final class org/mobilenativefoundation/store/cache5/CacheBuilder { 19 | public static final field Companion Lorg/mobilenativefoundation/store/cache5/CacheBuilder$Companion; 20 | public fun ()V 21 | public final fun build ()Lorg/mobilenativefoundation/store/cache5/Cache; 22 | public final fun concurrencyLevel (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 23 | public final fun expireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 24 | public final fun expireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 25 | public final fun maximumSize (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 26 | public final fun ticker (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 27 | public final fun weigher (JLkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; 28 | } 29 | 30 | public final class org/mobilenativefoundation/store/cache5/CacheBuilder$Companion { 31 | } 32 | 33 | public final class org/mobilenativefoundation/store/cache5/StoreMultiCache : org/mobilenativefoundation/store/cache5/Cache { 34 | public static final field Companion Lorg/mobilenativefoundation/store/cache5/StoreMultiCache$Companion; 35 | public fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V 36 | public synthetic fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 37 | public fun getAllPresent ()Ljava/util/Map; 38 | public fun getAllPresent (Ljava/util/List;)Ljava/util/Map; 39 | public synthetic fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; 40 | public fun getIfPresent (Lorg/mobilenativefoundation/store/core5/StoreKey;)Lorg/mobilenativefoundation/store/core5/StoreData; 41 | public synthetic fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; 42 | public fun getOrPut (Lorg/mobilenativefoundation/store/core5/StoreKey;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/core5/StoreData; 43 | public synthetic fun invalidate (Ljava/lang/Object;)V 44 | public fun invalidate (Lorg/mobilenativefoundation/store/core5/StoreKey;)V 45 | public fun invalidateAll ()V 46 | public fun invalidateAll (Ljava/util/List;)V 47 | public synthetic fun put (Ljava/lang/Object;Ljava/lang/Object;)V 48 | public fun put (Lorg/mobilenativefoundation/store/core5/StoreKey;Lorg/mobilenativefoundation/store/core5/StoreData;)V 49 | public fun putAll (Ljava/util/Map;)V 50 | public fun size ()J 51 | } 52 | 53 | public final class org/mobilenativefoundation/store/cache5/StoreMultiCache$Companion { 54 | public final fun invalidKeyErrorMessage (Ljava/lang/Object;)Ljava/lang/String; 55 | } 56 | 57 | public final class org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor { 58 | public fun (Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V 59 | public final fun getAllPresent ()Ljava/util/Map; 60 | public final fun getCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; 61 | public final fun getSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Lorg/mobilenativefoundation/store/core5/StoreData$Single; 62 | public final fun invalidateAll ()V 63 | public final fun invalidateCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Z 64 | public final fun invalidateSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Z 65 | public final fun putCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Collection;)Z 66 | public final fun putSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Z 67 | public final fun size ()J 68 | } 69 | 70 | -------------------------------------------------------------------------------- /cache/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.mobilenativefoundation.store.multiplatform") 3 | } 4 | 5 | kotlin { 6 | 7 | sourceSets { 8 | val commonMain by getting { 9 | dependencies { 10 | api(libs.kotlinx.atomic.fu) 11 | api(projects.core) 12 | implementation(libs.kotlinx.coroutines.core) 13 | } 14 | } 15 | val commonTest by getting { 16 | dependencies { 17 | implementation(libs.junit) 18 | implementation(libs.kotlinx.coroutines.test) 19 | } 20 | } 21 | } 22 | } 23 | 24 | android { 25 | namespace = "org.mobilenativefoundation.store.cache" 26 | } 27 | -------------------------------------------------------------------------------- /cache/config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /cache/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=org.mobilenativefoundation.store 2 | POM_ARTIFACT_ID=cache5 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /cache/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | interface Cache { 4 | /** 5 | * @return [Value] associated with [key] or `null` if there is no cached value for [key]. 6 | */ 7 | fun getIfPresent(key: Key): Value? 8 | 9 | /** 10 | * @return [Value] associated with [key], obtaining the value from [valueProducer] if necessary. 11 | * No observable state associated with this cache is modified until loading completes. 12 | * @param [valueProducer] Must not return `null`. It may either return a non-null value or throw an exception. 13 | * @throws ExecutionExeption If a checked exception was thrown while loading the value. 14 | * @throws UncheckedExecutionException If an unchecked exception was thrown while loading the value. 15 | * @throws ExecutionError If an error was thrown while loading the value. 16 | */ 17 | fun getOrPut( 18 | key: Key, 19 | valueProducer: () -> Value, 20 | ): Value 21 | 22 | /** 23 | * @return Map of the [Value] associated with each [Key] in [keys]. Returned map only contains entries already present in the cache. 24 | * The default implementation provided here throws a [NotImplementedError] to maintain backward compatibility for existing implementations. 25 | */ 26 | fun getAllPresent(keys: List<*>): Map 27 | 28 | /** 29 | * @return Map of the [Value] associated with each [Key] in the cache. 30 | */ 31 | fun getAllPresent(): Map = throw NotImplementedError() 32 | 33 | /** 34 | * Associates [value] with [key]. 35 | * If the cache previously contained a value associated with [key], the old value is replaced by [value]. 36 | * Prefer [getOrPut] when using the conventional "If cached, then return. Otherwise create, cache, and then return" pattern. 37 | */ 38 | fun put( 39 | key: Key, 40 | value: Value, 41 | ) 42 | 43 | /** 44 | * Copies all of the mappings from the specified map to the cache. The effect of this call is 45 | * equivalent to that of calling [put] on this map once for each mapping from [Key] to [Value] in the specified map. 46 | * The behavior of this operation is undefined if the specified map is modified while the operation is in progress. 47 | */ 48 | fun putAll(map: Map) 49 | 50 | /** 51 | * Discards any cached value associated with [key]. 52 | */ 53 | fun invalidate(key: Key) 54 | 55 | /** 56 | * Discards any cached value associated for [keys]. 57 | */ 58 | fun invalidateAll(keys: List) 59 | 60 | /** 61 | * Discards all entries in the cache. 62 | */ 63 | fun invalidateAll() 64 | 65 | /** 66 | * @return Approximate number of entries in the cache. 67 | */ 68 | fun size(): Long 69 | } 70 | -------------------------------------------------------------------------------- /cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | import kotlin.time.Duration 4 | 5 | class CacheBuilder { 6 | internal var concurrencyLevel = 4 7 | private set 8 | internal val initialCapacity = 16 9 | internal var maximumSize = UNSET 10 | private set 11 | internal var maximumWeight = UNSET 12 | private set 13 | internal var expireAfterAccess: Duration = Duration.INFINITE 14 | private set 15 | internal var expireAfterWrite: Duration = Duration.INFINITE 16 | private set 17 | internal var weigher: Weigher? = null 18 | private set 19 | internal var ticker: Ticker? = null 20 | private set 21 | 22 | fun concurrencyLevel(producer: () -> Int): CacheBuilder = 23 | apply { 24 | concurrencyLevel = producer.invoke() 25 | } 26 | 27 | fun maximumSize(maximumSize: Long): CacheBuilder = 28 | apply { 29 | if (maximumSize < 0) { 30 | throw IllegalArgumentException("Maximum size must be non-negative.") 31 | } 32 | this.maximumSize = maximumSize 33 | } 34 | 35 | fun expireAfterAccess(duration: Duration): CacheBuilder = 36 | apply { 37 | if (duration.isNegative()) { 38 | throw IllegalArgumentException("Duration must be non-negative.") 39 | } 40 | expireAfterAccess = duration 41 | } 42 | 43 | fun expireAfterWrite(duration: Duration): CacheBuilder = 44 | apply { 45 | if (duration.isNegative()) { 46 | throw IllegalArgumentException("Duration must be non-negative.") 47 | } 48 | expireAfterWrite = duration 49 | } 50 | 51 | fun ticker(ticker: Ticker): CacheBuilder = 52 | apply { 53 | this.ticker = ticker 54 | } 55 | 56 | fun weigher( 57 | maximumWeight: Long, 58 | weigher: Weigher, 59 | ): CacheBuilder = 60 | apply { 61 | if (maximumWeight < 0) { 62 | throw IllegalArgumentException("Maximum weight must be non-negative.") 63 | } 64 | 65 | this.maximumWeight = maximumWeight 66 | this.weigher = weigher 67 | } 68 | 69 | fun build(): Cache { 70 | if (maximumSize != -1L && weigher != null) { 71 | throw IllegalStateException("Maximum size cannot be combined with weigher.") 72 | } 73 | return LocalCache.LocalManualCache(this) 74 | } 75 | 76 | companion object { 77 | private const val UNSET = -1L 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | import kotlin.time.ExperimentalTime 4 | import kotlin.time.TimeSource 5 | 6 | @OptIn(ExperimentalTime::class) 7 | internal val MonotonicTicker: Ticker = TimeSource.Monotonic.markNow().let { timeMark -> { timeMark.elapsedNow().inWholeNanoseconds } } 8 | -------------------------------------------------------------------------------- /cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | /** 4 | * The reason why a cached entry was removed. 5 | * @param wasEvicted True if entry removal was automatic due to eviction. That is, the cause of removal is neither [EXPLICIT] or [REPLACED]. 6 | * @author Charles Fry 7 | * @since 10.0 8 | */ 9 | internal enum class RemovalCause(val wasEvicted: Boolean) { 10 | EXPLICIT(false), 11 | REPLACED(false), 12 | COLLECTED(true), 13 | EXPIRED(true), 14 | SIZE(true), 15 | } 16 | -------------------------------------------------------------------------------- /cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | /** 4 | * @return Number of nanoseconds elapsed since the ticker's fixed point of reference. 5 | */ 6 | typealias Ticker = () -> Long 7 | -------------------------------------------------------------------------------- /cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | /** 4 | * @return Weight of a cache entry. Must be non-negative. There is no unit for entry weights. Rather, they are simply relative to each other. 5 | */ 6 | typealias Weigher = (key: Key, value: Value) -> Int 7 | -------------------------------------------------------------------------------- /cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.cache5 2 | 3 | import kotlinx.coroutines.test.runTest 4 | import kotlin.test.Ignore 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.time.Duration.Companion.milliseconds 8 | 9 | class CacheTests { 10 | private val cache: Cache = CacheBuilder().build() 11 | 12 | @Test 13 | fun getIfPresent() { 14 | cache.put("key", "value") 15 | assertEquals("value", cache.getIfPresent("key")) 16 | } 17 | 18 | @Test 19 | fun getOrPut() { 20 | assertEquals("value", cache.getOrPut("key") { "value" }) 21 | } 22 | 23 | @Test 24 | fun getAllPresent() { 25 | cache.put("key1", "value1") 26 | cache.put("key2", "value2") 27 | assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) 28 | assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent()) 29 | } 30 | 31 | @Ignore // Not implemented yet 32 | @Test 33 | fun putAll() { 34 | cache.putAll(mapOf("key1" to "value1", "key2" to "value2")) 35 | assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) 36 | } 37 | 38 | @Test 39 | fun invalidate() { 40 | cache.put("key", "value") 41 | cache.invalidate("key") 42 | assertEquals(null, cache.getIfPresent("key")) 43 | } 44 | 45 | @Ignore // Not implemented yet 46 | @Test 47 | fun invalidateAll() { 48 | cache.put("key1", "value1") 49 | cache.put("key2", "value2") 50 | cache.invalidateAll(listOf("key1", "key2")) 51 | assertEquals(null, cache.getIfPresent("key1")) 52 | assertEquals(null, cache.getIfPresent("key2")) 53 | } 54 | 55 | @Ignore // Not implemented yet 56 | @Test 57 | fun size() { 58 | cache.put("key1", "value1") 59 | cache.put("key2", "value2") 60 | assertEquals(2, cache.size()) 61 | } 62 | 63 | @Test 64 | fun maximumSize() { 65 | val cache = CacheBuilder().maximumSize(1).build() 66 | cache.put("key1", "value1") 67 | cache.put("key2", "value2") 68 | assertEquals(null, cache.getIfPresent("key1")) 69 | assertEquals("value2", cache.getIfPresent("key2")) 70 | } 71 | 72 | @Test 73 | fun maximumWeight() { 74 | val cache = CacheBuilder().weigher(399) { _, _ -> 100 }.build() 75 | cache.put("key1", "value1") 76 | cache.put("key2", "value2") 77 | assertEquals(null, cache.getIfPresent("key1")) 78 | assertEquals("value2", cache.getIfPresent("key2")) 79 | } 80 | 81 | @Test 82 | fun expireAfterAccess() = 83 | runTest { 84 | var timeNs = 0L 85 | val cache = CacheBuilder().expireAfterAccess(100.milliseconds).ticker { timeNs }.build() 86 | cache.put("key", "value") 87 | 88 | timeNs += 50.milliseconds.inWholeNanoseconds 89 | assertEquals("value", cache.getIfPresent("key")) 90 | 91 | timeNs += 100.milliseconds.inWholeNanoseconds 92 | assertEquals(null, cache.getIfPresent("key")) 93 | } 94 | 95 | @Test 96 | fun expireAfterWrite() = 97 | runTest { 98 | var timeNs = 0L 99 | val cache = CacheBuilder().expireAfterWrite(100.milliseconds).ticker { timeNs }.build() 100 | cache.put("key", "value") 101 | 102 | timeNs += 50.milliseconds.inWholeNanoseconds 103 | assertEquals("value", cache.getIfPresent("key")) 104 | 105 | timeNs += 50.milliseconds.inWholeNanoseconds 106 | assertEquals(null, cache.getIfPresent("key")) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /core/api/android/core.api: -------------------------------------------------------------------------------- 1 | public final class org/mobilenativefoundation/store/core/BuildConfig { 2 | public static final field BUILD_TYPE Ljava/lang/String; 3 | public static final field DEBUG Z 4 | public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; 5 | public fun ()V 6 | } 7 | 8 | public abstract interface annotation class org/mobilenativefoundation/store/core5/ExperimentalStoreApi : java/lang/annotation/Annotation { 9 | } 10 | 11 | public final class org/mobilenativefoundation/store/core5/InsertionStrategy : java/lang/Enum { 12 | public static final field APPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 13 | public static final field PREPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 14 | public static final field REPLACE Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 15 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 16 | public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 17 | public static fun values ()[Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 18 | } 19 | 20 | public abstract interface class org/mobilenativefoundation/store/core5/KeyProvider { 21 | public abstract fun fromCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Single; 22 | public abstract fun fromSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Collection; 23 | } 24 | 25 | public abstract interface class org/mobilenativefoundation/store/core5/StoreData { 26 | } 27 | 28 | public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Collection : org/mobilenativefoundation/store/core5/StoreData { 29 | public abstract fun copyWith (Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; 30 | public abstract fun getItems ()Ljava/util/List; 31 | public abstract fun insertItems (Lorg/mobilenativefoundation/store/core5/InsertionStrategy;Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; 32 | } 33 | 34 | public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Single : org/mobilenativefoundation/store/core5/StoreData { 35 | public abstract fun getId ()Ljava/lang/Object; 36 | } 37 | 38 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey { 39 | } 40 | 41 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection : org/mobilenativefoundation/store/core5/StoreKey { 42 | public abstract fun getInsertionStrategy ()Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 43 | } 44 | 45 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Cursor : org/mobilenativefoundation/store/core5/StoreKey$Collection { 46 | public abstract fun getCursor ()Ljava/lang/Object; 47 | public abstract fun getFilters ()Ljava/util/List; 48 | public abstract fun getSize ()I 49 | public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 50 | } 51 | 52 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Page : org/mobilenativefoundation/store/core5/StoreKey$Collection { 53 | public abstract fun getFilters ()Ljava/util/List; 54 | public abstract fun getPage ()I 55 | public abstract fun getSize ()I 56 | public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 57 | } 58 | 59 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Filter { 60 | public abstract fun invoke (Ljava/util/List;)Ljava/util/List; 61 | } 62 | 63 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Single : org/mobilenativefoundation/store/core5/StoreKey { 64 | public abstract fun getId ()Ljava/lang/Object; 65 | } 66 | 67 | public final class org/mobilenativefoundation/store/core5/StoreKey$Sort : java/lang/Enum { 68 | public static final field ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 69 | public static final field NEWEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 70 | public static final field OLDEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 71 | public static final field REVERSE_ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 72 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 73 | public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 74 | public static fun values ()[Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 75 | } 76 | 77 | -------------------------------------------------------------------------------- /core/api/jvm/core.api: -------------------------------------------------------------------------------- 1 | public abstract interface annotation class org/mobilenativefoundation/store/core5/ExperimentalStoreApi : java/lang/annotation/Annotation { 2 | } 3 | 4 | public final class org/mobilenativefoundation/store/core5/InsertionStrategy : java/lang/Enum { 5 | public static final field APPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 6 | public static final field PREPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 7 | public static final field REPLACE Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 8 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 9 | public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 10 | public static fun values ()[Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 11 | } 12 | 13 | public abstract interface class org/mobilenativefoundation/store/core5/KeyProvider { 14 | public abstract fun fromCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Single; 15 | public abstract fun fromSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Collection; 16 | } 17 | 18 | public abstract interface class org/mobilenativefoundation/store/core5/StoreData { 19 | } 20 | 21 | public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Collection : org/mobilenativefoundation/store/core5/StoreData { 22 | public abstract fun copyWith (Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; 23 | public abstract fun getItems ()Ljava/util/List; 24 | public abstract fun insertItems (Lorg/mobilenativefoundation/store/core5/InsertionStrategy;Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; 25 | } 26 | 27 | public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Single : org/mobilenativefoundation/store/core5/StoreData { 28 | public abstract fun getId ()Ljava/lang/Object; 29 | } 30 | 31 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey { 32 | } 33 | 34 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection : org/mobilenativefoundation/store/core5/StoreKey { 35 | public abstract fun getInsertionStrategy ()Lorg/mobilenativefoundation/store/core5/InsertionStrategy; 36 | } 37 | 38 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Cursor : org/mobilenativefoundation/store/core5/StoreKey$Collection { 39 | public abstract fun getCursor ()Ljava/lang/Object; 40 | public abstract fun getFilters ()Ljava/util/List; 41 | public abstract fun getSize ()I 42 | public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 43 | } 44 | 45 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Page : org/mobilenativefoundation/store/core5/StoreKey$Collection { 46 | public abstract fun getFilters ()Ljava/util/List; 47 | public abstract fun getPage ()I 48 | public abstract fun getSize ()I 49 | public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 50 | } 51 | 52 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Filter { 53 | public abstract fun invoke (Ljava/util/List;)Ljava/util/List; 54 | } 55 | 56 | public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Single : org/mobilenativefoundation/store/core5/StoreKey { 57 | public abstract fun getId ()Ljava/lang/Object; 58 | } 59 | 60 | public final class org/mobilenativefoundation/store/core5/StoreKey$Sort : java/lang/Enum { 61 | public static final field ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 62 | public static final field NEWEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 63 | public static final field OLDEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 64 | public static final field REVERSE_ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 65 | public static fun getEntries ()Lkotlin/enums/EnumEntries; 66 | public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 67 | public static fun values ()[Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; 68 | } 69 | 70 | -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.mobilenativefoundation.store.multiplatform") 3 | } 4 | 5 | kotlin { 6 | 7 | sourceSets { 8 | val commonMain by getting { 9 | dependencies { 10 | implementation(libs.kotlin.stdlib) 11 | } 12 | } 13 | } 14 | } 15 | 16 | android { 17 | namespace = "org.mobilenativefoundation.store.core" 18 | } 19 | -------------------------------------------------------------------------------- /core/config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /core/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=org.mobilenativefoundation.store 2 | POM_ARTIFACT_ID=core5 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /core/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.core5 2 | 3 | /** 4 | * Marks declarations that are still **experimental** in store API. 5 | * Declarations marked with this annotation are unstable and subject to change. 6 | */ 7 | @MustBeDocumented 8 | @Retention(value = AnnotationRetention.BINARY) 9 | @RequiresOptIn(level = RequiresOptIn.Level.WARNING) 10 | annotation class ExperimentalStoreApi 11 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.core5 2 | 3 | @ExperimentalStoreApi 4 | enum class InsertionStrategy { 5 | APPEND, 6 | PREPEND, 7 | REPLACE, 8 | } 9 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.core5 2 | 3 | @ExperimentalStoreApi 4 | interface KeyProvider> { 5 | fun fromCollection( 6 | key: StoreKey.Collection, 7 | value: Single, 8 | ): StoreKey.Single 9 | 10 | fun fromSingle( 11 | key: StoreKey.Single, 12 | value: Single, 13 | ): StoreKey.Collection 14 | } 15 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.core5 2 | 3 | /** 4 | * An interface that defines items that can be uniquely identified. 5 | * Every item that implements the [StoreData] interface must have a means of identification. 6 | * This is useful in scenarios when data can be represented as singles or collections. 7 | */ 8 | @ExperimentalStoreApi 9 | interface StoreData { 10 | /** 11 | * Represents a single identifiable item. 12 | */ 13 | interface Single : StoreData { 14 | val id: Id 15 | } 16 | 17 | /** 18 | * Represents a collection of identifiable items. 19 | */ 20 | interface Collection> : StoreData { 21 | val items: List 22 | 23 | /** 24 | * Returns a new collection with the updated items. 25 | */ 26 | fun copyWith(items: List): Collection 27 | 28 | /** 29 | * Inserts items to the existing collection and returns the updated collection. 30 | */ 31 | fun insertItems( 32 | strategy: InsertionStrategy, 33 | items: List, 34 | ): Collection 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.core5 2 | 3 | /** 4 | * An interface that defines keys used by Store for data-fetching operations. 5 | * Allows Store to fetch individual items and collections of items. 6 | * Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. 7 | * Includes options for sorting and filtering. 8 | */ 9 | @ExperimentalStoreApi 10 | interface StoreKey { 11 | /** 12 | * Represents a key for fetching an individual item. 13 | */ 14 | interface Single : StoreKey { 15 | val id: Id 16 | } 17 | 18 | /** 19 | * Represents a key for fetching collections of items. 20 | */ 21 | interface Collection : StoreKey { 22 | val insertionStrategy: InsertionStrategy 23 | 24 | /** 25 | * Represents a key for page-based fetching. 26 | */ 27 | interface Page : Collection { 28 | val page: Int 29 | val size: Int 30 | val sort: Sort? 31 | val filters: List>? 32 | } 33 | 34 | /** 35 | * Represents a key for cursor-based fetching. 36 | */ 37 | interface Cursor : Collection { 38 | val cursor: Id? 39 | val size: Int 40 | val sort: Sort? 41 | val filters: List>? 42 | } 43 | } 44 | 45 | /** 46 | * An enum defining sorting options that can be applied during fetching. 47 | */ 48 | enum class Sort { 49 | NEWEST, 50 | OLDEST, 51 | ALPHABETICAL, 52 | REVERSE_ALPHABETICAL, 53 | } 54 | 55 | /** 56 | * Defines filters that can be applied during fetching. 57 | */ 58 | interface Filter { 59 | operator fun invoke(items: List): List 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # don't use jetifier, all deps are in androidX already android.enableJetifier=true 2 | android.useAndroidX=true 3 | org.gradle.caching=true 4 | org.gradle.configureondemand=true 5 | 6 | # https://github.com/Kotlin/dokka/issues/1405 7 | org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G 8 | 9 | # POM file 10 | GROUP=org.mobilenativefoundation.store 11 | VERSION_NAME=5.1.0-alpha06 12 | POM_PACKAGING=pom 13 | POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer 14 | 15 | POM_URL = https://github.com/MobileNativeFoundation/Store 16 | POM_SCM_URL=https://github.com/MobileNativeFoundation/Store 17 | POM_SCM_CONNECTION=scm:git:https://github.com/MobileNativeFoundation/Store.git 18 | POM_SCM_DEV_CONNECTION=scm:git:git@github.com:MobileNativeFoundation/Store.git 19 | POM_LICENCE_NAME=Apache License 20 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0 21 | POM_LICENCE_DIST=repo 22 | POM_DEVELOPER_ID=dropbox 23 | POM_DEVELOPER_NAME=Dropbox 24 | kotlinx.atomicfu.enableJvmIrTransformation=false 25 | kotlinx.atomicfu.enableJsIrTransformation=false 26 | kotlin.js.compiler=ir 27 | 28 | org.jetbrains.compose.experimental.uikit.enabled=true -------------------------------------------------------------------------------- /gradle/jacoco.gradle: -------------------------------------------------------------------------------- 1 | // Merge of 2 | // https://github.com/mgouline/android-samples/blob/master/jacoco/app/build.gradle 3 | // and https://github.com/pushtorefresh/storio/blob/master/gradle/jacoco-android.gradle 4 | 5 | // Enables code coverage for JVM tests. 6 | apply plugin: "jacoco" 7 | 8 | jacoco { 9 | toolVersion = "0.8.12" 10 | } 11 | // Android Gradle Plugin out of the box supports only code coverage for instrumentation tests. 12 | // Creates a task that will merge coverage for all projects 13 | project.afterEvaluate { 14 | def testTaskName = "test" 15 | def coverageTaskName = "${testTaskName}Coverage" 16 | 17 | // Create coverage task of form 'testFlavorTypeUnitTestCoverage' depending on 'testFlavorTypeUnitTest' 18 | task "${coverageTaskName}"(type: JacocoReport, dependsOn: "$testTaskName") { 19 | group = 'Reporting' 20 | description = "Generate Jacoco coverage reports for the build." 21 | 22 | List fileFilter = ['**/R.class', 23 | '**/R$*.class', 24 | '**/*$ViewInjector*.*', 25 | '**/*$ViewBinder*.*', 26 | '**/BuildConfig.*', 27 | '**/Manifest*.*', 28 | '**/*$Lambda$*.*', // Jacoco can not handle several "$" in class name. 29 | '**/*$inlined$*.*', // Kotlin specific, Jacoco can not handle several "$" in class name. 30 | '**/*Module.*', // Modules for Dagger. 31 | '**/*Dagger*.*', // Dagger auto-generated code. 32 | '**/*MembersInjector*.*', // Dagger auto-generated code. 33 | '**/*_Provide*Factory*.*', // Dagger auto-generated code. 34 | '**/atomicfu', // AtomicFU auto-generated code. 35 | '**/test/**/*.*', // Test code 36 | ] 37 | def kotlinFileTree = fileTree( 38 | dir: "${project.buildDir}/classes", 39 | excludes: fileFilter) 40 | logger.debug("Kotlin classes dirs: " + kotlinFileTree) 41 | 42 | classDirectories.setFrom kotlinFileTree 43 | 44 | def coverageSourceDirs = new File(project.projectDir, "src/main/java") 45 | def coverageData = fileTree(dir: new File(project.buildDir, 'jacoco'), include: '*.exec') 46 | 47 | logger.debug("Coverage source dirs: " + coverageSourceDirs) 48 | logger.debug("Coverage execution data: " + executionData) 49 | 50 | additionalSourceDirs.setFrom files(coverageSourceDirs) 51 | sourceDirectories.setFrom files(coverageSourceDirs) 52 | executionData.setFrom files(coverageData) 53 | 54 | reports { 55 | xml.enabled = true 56 | xml.destination = file("${buildDir}/reports/jacoco/report.xml") 57 | html.enabled = true 58 | } 59 | } 60 | 61 | check.dependsOn "${coverageTaskName}" 62 | } 63 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | androidMinSdk = "24" 3 | androidCompileSdk = "33" 4 | androidGradlePlugin = "7.4.2" 5 | androidTargetSdk = "33" 6 | atomicFu = "0.24.0" 7 | baseKotlin = "2.0.20" 8 | dokkaGradlePlugin = "1.9.20" 9 | ktlintGradle = "12.1.0" 10 | jacocoGradlePlugin = "0.8.12" 11 | mavenPublishPlugin = "0.22.0" 12 | moleculeGradlePlugin = "1.2.1" 13 | pagingCompose = "3.3.0-alpha02" 14 | pagingRuntime = "3.2.1" 15 | spotlessPluginGradle = "6.4.1" 16 | junit = "4.13.2" 17 | kotlinxCoroutines = "1.8.1" 18 | kotlinxSerialization = "1.6.3" 19 | kermit = "2.0.5" 20 | testCore = "1.6.1" 21 | kmmBridge = "0.3.2" 22 | ktlint = "0.39.0" 23 | kover = "0.9.0-RC" 24 | store = "5.1.0-alpha06" 25 | truth = "1.1.3" 26 | turbine = "1.2.0" 27 | binary-compatibility-validator = "0.15.0-Beta.2" 28 | 29 | [libraries] 30 | android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } 31 | androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" } 32 | androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } 33 | kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } 34 | kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } 35 | dokka-gradle-plugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokkaGradlePlugin" } 36 | ktlint-gradle-plugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlintGradle" } 37 | jacoco-gradle-plugin = { group = "org.jacoco", name = "org.jacoco.core", version.ref = "jacocoGradlePlugin" } 38 | maven-publish-plugin = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "mavenPublishPlugin" } 39 | kover-gradle-plugin = {group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover"} 40 | 41 | atomic-fu-gradle-plugin = { group = "org.jetbrains.kotlinx", name = "atomicfu-gradle-plugin", version.ref = "atomicFu" } 42 | kmmBridge-gradle-plugin = { group = "co.touchlab.faktory.kmmbridge", name = "co.touchlab.faktory.kmmbridge.gradle.plugin", version.ref = "kmmBridge" } 43 | 44 | kotlinx-atomic-fu = { group = "org.jetbrains.kotlinx", name = "atomicfu", version.ref = "atomicFu" } 45 | kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "baseKotlin" } 46 | kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } 47 | kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } 48 | kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } 49 | kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } 50 | kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutines" } 51 | kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.6.2" } 52 | molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "moleculeGradlePlugin" } 53 | molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "moleculeGradlePlugin" } 54 | rxjava = { group = "io.reactivex.rxjava2", name = "rxjava", version = "2.2.21" } 55 | androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } 56 | kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } 57 | junit = { group = "junit", name = "junit", version.ref = "junit" } 58 | google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } 59 | touchlab-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } 60 | turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } 61 | binary-compatibility-validator = {module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "binary-compatibility-validator"} 62 | 63 | [plugins] 64 | ktlint = {id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle"} 65 | binary-compatibility-validator = {id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator"} 66 | kover = {id = "org.jetbrains.kotlinx.kover", version.ref = "kover"} 67 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/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.6-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /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 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /multicast/api/android/multicast.api: -------------------------------------------------------------------------------- 1 | public final class org/mobilenativefoundation/store/multicast/BuildConfig { 2 | public static final field BUILD_TYPE Ljava/lang/String; 3 | public static final field DEBUG Z 4 | public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; 5 | public fun ()V 6 | } 7 | 8 | public final class org/mobilenativefoundation/store/multicast5/Multicaster { 9 | public fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;)V 10 | public synthetic fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 11 | public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 12 | public final fun newDownstream (Z)Lkotlinx/coroutines/flow/Flow; 13 | public static synthetic fun newDownstream$default (Lorg/mobilenativefoundation/store/multicast5/Multicaster;ZILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /multicast/api/jvm/multicast.api: -------------------------------------------------------------------------------- 1 | public final class org/mobilenativefoundation/store/multicast5/Multicaster { 2 | public fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;)V 3 | public synthetic fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 4 | public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 5 | public final fun newDownstream (Z)Lkotlinx/coroutines/flow/Flow; 6 | public static synthetic fun newDownstream$default (Lorg/mobilenativefoundation/store/multicast5/Multicaster;ZILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /multicast/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.mobilenativefoundation.store.multiplatform") 3 | } 4 | 5 | kotlin { 6 | 7 | sourceSets { 8 | 9 | val commonMain by getting { 10 | dependencies { 11 | api(libs.kotlinx.atomic.fu) 12 | implementation(libs.kotlinx.coroutines.core) 13 | } 14 | } 15 | 16 | val commonTest by getting { 17 | dependencies { 18 | implementation(libs.junit) 19 | implementation(libs.kotlinx.coroutines.test) 20 | implementation(libs.turbine) 21 | } 22 | } 23 | } 24 | } 25 | 26 | android { 27 | namespace = "org.mobilenativefoundation.store.multicast" 28 | } 29 | -------------------------------------------------------------------------------- /multicast/config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /multicast/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=org.mobilenativefoundation.store 2 | POM_ARTIFACT_ID=multicast5 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /multicast/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.multicast5 2 | 3 | import kotlinx.coroutines.CompletionHandler 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.channels.ReceiveChannel 7 | import kotlinx.coroutines.channels.SendChannel 8 | import kotlinx.coroutines.isActive 9 | import kotlinx.coroutines.launch 10 | import kotlin.coroutines.CoroutineContext 11 | import kotlin.coroutines.EmptyCoroutineContext 12 | 13 | /* 14 | * Credits to nickallendev 15 | * https://discuss.kotlinlang.org/t/actor-kotlin-common/19569 16 | */ 17 | internal fun CoroutineScope.actor( 18 | context: CoroutineContext = EmptyCoroutineContext, 19 | capacity: Int = 0, 20 | onCompletion: CompletionHandler? = null, 21 | block: suspend CoroutineScope.(ReceiveChannel) -> Unit, 22 | ): SendChannel { 23 | val channel = Channel(capacity) 24 | val job = 25 | launch(context) { 26 | try { 27 | block(channel) 28 | } finally { 29 | if (isActive) channel.cancel() 30 | } 31 | } 32 | if (onCompletion != null) job.invokeOnCompletion(handler = onCompletion) 33 | return channel 34 | } 35 | -------------------------------------------------------------------------------- /multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.multicast5 17 | 18 | import kotlinx.coroutines.CompletableDeferred 19 | import kotlinx.coroutines.CoroutineScope 20 | import kotlinx.coroutines.CoroutineStart 21 | import kotlinx.coroutines.Job 22 | import kotlinx.coroutines.cancelAndJoin 23 | import kotlinx.coroutines.channels.ClosedSendChannelException 24 | import kotlinx.coroutines.flow.Flow 25 | import kotlinx.coroutines.flow.catch 26 | import kotlinx.coroutines.launch 27 | 28 | /** 29 | * A flow collector that works with a [ChannelManager] to collect values from an upstream flow 30 | * and dispatch to the [sendUpsteamMessage] which then dispatches to downstream collectors. 31 | * 32 | * They work in sync such that this producer always expects an ack from the [ChannelManager] after 33 | * sending an event. 34 | * 35 | * Cancellation of the collection might be triggered by both this producer (e.g. upstream completes) 36 | * or the [ChannelManager] (e.g. all active collectors complete). 37 | */ 38 | internal class SharedFlowProducer( 39 | private val scope: CoroutineScope, 40 | private val src: Flow, 41 | private val sendUpsteamMessage: suspend (ChannelManager.Message.Dispatch) -> Unit, 42 | ) { 43 | private val collectionJob: Job = 44 | scope.launch(start = CoroutineStart.LAZY) { 45 | try { 46 | src.catch { 47 | sendUpsteamMessage(ChannelManager.Message.Dispatch.Error(it)) 48 | }.collect { 49 | val ack = CompletableDeferred() 50 | sendUpsteamMessage( 51 | ChannelManager.Message.Dispatch.Value( 52 | it, 53 | ack, 54 | ), 55 | ) 56 | // suspend until at least 1 receives the new value 57 | ack.await() 58 | } 59 | } catch (closed: ClosedSendChannelException) { 60 | // ignore. if consumers are gone, it might close itself. 61 | } 62 | } 63 | 64 | /** 65 | * Starts the collection of the upstream flow. 66 | */ 67 | fun start() { 68 | scope.launch { 69 | try { 70 | // trigger start of the collection and wait until collection ends, either due to an 71 | // error or ordered by the channel manager 72 | collectionJob.join() 73 | } finally { 74 | // cleanup the channel manager so that downstreams can be closed if they are not 75 | // closed already and leftovers can be moved to a new producer if necessary. 76 | try { 77 | sendUpsteamMessage(ChannelManager.Message.Dispatch.UpstreamFinished(this@SharedFlowProducer)) 78 | } catch (closed: ClosedSendChannelException) { 79 | // it might close before us, its fine. 80 | } 81 | } 82 | } 83 | } 84 | 85 | suspend fun cancelAndJoin() { 86 | collectionJob.cancelAndJoin() 87 | } 88 | 89 | fun cancel() { 90 | collectionJob.cancel() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.multicast5 17 | 18 | import kotlinx.atomicfu.atomic 19 | import kotlinx.coroutines.CompletableDeferred 20 | import kotlinx.coroutines.CoroutineScope 21 | import kotlinx.coroutines.channels.ClosedSendChannelException 22 | import kotlinx.coroutines.channels.SendChannel 23 | 24 | /** 25 | * Simple actor implementation abstracting away Coroutine.actor since it is deprecated. 26 | * It also enforces a 0 capacity buffer. 27 | */ 28 | @Suppress("EXPERIMENTAL_API_USAGE") 29 | internal abstract class StoreRealActor( 30 | scope: CoroutineScope, 31 | ) { 32 | private val inboundChannel: SendChannel 33 | private val closeCompleted = CompletableDeferred() 34 | private val didClose = atomic(false) 35 | 36 | init { 37 | inboundChannel = 38 | scope.actor( 39 | capacity = 0, 40 | ) { 41 | try { 42 | for (msg in it) { 43 | if (msg === CLOSE_TOKEN) { 44 | doClose() 45 | break 46 | } else { 47 | @Suppress("UNCHECKED_CAST") 48 | handle(msg as T) 49 | } 50 | } 51 | } finally { 52 | doClose() 53 | } 54 | } 55 | } 56 | 57 | private fun doClose() { 58 | if (didClose.compareAndSet(expect = false, update = true)) { 59 | try { 60 | onClosed() 61 | } finally { 62 | inboundChannel.close() 63 | closeCompleted.complete(Unit) 64 | } 65 | } 66 | } 67 | 68 | open fun onClosed() = Unit 69 | 70 | abstract suspend fun handle(msg: T) 71 | 72 | suspend fun send(msg: T) { 73 | inboundChannel.send(msg) 74 | } 75 | 76 | suspend fun close() { 77 | try { 78 | // using a custom token to close so that we can gracefully close the downstream 79 | inboundChannel.send(CLOSE_TOKEN) 80 | // wait until close is done done 81 | closeCompleted.await() 82 | } catch (closed: ClosedSendChannelException) { 83 | // already closed, ignore 84 | } 85 | } 86 | 87 | companion object { 88 | val CLOSE_TOKEN = Any() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.multicast5 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.channels.Channel 7 | import kotlinx.coroutines.flow.consumeAsFlow 8 | import kotlinx.coroutines.flow.filterIsInstance 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.coroutines.flow.onEach 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.sync.Mutex 13 | import kotlinx.coroutines.sync.withLock 14 | import kotlinx.coroutines.test.runTest 15 | import kotlin.test.Test 16 | import kotlin.test.assertEquals 17 | 18 | class StoreChannelManagerTests { 19 | @Test 20 | fun cancelledDownstreamChannelShouldNotCancelOtherChannels() = 21 | runTest { 22 | val coroutineScope = CoroutineScope(Dispatchers.Default) 23 | val lockUpstream = Mutex(true) 24 | val testMessages = listOf(1, 2, 3) 25 | val numChannels = 20 26 | val upstreamFlow = 27 | flow { 28 | lockUpstream.withLock { 29 | testMessages.onEach { emit(it) } 30 | } 31 | } 32 | val channelManager = 33 | StoreChannelManager( 34 | scope = coroutineScope, 35 | bufferSize = 0, 36 | upstream = upstreamFlow, 37 | piggybackingDownstream = false, 38 | keepUpstreamAlive = false, 39 | onEach = { }, 40 | ) 41 | val channels = createChannels(numChannels) 42 | val channelToBeCancelled = 43 | Channel>(Channel.UNLIMITED) 44 | .also { channel -> 45 | coroutineScope.launch { 46 | channel.consumeAsFlow().test { 47 | cancelAndIgnoreRemainingEvents() 48 | } 49 | } 50 | } 51 | coroutineScope.launch { 52 | channels.forEach { channelManager.addDownstream(it) } 53 | lockUpstream.unlock() 54 | } 55 | coroutineScope.launch { 56 | channelManager.addDownstream(channelToBeCancelled) 57 | } 58 | 59 | channels.forEach { channel -> 60 | val messagesFlow = 61 | channel.consumeAsFlow() 62 | .filterIsInstance>() 63 | .onEach { it.delivered.complete(Unit) } 64 | 65 | messagesFlow.test { 66 | for (message in testMessages) { 67 | val dispatchValue = awaitItem() 68 | assertEquals(message, dispatchValue.value) 69 | } 70 | awaitComplete() 71 | } 72 | } 73 | } 74 | 75 | private fun createChannels(count: Int): List>> { 76 | return (1..count).map { Channel(Channel.UNLIMITED) } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | Closes # (Issue number) 2 | 3 | ## Description 4 | What changes are you introducing with this PR? Please include motivation and context. 5 | 6 | ## Type of Change 7 | - [ ] Bug fix (non-breaking change which fixes an issue) 8 | - [ ] New feature (non-breaking change which adds functionality) 9 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 10 | - [ ] This change requires a documentation update 11 | 12 | ## Test Plan 13 | How has this been tested? Please describe the tests that you added to verify your changes. 14 | 15 | ## Checklist: 16 | Before submitting your PR, please review and check all of the following: 17 | - [ ] I have performed a self-review of my own code 18 | - [ ] I have commented my code, particularly in hard-to-understand areas 19 | - [ ] I have made corresponding changes to the documentation 20 | - [ ] My changes generate no new warnings 21 | - [ ] I have added tests that prove my change is effective 22 | - [ ] New and existing unit tests pass locally with my changes 23 | 24 | ## Additional Notes: 25 | Add any other information about the PR here. 26 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /rx2/api/rx2.api: -------------------------------------------------------------------------------- 1 | public final class org/mobilenativefoundation/store/rx2/BuildConfig { 2 | public static final field BUILD_TYPE Ljava/lang/String; 3 | public static final field DEBUG Z 4 | public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; 5 | public fun ()V 6 | } 7 | 8 | public final class org/mobilenativefoundation/store/rx2/RxFetcherKt { 9 | public static final fun ofFlowable (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; 10 | public static final fun ofResultFlowable (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; 11 | public static final fun ofResultSingle (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; 12 | public static final fun ofSingle (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; 13 | } 14 | 15 | public final class org/mobilenativefoundation/store/rx2/RxSourceOfTruthKt { 16 | public static final fun ofFlowable (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; 17 | public static synthetic fun ofFlowable$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; 18 | public static final fun ofMaybe (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; 19 | public static synthetic fun ofMaybe$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; 20 | } 21 | 22 | public final class org/mobilenativefoundation/store/rx2/RxStoreBuilderKt { 23 | public static final fun withScheduler (Lorg/mobilenativefoundation/store/store5/StoreBuilder;Lio/reactivex/Scheduler;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; 24 | } 25 | 26 | public final class org/mobilenativefoundation/store/rx2/RxStoreKt { 27 | public static final fun freshSingle (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Single; 28 | public static final fun getSingle (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Single; 29 | public static final fun observe (Lorg/mobilenativefoundation/store/store5/Store;Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lio/reactivex/Flowable; 30 | public static final fun observeClear (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Completable; 31 | public static final fun observeClearAll (Lorg/mobilenativefoundation/store/store5/Store;)Lio/reactivex/Completable; 32 | } 33 | 34 | -------------------------------------------------------------------------------- /rx2/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | plugins { 4 | id("org.mobilenativefoundation.store.android") 5 | } 6 | 7 | dependencies { 8 | implementation(libs.kotlinx.coroutines.rx2) 9 | implementation(libs.kotlinx.coroutines.core) 10 | implementation(libs.kotlinx.coroutines.android) 11 | implementation(libs.rxjava) 12 | implementation(projects.store) 13 | 14 | testImplementation(kotlin("test")) 15 | testImplementation(libs.junit) 16 | testImplementation(libs.google.truth) 17 | testImplementation(libs.androidx.test.core) 18 | testImplementation(libs.kotlinx.coroutines.test) 19 | } 20 | 21 | android { 22 | namespace = "org.mobilenativefoundation.store.rx2" 23 | } 24 | -------------------------------------------------------------------------------- /rx2/config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /rx2/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=org.mobilenativefoundation.store 2 | POM_ARTIFACT_ID=rx2 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /rx2/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2 2 | 3 | import io.reactivex.Flowable 4 | import io.reactivex.Single 5 | import kotlinx.coroutines.reactive.asFlow 6 | import org.mobilenativefoundation.store.store5.Fetcher 7 | import org.mobilenativefoundation.store.store5.FetcherResult 8 | import org.mobilenativefoundation.store.store5.Store 9 | 10 | /** 11 | * Creates a new [Fetcher] from a [flowableFactory]. 12 | * 13 | * [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These 14 | * exception will be propagated to the caller. 15 | * 16 | * Use when creating a [Store] that fetches objects in a multiple responses per request 17 | * network protocol (e.g Web Sockets). 18 | * 19 | * @param flowableFactory a factory for a [Flowable] source of network records. 20 | */ 21 | fun Fetcher.Companion.ofResultFlowable( 22 | flowableFactory: (key: Key) -> Flowable>, 23 | ): Fetcher = ofResultFlow { key: Key -> flowableFactory(key).asFlow() } 24 | 25 | /** 26 | * "Creates" a [Fetcher] from a [singleFactory]. 27 | * 28 | * [Store] does not catch exception thrown in [singleFactory] or in the returned [Single]. These 29 | * exception will be propagated to the caller. 30 | * 31 | * Use when creating a [Store] that fetches objects in a single response per request network 32 | * protocol (e.g Http). 33 | * 34 | * @param singleFactory a factory for a [Single] source of network records. 35 | */ 36 | fun Fetcher.Companion.ofResultSingle( 37 | singleFactory: (key: Key) -> Single>, 38 | ): Fetcher = ofResultFlowable { key: Key -> singleFactory(key).toFlowable() } 39 | 40 | /** 41 | * "Creates" a [Fetcher] from a [flowableFactory] and translate the results to a [FetcherResult]. 42 | * 43 | * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the stream then 44 | * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are 45 | * not caught and will be returned to the caller. 46 | * 47 | * Use when creating a [Store] that fetches objects in a multiple responses per request 48 | * network protocol (e.g Web Sockets). 49 | * 50 | * @param flowFactory a factory for a [Flowable] source of network records. 51 | */ 52 | fun Fetcher.Companion.ofFlowable(flowableFactory: (key: Key) -> Flowable): Fetcher = 53 | ofFlow { key: Key -> flowableFactory(key).asFlow() } 54 | 55 | /** 56 | * Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult]. 57 | * 58 | * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then 59 | * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are 60 | * not caught and will be returned to the caller. 61 | * 62 | * Use when creating a [Store] that fetches objects in a single response per request network 63 | * protocol (e.g Http). 64 | * 65 | * @param singleFactory a factory for a [Single] source of network records. 66 | */ 67 | fun Fetcher.Companion.ofSingle(singleFactory: (key: Key) -> Single): Fetcher = 68 | ofFlowable { key: Key -> singleFactory(key).toFlowable() } 69 | -------------------------------------------------------------------------------- /rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.Flowable 5 | import io.reactivex.Maybe 6 | import kotlinx.coroutines.reactive.asFlow 7 | import kotlinx.coroutines.rx2.await 8 | import kotlinx.coroutines.rx2.awaitSingleOrNull 9 | import org.mobilenativefoundation.store.store5.SourceOfTruth 10 | 11 | /** 12 | * Creates a [Maybe] source of truth that is accessible via [reader], [writer], [delete] and 13 | * [deleteAll]. 14 | * 15 | * @param reader function for reading records from the source of truth 16 | * @param writer function for writing updates to the backing source of truth 17 | * @param delete function for deleting records in the source of truth for the given key 18 | * @param deleteAll function for deleting all records in the source of truth 19 | * 20 | */ 21 | fun SourceOfTruth.Companion.ofMaybe( 22 | reader: (Key) -> Maybe, 23 | writer: (Key, Local) -> Completable, 24 | delete: ((Key) -> Completable)? = null, 25 | deleteAll: (() -> Completable)? = null, 26 | ): SourceOfTruth { 27 | val deleteFun: (suspend (Key) -> Unit)? = 28 | if (delete != null) { key -> delete(key).await() } else null 29 | val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } 30 | return of( 31 | nonFlowReader = { key -> reader.invoke(key).awaitSingleOrNull() }, 32 | writer = { key, output -> writer.invoke(key, output).await() }, 33 | delete = deleteFun, 34 | deleteAll = deleteAllFun, 35 | ) 36 | } 37 | 38 | /** 39 | * Creates a ([Flowable]) source of truth that is accessed via [reader], [writer], [delete] and 40 | * [deleteAll]. 41 | * 42 | * @param reader function for reading records from the source of truth 43 | * @param writer function for writing updates to the backing source of truth 44 | * @param delete function for deleting records in the source of truth for the given key 45 | * @param deleteAll function for deleting all records in the source of truth 46 | * 47 | */ 48 | fun SourceOfTruth.Companion.ofFlowable( 49 | reader: (Key) -> Flowable, 50 | writer: (Key, Local) -> Completable, 51 | delete: ((Key) -> Completable)? = null, 52 | deleteAll: (() -> Completable)? = null, 53 | ): SourceOfTruth { 54 | val deleteFun: (suspend (Key) -> Unit)? = 55 | if (delete != null) { key -> delete(key).await() } else null 56 | val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } 57 | return of( 58 | reader = { key -> reader.invoke(key).asFlow() }, 59 | writer = { key, output -> writer.invoke(key, output).await() }, 60 | delete = deleteFun, 61 | deleteAll = deleteAllFun, 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.Flowable 5 | import kotlinx.coroutines.rx2.asFlowable 6 | import kotlinx.coroutines.rx2.rxCompletable 7 | import kotlinx.coroutines.rx2.rxSingle 8 | import org.mobilenativefoundation.store.core5.ExperimentalStoreApi 9 | import org.mobilenativefoundation.store.store5.Store 10 | import org.mobilenativefoundation.store.store5.StoreBuilder 11 | import org.mobilenativefoundation.store.store5.StoreReadRequest 12 | import org.mobilenativefoundation.store.store5.StoreReadResponse 13 | import org.mobilenativefoundation.store.store5.impl.extensions.fresh 14 | import org.mobilenativefoundation.store.store5.impl.extensions.get 15 | 16 | /** 17 | * Return a [Flowable] for the given key 18 | * @param request - see [StoreReadRequest] for configurations 19 | */ 20 | fun Store.observe(request: StoreReadRequest): Flowable> = 21 | stream(request).asFlowable() 22 | 23 | /** 24 | * Purge a particular entry from memory and disk cache. 25 | * Persistent storage will only be cleared if a delete function was passed to 26 | * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. 27 | */ 28 | fun Store.observeClear(key: Key): Completable = rxCompletable { clear(key) } 29 | 30 | /** 31 | * Purge all entries from memory and disk cache. 32 | * Persistent storage will only be cleared if a deleteAll function was passed to 33 | * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. 34 | */ 35 | @ExperimentalStoreApi 36 | fun Store.observeClearAll(): Completable = rxCompletable { clear() } 37 | 38 | /** 39 | * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) 40 | */ 41 | fun Store.getSingle(key: Key) = rxSingle { this@getSingle.get(key) } 42 | 43 | /** 44 | * Helper factory that will return fresh data as a [Single] for [key] while updating your caches 45 | */ 46 | fun Store.freshSingle(key: Key) = rxSingle { this@freshSingle.fresh(key) } 47 | -------------------------------------------------------------------------------- /rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2 2 | 3 | import io.reactivex.Scheduler 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.GlobalScope 6 | import kotlinx.coroutines.rx2.asCoroutineDispatcher 7 | import org.mobilenativefoundation.store.store5.Store 8 | import org.mobilenativefoundation.store.store5.StoreBuilder 9 | 10 | /** 11 | * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default 12 | * [Store] will open a global scope for management of shared responses, if instead you'd like to control 13 | * the scheduler that sharing/multicasting happens in you can pass a @param [scheduler] 14 | * 15 | * Note this does not control what scheduler a response is emitted on but rather what thread/scheduler 16 | * to use when managing in flight responses. This is usually used for things like testing where you 17 | * may want to confine to a scheduler backed by a single thread executor 18 | * 19 | * @param scheduler - scheduler to use for sharing 20 | * if a scheduler is not set Store will use [GlobalScope] 21 | */ 22 | fun StoreBuilder.withScheduler(scheduler: Scheduler): StoreBuilder { 23 | return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) 24 | } 25 | -------------------------------------------------------------------------------- /rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2.test 2 | 3 | /* 4 | * Copyright 2019 Google LLC 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 | 19 | import com.google.common.truth.FailureMetadata 20 | import com.google.common.truth.Subject 21 | import com.google.common.truth.Truth 22 | import com.google.common.truth.Truth.assertWithMessage 23 | import kotlinx.coroutines.ExperimentalCoroutinesApi 24 | import kotlinx.coroutines.async 25 | import kotlinx.coroutines.cancelAndJoin 26 | import kotlinx.coroutines.flow.Flow 27 | import kotlinx.coroutines.test.TestCoroutineScope 28 | import kotlinx.coroutines.test.advanceUntilIdle 29 | 30 | @OptIn(ExperimentalCoroutinesApi::class) 31 | internal fun TestCoroutineScope.assertThat(flow: Flow): FlowSubject { 32 | return Truth.assertAbout(FlowSubject.Factory(this)).that(flow) 33 | } 34 | 35 | @OptIn(ExperimentalCoroutinesApi::class) 36 | internal class FlowSubject constructor( 37 | failureMetadata: FailureMetadata, 38 | private val testCoroutineScope: TestCoroutineScope, 39 | private val actual: Flow, 40 | ) : Subject(failureMetadata, actual) { 41 | /** 42 | * Takes all items in the flow that are available by collecting on it as long as there are 43 | * active jobs in the given [TestCoroutineScope]. 44 | * 45 | * It ensures all expected items are dispatched as well as no additional unexpected items are 46 | * dispatched. 47 | */ 48 | suspend fun emitsExactly(vararg expected: T) { 49 | val collectedSoFar = mutableListOf() 50 | val collectionCoroutine = 51 | testCoroutineScope.async { 52 | actual.collect { 53 | collectedSoFar.add(it) 54 | if (collectedSoFar.size > expected.size) { 55 | assertWithMessage("Too many emissions in the flow (only first additional item is shown)") 56 | .that(collectedSoFar) 57 | .isEqualTo(expected) 58 | } 59 | } 60 | } 61 | testCoroutineScope.advanceUntilIdle() 62 | if (!collectionCoroutine.isActive) { 63 | collectionCoroutine.getCompletionExceptionOrNull()?.let { 64 | throw it 65 | } 66 | } 67 | collectionCoroutine.cancelAndJoin() 68 | assertWithMessage("Flow didn't exactly emit expected items") 69 | .that(collectedSoFar) 70 | .isEqualTo(expected.toList()) 71 | } 72 | 73 | class Factory( 74 | private val testCoroutineScope: TestCoroutineScope, 75 | ) : Subject.Factory, Flow> { 76 | override fun createSubject( 77 | metadata: FailureMetadata, 78 | actual: Flow, 79 | ): FlowSubject { 80 | return FlowSubject( 81 | failureMetadata = metadata, 82 | actual = actual, 83 | testCoroutineScope = testCoroutineScope, 84 | ) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2.test 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import io.reactivex.Single 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.FlowPreview 7 | import kotlinx.coroutines.test.TestCoroutineScope 8 | import kotlinx.coroutines.test.runBlockingTest 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.junit.runners.JUnit4 12 | import org.mobilenativefoundation.store.rx2.ofResultSingle 13 | import org.mobilenativefoundation.store.store5.Fetcher 14 | import org.mobilenativefoundation.store.store5.FetcherResult 15 | import org.mobilenativefoundation.store.store5.StoreBuilder 16 | import org.mobilenativefoundation.store.store5.StoreReadRequest 17 | import org.mobilenativefoundation.store.store5.StoreReadResponse 18 | import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin 19 | 20 | @RunWith(JUnit4::class) 21 | @FlowPreview 22 | @ExperimentalCoroutinesApi 23 | class HotRxSingleStoreTest { 24 | private val testScope = TestCoroutineScope() 25 | 26 | @Test 27 | fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = 28 | testScope.runBlockingTest { 29 | val fetcher: FakeRxFetcher> = 30 | FakeRxFetcher( 31 | 3 to FetcherResult.Data("three-1"), 32 | 3 to FetcherResult.Data("three-2"), 33 | ) 34 | val pipeline = 35 | StoreBuilder.from(Fetcher.ofResultSingle { fetcher.fetch(it) }) 36 | .scope(testScope) 37 | .build() 38 | 39 | assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false))) 40 | .emitsExactly( 41 | StoreReadResponse.Loading( 42 | origin = StoreReadResponseOrigin.Fetcher(), 43 | ), 44 | StoreReadResponse.Data( 45 | value = "three-1", 46 | origin = StoreReadResponseOrigin.Fetcher(), 47 | ), 48 | ) 49 | assertThat( 50 | pipeline.stream(StoreReadRequest.cached(3, refresh = false)), 51 | ).emitsExactly( 52 | StoreReadResponse.Data( 53 | value = "three-1", 54 | origin = StoreReadResponseOrigin.Cache, 55 | ), 56 | ) 57 | 58 | assertThat(pipeline.stream(StoreReadRequest.fresh(3))) 59 | .emitsExactly( 60 | StoreReadResponse.Loading( 61 | origin = StoreReadResponseOrigin.Fetcher(), 62 | ), 63 | StoreReadResponse.Data( 64 | value = "three-2", 65 | origin = StoreReadResponseOrigin.Fetcher(), 66 | ), 67 | ) 68 | } 69 | } 70 | 71 | class FakeRxFetcher( 72 | vararg val responses: Pair, 73 | ) { 74 | private var index = 0 75 | 76 | @Suppress("RedundantSuspendModifier") // needed for function reference 77 | fun fetch(key: Key): Single { 78 | // will throw if fetcher called more than twice 79 | if (index >= responses.size) { 80 | throw AssertionError("unexpected fetch request") 81 | } 82 | val pair = responses[index++] 83 | assertThat(pair.first).isEqualTo(key) 84 | return Single.just(pair.second) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.rx2.test 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.Maybe 5 | import io.reactivex.Single 6 | import io.reactivex.schedulers.Schedulers 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.FlowPreview 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.junit.runners.JUnit4 12 | import org.mobilenativefoundation.store.core5.ExperimentalStoreApi 13 | import org.mobilenativefoundation.store.rx2.freshSingle 14 | import org.mobilenativefoundation.store.rx2.getSingle 15 | import org.mobilenativefoundation.store.rx2.ofMaybe 16 | import org.mobilenativefoundation.store.rx2.ofResultSingle 17 | import org.mobilenativefoundation.store.rx2.withScheduler 18 | import org.mobilenativefoundation.store.store5.Fetcher 19 | import org.mobilenativefoundation.store.store5.FetcherResult 20 | import org.mobilenativefoundation.store.store5.SourceOfTruth 21 | import org.mobilenativefoundation.store.store5.StoreBuilder 22 | import java.util.concurrent.atomic.AtomicInteger 23 | 24 | @ExperimentalStoreApi 25 | @RunWith(JUnit4::class) 26 | @FlowPreview 27 | @ExperimentalCoroutinesApi 28 | class RxSingleStoreExtensionsTest { 29 | private val atomicInteger = AtomicInteger(0) 30 | private var fakeDisk = mutableMapOf() 31 | private val store = 32 | StoreBuilder.from( 33 | fetcher = 34 | Fetcher.ofResultSingle { 35 | Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } 36 | }, 37 | sourceOfTruth = 38 | SourceOfTruth.ofMaybe( 39 | reader = { Maybe.fromCallable { fakeDisk[it] } }, 40 | writer = { key, value -> 41 | Completable.fromAction { fakeDisk[key] = value } 42 | }, 43 | delete = { key -> 44 | Completable.fromAction { fakeDisk.remove(key) } 45 | }, 46 | deleteAll = { 47 | Completable.fromAction { fakeDisk.clear() } 48 | }, 49 | ), 50 | ) 51 | .withScheduler(Schedulers.trampoline()) 52 | .build() 53 | 54 | @Test 55 | fun `store rx extension tests`() { 56 | // Return from cache - after initial fetch 57 | store.getSingle(3) 58 | .test() 59 | .await() 60 | .assertValue("3 1") 61 | 62 | // Return from cache 63 | store.getSingle(3) 64 | .test() 65 | .await() 66 | .assertValue("3 1") 67 | 68 | // Return from fresh - forcing a new fetch 69 | store.freshSingle(3) 70 | .test() 71 | .await() 72 | .assertValue("3 2") 73 | 74 | // Return from cache - different to initial 75 | store.getSingle(3) 76 | .test() 77 | .await() 78 | .assertValue("3 2") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | includeBuild("tooling") 3 | } 4 | 5 | plugins { 6 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 7 | } 8 | 9 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 10 | 11 | rootProject.name = "Store5" 12 | 13 | include ':store' 14 | include ':cache' 15 | include ':multicast' 16 | include ':rx2' 17 | include ':core' 18 | -------------------------------------------------------------------------------- /store/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.internal.impldep.org.testng.reporters.XMLUtils.xml 2 | 3 | 4 | plugins { 5 | id("org.mobilenativefoundation.store.multiplatform") 6 | alias(libs.plugins.kover) 7 | } 8 | 9 | kotlin { 10 | sourceSets { 11 | val commonMain by getting { 12 | dependencies { 13 | implementation(libs.kotlin.stdlib) 14 | implementation(libs.kotlinx.coroutines.core) 15 | implementation(libs.kotlinx.serialization.core) 16 | implementation(libs.kotlinx.datetime) 17 | api(libs.kotlinx.atomic.fu) 18 | implementation(libs.touchlab.kermit) 19 | implementation(projects.multicast) 20 | implementation(projects.cache) 21 | api(projects.core) 22 | } 23 | } 24 | 25 | val commonTest by getting { 26 | dependencies { 27 | implementation(libs.junit) 28 | implementation(libs.kotlinx.coroutines.test) 29 | implementation(libs.turbine) 30 | } 31 | } 32 | } 33 | } 34 | 35 | android { 36 | namespace = "org.mobilenativefoundation.store.store5" 37 | } 38 | 39 | kover { 40 | 41 | reports { 42 | total { 43 | xml { 44 | onCheck = true 45 | xmlFile.set(file("${layout.buildDirectory}/reports/kover/coverage.xml")) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /store/config/ktlint/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /store/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=org.mobilenativefoundation.store 2 | POM_ARTIFACT_ID=store5 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /store/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import org.mobilenativefoundation.store.store5.impl.RealBookkeeper 4 | import org.mobilenativefoundation.store.store5.impl.RealMutableStore 5 | import org.mobilenativefoundation.store.store5.impl.extensions.now 6 | 7 | /** 8 | * Tracks when local changes fail to sync with network. 9 | * @see [RealMutableStore] usage to persist write request failures and eagerly resolve conflicts before completing a read request. 10 | */ 11 | 12 | interface Bookkeeper { 13 | suspend fun getLastFailedSync(key: Key): Long? 14 | 15 | suspend fun setLastFailedSync( 16 | key: Key, 17 | timestamp: Long = now(), 18 | ): Boolean 19 | 20 | suspend fun clear(key: Key): Boolean 21 | 22 | suspend fun clearAll(): Boolean 23 | 24 | companion object { 25 | fun by( 26 | getLastFailedSync: suspend (key: Key) -> Long?, 27 | setLastFailedSync: suspend (key: Key, timestamp: Long) -> Boolean, 28 | clear: suspend (key: Key) -> Boolean, 29 | clearAll: suspend () -> Boolean, 30 | ): Bookkeeper = RealBookkeeper(getLastFailedSync, setLastFailedSync, clear, clearAll) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import org.mobilenativefoundation.store.core5.ExperimentalStoreApi 4 | 5 | interface Clear { 6 | interface Key { 7 | /** 8 | * Purge a particular entry from memory and disk cache. 9 | * Persistent storage will only be cleared if a delete function was passed to 10 | * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. 11 | */ 12 | suspend fun clear(key: Key) 13 | } 14 | 15 | interface All { 16 | /** 17 | * Purge all entries from memory and disk cache. 18 | * Persistent storage will only be cleared if a clear function was passed to 19 | * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. 20 | */ 21 | @ExperimentalStoreApi 22 | suspend fun clear() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | /** 4 | * Converter is a utility interface that can be used to convert a network or output model to a local model. 5 | * Network to Local conversion is needed when the network model is different what you are saving in 6 | * your Source of Truth. 7 | * Output to Local conversion is needed when you are doing local writes in a MutableStore 8 | * @param Network The network data source model type. This is the type used in [Fetcher] 9 | * @param Output The common model type emitted from Store, typically the type returend from your Source of Truth 10 | * @param Local The local data source model type. This is the type used to save to your Source of Truth 11 | */ 12 | interface Converter { 13 | fun fromNetworkToLocal(network: Network): Local 14 | 15 | fun fromOutputToLocal(output: Output): Local 16 | 17 | class Builder { 18 | lateinit var fromOutputToLocal: ((output: Output) -> Local) 19 | lateinit var fromNetworkToLocal: ((network: Network) -> Local) 20 | 21 | fun build(): Converter = RealConverter(fromOutputToLocal, fromNetworkToLocal) 22 | 23 | fun fromOutputToLocal(converter: (output: Output) -> Local): Builder { 24 | fromOutputToLocal = converter 25 | return this 26 | } 27 | 28 | fun fromNetworkToLocal(converter: (network: Network) -> Local): Builder { 29 | fromNetworkToLocal = converter 30 | return this 31 | } 32 | } 33 | } 34 | 35 | private class RealConverter( 36 | private val fromOutputToLocal: ((output: Output) -> Local), 37 | private val fromNetworkToLocal: ((network: Network) -> Local), 38 | ) : Converter { 39 | override fun fromNetworkToLocal(network: Network): Local = fromNetworkToLocal.invoke(network) 40 | 41 | override fun fromOutputToLocal(output: Output): Local = fromOutputToLocal.invoke(output) 42 | } 43 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | sealed class FetcherResult { 4 | data class Data(val value: Network, val origin: String? = null) : FetcherResult() 5 | 6 | sealed class Error : FetcherResult() { 7 | data class Exception(val error: Throwable) : Error() 8 | 9 | data class Message(val message: String) : Error() 10 | 11 | data class Custom(val error: E) : Error() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | /** 4 | * A simple logging interface for logging error and debug messages. 5 | */ 6 | interface Logger { 7 | /** 8 | * Logs an error message, optionally with a throwable. 9 | * 10 | * @param message The error message to log. 11 | * @param throwable An optional [Throwable] associated with the error. 12 | */ 13 | fun error( 14 | message: String, 15 | throwable: Throwable? = null, 16 | ) 17 | 18 | /** 19 | * Logs a debug message. 20 | * 21 | * @param message The debug message to log. 22 | */ 23 | fun debug(message: String) 24 | } 25 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import org.mobilenativefoundation.store.cache5.Cache 4 | import kotlin.time.Duration 5 | 6 | fun interface Weigher { 7 | /** 8 | * Returns the weight of a cache entry. There is no unit for entry weights; rather they are simply 9 | * relative to each other. 10 | * 11 | * @return the weight of the entry; must be non-negative 12 | */ 13 | fun weigh( 14 | key: K, 15 | value: V, 16 | ): Int 17 | } 18 | 19 | internal object OneWeigher : Weigher { 20 | override fun weigh( 21 | key: Any, 22 | value: Any, 23 | ): Int = 1 24 | } 25 | 26 | /** 27 | * Defines behavior of in-memory [Cache]. 28 | * Used by [Store]. 29 | * @see [Store] 30 | */ 31 | class MemoryPolicy internal constructor( 32 | val expireAfterWrite: Duration, 33 | val expireAfterAccess: Duration, 34 | val maxSize: Long, 35 | val maxWeight: Long, 36 | val weigher: Weigher, 37 | ) { 38 | val isDefaultWritePolicy: Boolean = expireAfterWrite == DEFAULT_DURATION_POLICY 39 | 40 | val hasWritePolicy: Boolean = expireAfterWrite != DEFAULT_DURATION_POLICY 41 | 42 | val hasAccessPolicy: Boolean = expireAfterAccess != DEFAULT_DURATION_POLICY 43 | 44 | val hasMaxSize: Boolean = maxSize != DEFAULT_SIZE_POLICY 45 | 46 | val hasMaxWeight: Boolean = maxWeight != DEFAULT_SIZE_POLICY 47 | 48 | class MemoryPolicyBuilder { 49 | private var expireAfterWrite = DEFAULT_DURATION_POLICY 50 | private var expireAfterAccess = DEFAULT_DURATION_POLICY 51 | private var maxSize: Long = DEFAULT_SIZE_POLICY 52 | private var maxWeight: Long = DEFAULT_SIZE_POLICY 53 | private var weigher: Weigher = OneWeigher 54 | 55 | fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder = 56 | apply { 57 | check(expireAfterAccess == DEFAULT_DURATION_POLICY) { 58 | "Cannot set expireAfterWrite with expireAfterAccess already set" 59 | } 60 | this.expireAfterWrite = expireAfterWrite 61 | } 62 | 63 | fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder = 64 | apply { 65 | check(expireAfterWrite == DEFAULT_DURATION_POLICY) { 66 | "Cannot set expireAfterAccess with expireAfterWrite already set" 67 | } 68 | this.expireAfterAccess = expireAfterAccess 69 | } 70 | 71 | /** 72 | * Sets the maximum number of items ([maxSize]) kept in the cache. 73 | * 74 | * When [maxSize] is 0, entries will be discarded immediately and no values will be cached. 75 | * 76 | * If not set, cache size will be unlimited. 77 | */ 78 | fun setMaxSize(maxSize: Long): MemoryPolicyBuilder = 79 | apply { 80 | check(maxWeight == DEFAULT_SIZE_POLICY && weigher == OneWeigher) { 81 | "Cannot setMaxSize when maxWeight or weigher are already set" 82 | } 83 | check(maxSize >= 0) { "maxSize cannot be negative" } 84 | this.maxSize = maxSize 85 | } 86 | 87 | fun setWeigherAndMaxWeight( 88 | weigher: Weigher, 89 | maxWeight: Long, 90 | ): MemoryPolicyBuilder = 91 | apply { 92 | check(maxSize == DEFAULT_SIZE_POLICY) { 93 | "Cannot setWeigherAndMaxWeight when maxSize already set" 94 | } 95 | check(maxWeight >= 0) { "maxWeight cannot be negative" } 96 | this.weigher = weigher 97 | this.maxWeight = maxWeight 98 | } 99 | 100 | fun build() = 101 | MemoryPolicy( 102 | expireAfterWrite = expireAfterWrite, 103 | expireAfterAccess = expireAfterAccess, 104 | maxSize = maxSize, 105 | maxWeight = maxWeight, 106 | weigher = weigher, 107 | ) 108 | } 109 | 110 | companion object { 111 | val DEFAULT_DURATION_POLICY: Duration = Duration.INFINITE 112 | const val DEFAULT_SIZE_POLICY: Long = -1 113 | 114 | fun builder(): MemoryPolicyBuilder = MemoryPolicyBuilder() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import org.mobilenativefoundation.store.core5.ExperimentalStoreApi 4 | 5 | @ExperimentalStoreApi 6 | interface MutableStore : 7 | Read.StreamWithConflictResolution, 8 | Write, 9 | Write.Stream, 10 | Clear.Key, 11 | Clear 12 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import org.mobilenativefoundation.store.store5.impl.mutableStoreBuilderFromFetcherAndSourceOfTruth 5 | 6 | interface MutableStoreBuilder { 7 | fun build( 8 | updater: Updater, 9 | bookkeeper: Bookkeeper? = null, 10 | ): MutableStore 11 | 12 | /** 13 | * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default 14 | * [Store] will open a global scope for management of shared responses, if instead you'd like to control 15 | * the scope that sharing/multicasting happens in you can pass a @param [scope] 16 | * 17 | * @param scope - scope to use for sharing 18 | */ 19 | fun scope(scope: CoroutineScope): MutableStoreBuilder 20 | 21 | /** 22 | * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL 23 | * or size based eviction 24 | * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() 25 | */ 26 | fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder 27 | 28 | /** 29 | * by default a Store caches in memory with a default policy of max items = 100 30 | */ 31 | fun disableCache(): MutableStoreBuilder 32 | 33 | fun validator(validator: Validator): MutableStoreBuilder 34 | 35 | companion object { 36 | /** 37 | * Creates a new [MutableStoreBuilder] from a [Fetcher] and a [SourceOfTruth]. 38 | * 39 | * @param fetcher a function for fetching a flow of network records. 40 | * @param sourceOfTruth a [SourceOfTruth] for the store. 41 | */ 42 | fun from( 43 | fetcher: Fetcher, 44 | sourceOfTruth: SourceOfTruth, 45 | converter: Converter, 46 | ): MutableStoreBuilder = 47 | mutableStoreBuilderFromFetcherAndSourceOfTruth( 48 | fetcher = fetcher, 49 | sourceOfTruth = sourceOfTruth, 50 | converter = converter, 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | data class OnFetcherCompletion( 4 | val onSuccess: (FetcherResult.Data) -> Unit, 5 | val onFailure: (FetcherResult.Error) -> Unit, 6 | ) 7 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | data class OnUpdaterCompletion( 4 | val onSuccess: (UpdaterResult.Success) -> Unit, 5 | val onFailure: (UpdaterResult.Error) -> Unit, 6 | ) 7 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface Read { 6 | interface Stream { 7 | /** 8 | * Return a flow for the given key 9 | * @param request - see [StoreReadRequest] for configurations 10 | */ 11 | fun stream(request: StoreReadRequest): Flow> 12 | } 13 | 14 | interface StreamWithConflictResolution { 15 | fun stream(request: StoreReadRequest): Flow> 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | /** 4 | * A Store is responsible for managing a particular data request. 5 | * 6 | * When you create an implementation of a Store, you provide it with a Fetcher, a function that defines how data will be fetched over network. 7 | * 8 | * You can also define how your Store will cache data in-memory and on-disk. See [StoreBuilder] for full configuration 9 | * 10 | * Example usage: 11 | * 12 | * val store = StoreBuilder 13 | * .fromNonFlow, List> { (query, config) -> 14 | * provideRetrofit().fetchData(query, config.limit).data.children.map(::toPosts) 15 | * } 16 | * .persister(reader = { (query, _) -> db.postDao().loadData(query) }, 17 | * writer = { (query, _), posts -> db.dataDAO().insertData(query, posts) }, 18 | * delete = { (query, _) -> db.dataDAO().clearData(query) }, 19 | * deleteAll = db.postDao()::clearAllFeeds) 20 | * .build() 21 | * 22 | * // single shot response 23 | * viewModelScope.launch { 24 | * val data = store.fresh(key) 25 | * } 26 | * 27 | * // get cached data and collect future emissions as well 28 | * viewModelScope.launch { 29 | * val data = store.cached(key, refresh=true) 30 | * .collect{data.value=it } 31 | * } 32 | * 33 | */ 34 | interface Store : 35 | Read.Stream, 36 | Clear.Key, 37 | Clear.All 38 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlin.time.Duration 4 | import kotlin.time.Duration.Companion.hours 5 | 6 | internal object StoreDefaults { 7 | /** 8 | * Cache TTL (default is 24 hours), can be overridden 9 | * 10 | * @return memory cache TTL 11 | */ 12 | val cacheTTL: Duration = 24.hours 13 | 14 | /** 15 | * Cache size (default is 100), can be overridden 16 | * 17 | * @return memory cache size 18 | */ 19 | val cacheSize: Long = 100 20 | 21 | val memoryPolicy = 22 | MemoryPolicy.builder() 23 | .setMaxSize(cacheSize) 24 | .setExpireAfterWrite(cacheTTL) 25 | .build() 26 | } 27 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.store5 17 | 18 | /** 19 | * data class to represent a single store request 20 | * @param key a unique identifier for your data 21 | * @param skippedCaches List of cache types that should be skipped when retuning the response see [CacheType] 22 | * @param refresh If set to true [Store] will always get fresh value from fetcher while also 23 | * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and memory cache 24 | * @param fetch If set to false, then fetcher will not be used 25 | */ 26 | data class StoreReadRequest private constructor( 27 | val key: Key, 28 | private val skippedCaches: Int, 29 | val refresh: Boolean = false, 30 | val fallBackToSourceOfTruth: Boolean = false, 31 | val fetch: Boolean = true, 32 | ) { 33 | internal fun shouldSkipCache(type: CacheType) = skippedCaches.and(type.flag) != 0 34 | 35 | /** 36 | * Factories for common store requests 37 | */ 38 | companion object { 39 | private val allCaches = 40 | CacheType.values().fold(0) { prev, next -> 41 | prev.or(next.flag) 42 | } 43 | 44 | /** 45 | * Create a [StoreReadRequest] which will skip all caches and hit your fetcher 46 | * (filling your caches). 47 | * 48 | * Note: If the [Fetcher] does not return any data (i.e., the returned 49 | * [kotlinx.coroutines.Flow], when collected, is empty). Then store will fall back to local 50 | * data **even** if you explicitly requested fresh data. 51 | * See https://github.com/dropbox/Store/pull/194 for context. 52 | */ 53 | fun fresh( 54 | key: Key, 55 | fallBackToSourceOfTruth: Boolean = false, 56 | ) = StoreReadRequest( 57 | key = key, 58 | skippedCaches = allCaches, 59 | refresh = true, 60 | fallBackToSourceOfTruth = fallBackToSourceOfTruth, 61 | ) 62 | 63 | /** 64 | * Create a [StoreReadRequest] which will return data from memory/disk caches if present, 65 | * otherwise will hit your fetcher (filling your caches). 66 | * @param refresh if true then return fetcher (new) data as well (updating your caches) 67 | */ 68 | fun cached( 69 | key: Key, 70 | refresh: Boolean, 71 | ) = StoreReadRequest( 72 | key = key, 73 | skippedCaches = 0, 74 | refresh = refresh, 75 | ) 76 | 77 | /** 78 | * Create a [StoreReadRequest] which will return data from memory/disk caches if present, 79 | * otherwise will return [StoreReadResponse.NoNewData] 80 | */ 81 | fun localOnly(key: Key) = 82 | StoreReadRequest( 83 | key = key, 84 | skippedCaches = 0, 85 | fetch = false, 86 | ) 87 | 88 | /** 89 | * Create a [StoreReadRequest] which will return data from disk cache 90 | * @param refresh if true then return fetcher (new) data as well (updating your caches) 91 | */ 92 | fun skipMemory( 93 | key: Key, 94 | refresh: Boolean, 95 | ) = StoreReadRequest( 96 | key = key, 97 | skippedCaches = CacheType.MEMORY.flag, 98 | refresh = refresh, 99 | ) 100 | 101 | /** 102 | * Creates a [StoreReadRequest] skipping all caches and returning data from network on success and data from [SourceOfTruth] on failure. 103 | */ 104 | fun freshWithFallBackToSourceOfTruth(key: Key) = fresh(key, fallBackToSourceOfTruth = true) 105 | } 106 | } 107 | 108 | internal enum class CacheType(internal val flag: Int) { 109 | MEMORY(0b01), 110 | DISK(0b10), 111 | } 112 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlinx.datetime.Clock 4 | import org.mobilenativefoundation.store.store5.impl.OnStoreWriteCompletion 5 | import org.mobilenativefoundation.store.store5.impl.RealStoreWriteRequest 6 | 7 | interface StoreWriteRequest { 8 | val key: Key 9 | val value: Output 10 | val created: Long 11 | val onCompletions: List? 12 | 13 | companion object { 14 | fun of( 15 | key: Key, 16 | value: Output, 17 | onCompletions: List? = null, 18 | created: Long = Clock.System.now().toEpochMilliseconds(), 19 | ): StoreWriteRequest = RealStoreWriteRequest(key, value, created, onCompletions) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | sealed class StoreWriteResponse { 4 | sealed class Success : StoreWriteResponse() { 5 | data class Typed(val value: Response) : Success() 6 | 7 | data class Untyped(val value: Any) : Success() 8 | } 9 | 10 | sealed class Error : StoreWriteResponse() { 11 | data class Exception(val error: Throwable) : Error() 12 | 13 | data class Message(val message: String) : Error() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | typealias PostRequest = suspend (key: Key, value: Output) -> UpdaterResult 4 | 5 | /** 6 | * Posts data to remote data source. 7 | * @see [StoreWriteRequest] 8 | */ 9 | interface Updater { 10 | /** 11 | * Makes HTTP POST request. 12 | */ 13 | suspend fun post( 14 | key: Key, 15 | value: Output, 16 | ): UpdaterResult 17 | 18 | /** 19 | * Executes on network completion. 20 | */ 21 | val onCompletion: OnUpdaterCompletion? 22 | 23 | companion object { 24 | fun by( 25 | post: PostRequest, 26 | onCompletion: OnUpdaterCompletion? = null, 27 | ): Updater = 28 | RealNetworkUpdater( 29 | post, 30 | onCompletion, 31 | ) 32 | } 33 | } 34 | 35 | internal class RealNetworkUpdater( 36 | private val realPost: PostRequest, 37 | override val onCompletion: OnUpdaterCompletion?, 38 | ) : Updater { 39 | override suspend fun post( 40 | key: Key, 41 | value: Output, 42 | ): UpdaterResult = realPost(key, value) 43 | } 44 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | sealed class UpdaterResult { 4 | sealed class Success : UpdaterResult() { 5 | data class Typed(val value: Response) : Success() 6 | 7 | data class Untyped(val value: Any) : Success() 8 | } 9 | 10 | sealed class Error : UpdaterResult() { 11 | data class Exception(val error: Throwable) : Error() 12 | 13 | data class Message(val message: String) : Error() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import org.mobilenativefoundation.store.store5.impl.RealValidator 4 | 5 | /** 6 | * Enables custom validation of [Store] items. 7 | * @see [StoreReadRequest] 8 | */ 9 | interface Validator { 10 | /** 11 | * Determines whether a [Store] item is valid. 12 | * If invalid, [MutableStore] will get the latest network value using [Fetcher]. 13 | * [MutableStore] will not validate network responses. 14 | */ 15 | suspend fun isValid(item: Output): Boolean 16 | 17 | companion object { 18 | fun by(validator: suspend (item: Output) -> Boolean): Validator = RealValidator(validator) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mobilenativefoundation.store.core5.ExperimentalStoreApi 5 | 6 | interface Write { 7 | @ExperimentalStoreApi 8 | suspend fun write(request: StoreWriteRequest): StoreWriteResponse 9 | 10 | interface Stream { 11 | @ExperimentalStoreApi 12 | fun stream(requestStream: Flow>): Flow 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.impl 2 | 3 | import co.touchlab.kermit.CommonWriter 4 | import org.mobilenativefoundation.store.store5.Logger 5 | 6 | /** 7 | * Default implementation of [Logger] using the Kermit logging library. 8 | */ 9 | internal class DefaultLogger : Logger { 10 | private val delegate = 11 | co.touchlab.kermit.Logger.apply { 12 | setLogWriters(listOf(CommonWriter())) 13 | setTag("Store") 14 | } 15 | 16 | override fun debug(message: String) { 17 | delegate.d(message) 18 | } 19 | 20 | override fun error( 21 | message: String, 22 | throwable: Throwable?, 23 | ) { 24 | delegate.e(message, throwable) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.impl 2 | 3 | import org.mobilenativefoundation.store.store5.StoreWriteResponse 4 | 5 | data class OnStoreWriteCompletion( 6 | val onSuccess: (StoreWriteResponse.Success) -> Unit, 7 | val onFailure: (StoreWriteResponse.Error) -> Unit, 8 | ) 9 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.impl 2 | 3 | import org.mobilenativefoundation.store.store5.Bookkeeper 4 | import org.mobilenativefoundation.store.store5.internal.definition.Timestamp 5 | 6 | internal class RealBookkeeper( 7 | private val realGetLastFailedSync: suspend (key: Key) -> Timestamp?, 8 | private val realSetLastFailedSync: suspend (key: Key, timestamp: Timestamp) -> Boolean, 9 | private val realClear: suspend (key: Key) -> Boolean, 10 | private val realClearAll: suspend () -> Boolean, 11 | ) : Bookkeeper { 12 | override suspend fun getLastFailedSync(key: Key): Long? = realGetLastFailedSync(key) 13 | 14 | override suspend fun setLastFailedSync( 15 | key: Key, 16 | timestamp: Long, 17 | ): Boolean = realSetLastFailedSync(key, timestamp) 18 | 19 | override suspend fun clear(key: Key): Boolean = realClear(key) 20 | 21 | override suspend fun clearAll(): Boolean = realClearAll() 22 | } 23 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.store5.impl 17 | 18 | import kotlinx.coroutines.flow.Flow 19 | import kotlinx.coroutines.flow.flow 20 | import org.mobilenativefoundation.store.store5.SourceOfTruth 21 | 22 | internal class PersistentSourceOfTruth( 23 | private val realReader: (Key) -> Flow, 24 | private val realWriter: suspend (Key, Local) -> Unit, 25 | private val realDelete: (suspend (Key) -> Unit)? = null, 26 | private val realDeleteAll: (suspend () -> Unit)? = null, 27 | ) : SourceOfTruth { 28 | override fun reader(key: Key): Flow = realReader.invoke(key) 29 | 30 | override suspend fun write( 31 | key: Key, 32 | value: Local, 33 | ) = realWriter(key, value) 34 | 35 | override suspend fun delete(key: Key) { 36 | realDelete?.invoke(key) 37 | } 38 | 39 | override suspend fun deleteAll() { 40 | realDeleteAll?.invoke() 41 | } 42 | } 43 | 44 | internal class PersistentNonFlowingSourceOfTruth( 45 | private val realReader: suspend (Key) -> Output?, 46 | private val realWriter: suspend (Key, Local) -> Unit, 47 | private val realDelete: (suspend (Key) -> Unit)? = null, 48 | private val realDeleteAll: (suspend () -> Unit)?, 49 | ) : SourceOfTruth { 50 | override fun reader(key: Key): Flow = 51 | flow { 52 | val sot = realReader(key) 53 | emit(sot) 54 | } 55 | 56 | override suspend fun write( 57 | key: Key, 58 | value: Local, 59 | ) { 60 | return realWriter(key, value) 61 | } 62 | 63 | override suspend fun delete(key: Key) { 64 | realDelete?.invoke(key) 65 | } 66 | 67 | override suspend fun deleteAll() { 68 | realDeleteAll?.invoke() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.impl 2 | 3 | import org.mobilenativefoundation.store.store5.StoreWriteRequest 4 | 5 | data class RealStoreWriteRequest( 6 | override val key: Key, 7 | override val value: Output, 8 | override val created: Long, 9 | override val onCompletions: List?, 10 | ) : StoreWriteRequest 11 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.impl 2 | 3 | import org.mobilenativefoundation.store.store5.Validator 4 | 5 | internal class RealValidator( 6 | private val realValidator: suspend (item: Output) -> Boolean, 7 | ) : Validator { 8 | override suspend fun isValid(item: Output): Boolean = realValidator(item) 9 | } 10 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.store5.impl 17 | 18 | import kotlinx.coroutines.sync.Mutex 19 | import kotlinx.coroutines.sync.withLock 20 | 21 | /** 22 | * Simple holder that can ref-count items by a given key. 23 | */ 24 | internal class RefCountedResource( 25 | private val create: suspend (Key) -> T, 26 | private val onRelease: (suspend (Key, T) -> Unit)? = null, 27 | ) { 28 | private val items = mutableMapOf() 29 | private val lock = Mutex() 30 | 31 | suspend fun acquire(key: Key): T = 32 | lock.withLock { 33 | items.getOrPut(key) { 34 | Item(create(key)) 35 | }.also { 36 | it.refCount++ 37 | }.value 38 | } 39 | 40 | suspend fun release( 41 | key: Key, 42 | value: T, 43 | ) = lock.withLock { 44 | val existing = items[key] 45 | check(existing != null && existing.value === value) { 46 | "inconsistent release, seems like $value was leaked or never acquired" 47 | } 48 | existing.refCount-- 49 | if (existing.refCount < 1) { 50 | items.remove(key) 51 | onRelease?.invoke(key, value) 52 | } 53 | } 54 | 55 | // used in tests 56 | suspend fun size() = 57 | lock.withLock { 58 | items.size 59 | } 60 | 61 | private inner class Item( 62 | val value: T, 63 | var refCount: Int = 0, 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/clock.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.impl.extensions 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlin.time.Duration.Companion.hours 5 | 6 | internal fun now() = Clock.System.now().toEpochMilliseconds() 7 | 8 | internal fun inHours(n: Int) = Clock.System.now().plus(n.hours).toEpochMilliseconds() 9 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.store5.impl.operators 17 | 18 | import kotlinx.coroutines.channels.Channel 19 | import kotlinx.coroutines.flow.Flow 20 | import kotlinx.coroutines.flow.buffer 21 | import kotlinx.coroutines.flow.channelFlow 22 | import kotlinx.coroutines.launch 23 | 24 | /** 25 | * Merge implementation tells downstream what the source is and also uses a rendezvous channel 26 | */ 27 | internal fun Flow.merge(other: Flow): Flow> { 28 | return channelFlow> { 29 | launch { 30 | this@merge.collect { 31 | send(Either.Left(it)) 32 | } 33 | } 34 | launch { 35 | other.collect { 36 | send(Either.Right(it)) 37 | } 38 | } 39 | }.buffer(Channel.RENDEZVOUS) 40 | } 41 | 42 | internal sealed class Either { 43 | data class Left(val value: T) : Either() 44 | 45 | data class Right(val value: R) : Either() 46 | } 47 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.store5.impl.operators 17 | 18 | import kotlinx.coroutines.flow.Flow 19 | import kotlinx.coroutines.flow.collectIndexed 20 | import kotlinx.coroutines.flow.flow 21 | 22 | internal inline fun Flow.mapIndexed(crossinline block: (Int, T) -> R) = 23 | flow { 24 | collectIndexed { index, value -> 25 | emit(block(index, value)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/Lightswitch.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.internal.concurrent 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.coroutines.sync.withLock 5 | 6 | /** 7 | * Locks when first reader starts and unlocks when last reader finishes. 8 | * Lightswitch analogy: First one into a room turns on the light (locks the mutex), and the last one out turns off the light (unlocks the mutex). 9 | * @property counter Number of readers 10 | */ 11 | internal class Lightswitch { 12 | private var counter = 0 13 | private val mutex = Mutex() 14 | 15 | suspend fun lock(room: Mutex) { 16 | mutex.withLock { 17 | counter += 1 18 | if (counter == 1) { 19 | room.lock() 20 | } 21 | } 22 | } 23 | 24 | suspend fun unlock(room: Mutex) { 25 | mutex.withLock { 26 | counter -= 1 27 | check(counter >= 0) 28 | if (counter == 0) { 29 | room.unlock() 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.internal.concurrent 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | 5 | internal data class ThreadSafety( 6 | val writeRequests: StoreThreadSafety = StoreThreadSafety(), 7 | val readCompletions: StoreThreadSafety = StoreThreadSafety(), 8 | ) 9 | 10 | internal data class StoreThreadSafety( 11 | val mutex: Mutex = Mutex(), 12 | val lightswitch: Lightswitch = Lightswitch(), 13 | ) 14 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.internal.definition 2 | 3 | typealias Timestamp = Long 4 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.internal.definition 2 | 3 | import org.mobilenativefoundation.store.store5.StoreWriteRequest 4 | 5 | typealias WriteRequestQueue = ArrayDeque> 6 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.internal.result 2 | 3 | import org.mobilenativefoundation.store.store5.UpdaterResult 4 | 5 | sealed class EagerConflictResolutionResult { 6 | sealed class Success : EagerConflictResolutionResult() { 7 | object NoConflicts : Success() 8 | 9 | data class ConflictsResolved(val value: UpdaterResult.Success) : Success() 10 | } 11 | 12 | sealed class Error : EagerConflictResolutionResult() { 13 | data class Message(val message: String) : Error() 14 | 15 | data class Exception(val error: Throwable) : Error() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.internal.result 2 | 3 | sealed class StoreDelegateWriteResult { 4 | object Success : StoreDelegateWriteResult() 5 | 6 | sealed class Error : StoreDelegateWriteResult() { 7 | data class Message(val error: String) : Error() 8 | 9 | data class Exception(val error: Throwable) : Error() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml: -------------------------------------------------------------------------------- 1 | 2 | 3 | JAVA 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Fields 12 | Constructors 13 | Methods 14 | Properties 15 | Inner Classes 16 | 17 | All 18 | public 19 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.FlowPreview 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.flowOf 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.test.TestScope 10 | import kotlinx.coroutines.test.runTest 11 | import kotlin.test.Test 12 | import kotlin.test.assertEquals 13 | 14 | @ExperimentalCoroutinesApi 15 | @FlowPreview 16 | class HotFlowStoreTests { 17 | private val testScope = TestScope() 18 | 19 | @Test 20 | fun givenAHotFetcherWhenTwoCachedAndOneFreshCallThenFetcherIsOnlyCalledTwice() = 21 | testScope.runTest { 22 | val fetcher = 23 | FakeFlowFetcher( 24 | 3 to "three-1", 25 | 3 to "three-2", 26 | ) 27 | val pipeline = 28 | StoreBuilder 29 | .from(fetcher) 30 | .scope(testScope) 31 | .build() 32 | 33 | val job = 34 | launch { 35 | pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { 36 | assertEquals( 37 | StoreReadResponse.Loading( 38 | origin = StoreReadResponseOrigin.Fetcher(), 39 | ), 40 | awaitItem(), 41 | ) 42 | 43 | assertEquals( 44 | StoreReadResponse.Data( 45 | value = "three-1", 46 | origin = StoreReadResponseOrigin.Fetcher(), 47 | ), 48 | awaitItem(), 49 | ) 50 | } 51 | 52 | pipeline.stream( 53 | StoreReadRequest.cached(3, refresh = false), 54 | ).test { 55 | assertEquals( 56 | StoreReadResponse.Data( 57 | value = "three-1", 58 | origin = StoreReadResponseOrigin.Cache, 59 | ), 60 | awaitItem(), 61 | ) 62 | } 63 | 64 | pipeline.stream(StoreReadRequest.fresh(3)).test { 65 | assertEquals( 66 | StoreReadResponse.Loading( 67 | origin = StoreReadResponseOrigin.Fetcher(), 68 | ), 69 | awaitItem(), 70 | ) 71 | 72 | assertEquals( 73 | StoreReadResponse.Data( 74 | value = "three-2", 75 | origin = StoreReadResponseOrigin.Fetcher(), 76 | ), 77 | awaitItem(), 78 | ) 79 | } 80 | } 81 | 82 | job.cancel() 83 | } 84 | } 85 | 86 | private class FakeFlowFetcher( 87 | vararg val responses: Pair, 88 | ) : Fetcher { 89 | private var index = 0 90 | override val name: String? = null 91 | 92 | override val fallback: Fetcher? = null 93 | 94 | override fun invoke(key: Key): Flow> { 95 | if (index >= responses.size) { 96 | throw AssertionError("unexpected fetch request") 97 | } 98 | val pair = responses[index++] 99 | assertEquals(key, pair.first) 100 | return flowOf(FetcherResult.Data(pair.second)) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.async 5 | import kotlinx.coroutines.cancel 6 | import kotlinx.coroutines.flow.collectIndexed 7 | import kotlinx.coroutines.flow.take 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.test.TestScope 11 | import kotlinx.coroutines.test.advanceUntilIdle 12 | import kotlinx.coroutines.test.runTest 13 | import org.mobilenativefoundation.store.store5.util.KeyTracker 14 | import kotlin.test.Test 15 | import kotlin.test.assertEquals 16 | 17 | @ExperimentalCoroutinesApi 18 | class KeyTrackerTests { 19 | private val scope1 = TestScope() 20 | private val scope2 = TestScope() 21 | 22 | private val subject = KeyTracker() 23 | 24 | @Test 25 | fun dontSkipInvalidations() = 26 | scope1.runTest { 27 | val collection = 28 | scope2.async { 29 | subject.keyFlow('b') 30 | .take(2) 31 | .toList() 32 | } 33 | scope2.advanceUntilIdle() 34 | assertEquals(1, subject.activeKeyCount()) 35 | scope2.advanceUntilIdle() 36 | subject.invalidate('a') 37 | subject.invalidate('b') 38 | subject.invalidate('c') 39 | scope2.advanceUntilIdle() 40 | assertEquals(true, collection.isCompleted) 41 | assertEquals(0, subject.activeKeyCount()) 42 | } 43 | 44 | @Test 45 | fun multipleScopes() = 46 | scope1.runTest { 47 | val keys = 'a'..'z' 48 | val collections = 49 | keys.associate { key -> 50 | key to 51 | scope2.async { 52 | subject.keyFlow(key) 53 | .take(2) 54 | .toList() 55 | } 56 | } 57 | scope2.advanceUntilIdle() 58 | assertEquals(26, subject.activeKeyCount()) 59 | 60 | scope2.advanceUntilIdle() 61 | keys.forEach { 62 | subject.invalidate(it) 63 | } 64 | scope2.advanceUntilIdle() 65 | 66 | collections.forEach { (_, deferred) -> 67 | assertEquals(true, deferred.isCompleted) 68 | } 69 | assertEquals(0, subject.activeKeyCount()) 70 | } 71 | 72 | @Test 73 | fun multipleObservers() = 74 | scope1.runTest { 75 | val collections = 76 | (0..4).map { 77 | scope2.async { 78 | subject.keyFlow('b') 79 | .take(2) 80 | .toList() 81 | } 82 | } 83 | scope2.advanceUntilIdle() 84 | assertEquals(1, subject.activeKeyCount()) 85 | scope2.advanceUntilIdle() 86 | subject.invalidate('a') 87 | subject.invalidate('b') 88 | subject.invalidate('c') 89 | scope2.advanceUntilIdle() 90 | collections.forEach { collection -> 91 | assertEquals(true, collection.isCompleted) 92 | } 93 | assertEquals(0, subject.activeKeyCount()) 94 | } 95 | 96 | @Test 97 | fun keyFlow_notCollected_shouldNotBeTracked() = 98 | scope1.runTest { 99 | val flow = subject.keyFlow('b') 100 | assertEquals(0, subject.activeKeyCount()) 101 | scope2.launch { 102 | flow.collectIndexed { index, value -> 103 | assertEquals(1, index) 104 | assertEquals(Unit, value) 105 | assertEquals(1, subject.activeKeyCount()) 106 | cancel() 107 | } 108 | } 109 | assertEquals(0, subject.activeKeyCount()) 110 | } 111 | 112 | @Test 113 | fun keyFlow_trackerShouldRefCount() = 114 | scope1.runTest { 115 | val flow = subject.keyFlow('a') 116 | assertEquals(0, subject.activeKeyCount()) 117 | scope2.launch { 118 | flow.collectIndexed { index, value -> 119 | assertEquals(1, index) 120 | assertEquals(Unit, value) 121 | assertEquals(1, subject.activeKeyCount()) 122 | cancel() 123 | } 124 | } 125 | scope2.launch { 126 | flow.collectIndexed { index, value -> 127 | assertEquals(1, index) 128 | assertEquals(Unit, value) 129 | assertEquals(1, subject.activeKeyCount()) 130 | cancel() 131 | } 132 | } 133 | 134 | assertEquals(0, subject.activeKeyCount()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.flow.flowOf 5 | import kotlinx.coroutines.test.TestScope 6 | import kotlinx.coroutines.test.runTest 7 | import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | 11 | class MapIndexedTests { 12 | private val scope = TestScope() 13 | 14 | @Test 15 | fun mapIndexed() = 16 | scope.runTest { 17 | flowOf(5, 6).mapIndexed { index, value -> index to value }.test { 18 | assertEquals(0 to 5, awaitItem()) 19 | assertEquals(1 to 6, awaitItem()) 20 | awaitComplete() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertFailsWith 6 | import kotlin.test.assertNull 7 | 8 | class StoreReadResponseTests { 9 | @Test 10 | fun requireData() { 11 | assertEquals("Foo", StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).requireData()) 12 | 13 | // should throw 14 | assertFailsWith { 15 | StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).requireData() 16 | } 17 | } 18 | 19 | @Test 20 | fun throwIfErrorException() { 21 | assertFailsWith { 22 | StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() 23 | } 24 | } 25 | 26 | @Test 27 | fun throwIfErrorMessage() { 28 | assertFailsWith { 29 | StoreReadResponse.Error.Message("test error", StoreReadResponseOrigin.Fetcher()).throwIfError() 30 | } 31 | } 32 | 33 | @Test() 34 | fun errorMessageOrNull() { 35 | assertFailsWith(message = Exception::class.toString()) { 36 | StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() 37 | } 38 | 39 | assertFailsWith(message = "test error message") { 40 | StoreReadResponse.Error.Message("test error message", StoreReadResponseOrigin.Fetcher()).throwIfError() 41 | } 42 | 43 | assertNull(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).errorMessageOrNull()) 44 | } 45 | 46 | @Test 47 | fun swapType() { 48 | assertFailsWith { 49 | StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).swapType() 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.FlowPreview 6 | import kotlinx.coroutines.async 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.flow.take 9 | import kotlinx.coroutines.flow.toList 10 | import kotlinx.coroutines.test.TestScope 11 | import kotlinx.coroutines.test.runTest 12 | import org.mobilenativefoundation.store.store5.util.FakeFetcher 13 | import kotlin.test.Test 14 | import kotlin.test.assertEquals 15 | 16 | @FlowPreview 17 | @ExperimentalCoroutinesApi 18 | class StreamWithoutSourceOfTruthTests { 19 | private val testScope = TestScope() 20 | 21 | @Test 22 | fun streamWithoutPersisterAndCacheEnabled() = 23 | testScope.runTest { 24 | val fetcher = 25 | FakeFetcher( 26 | 3 to "three-1", 27 | 3 to "three-2", 28 | ) 29 | val pipeline = 30 | StoreBuilder.from(fetcher) 31 | .scope(testScope) 32 | .build() 33 | val twoItemsNoRefresh = 34 | async { 35 | pipeline.stream( 36 | StoreReadRequest.cached(3, refresh = false), 37 | ).take(3).toList() 38 | } 39 | delay(1_000) // make sure the async block starts first 40 | pipeline.stream(StoreReadRequest.fresh(3)).test { 41 | assertEquals( 42 | StoreReadResponse.Loading( 43 | origin = StoreReadResponseOrigin.Fetcher(), 44 | ), 45 | awaitItem(), 46 | ) 47 | 48 | assertEquals( 49 | StoreReadResponse.Data( 50 | value = "three-2", 51 | origin = StoreReadResponseOrigin.Fetcher(), 52 | ), 53 | awaitItem(), 54 | ) 55 | } 56 | 57 | assertEquals( 58 | listOf( 59 | StoreReadResponse.Loading( 60 | origin = StoreReadResponseOrigin.Fetcher(), 61 | ), 62 | StoreReadResponse.Data( 63 | value = "three-1", 64 | origin = StoreReadResponseOrigin.Fetcher(), 65 | ), 66 | StoreReadResponse.Data( 67 | value = "three-2", 68 | origin = StoreReadResponseOrigin.Fetcher(), 69 | ), 70 | ), 71 | twoItemsNoRefresh.await(), 72 | ) 73 | } 74 | 75 | @Test 76 | fun streamWithoutPersisterAndCacheDisabled() = 77 | testScope.runTest { 78 | val fetcher = 79 | FakeFetcher( 80 | 3 to "three-1", 81 | 3 to "three-2", 82 | ) 83 | val pipeline = 84 | StoreBuilder.from(fetcher) 85 | .scope(testScope) 86 | .disableCache() 87 | .build() 88 | val twoItemsNoRefresh = 89 | async { 90 | pipeline.stream( 91 | StoreReadRequest.cached(3, refresh = false), 92 | ).take(3).toList() 93 | } 94 | delay(1_000) // make sure the async block starts first 95 | pipeline.stream(StoreReadRequest.fresh(3)).test { 96 | assertEquals( 97 | StoreReadResponse.Loading( 98 | origin = StoreReadResponseOrigin.Fetcher(), 99 | ), 100 | awaitItem(), 101 | ) 102 | 103 | assertEquals( 104 | StoreReadResponse.Data( 105 | value = "three-2", 106 | origin = StoreReadResponseOrigin.Fetcher(), 107 | ), 108 | awaitItem(), 109 | ) 110 | } 111 | 112 | assertEquals( 113 | listOf( 114 | StoreReadResponse.Loading( 115 | origin = StoreReadResponseOrigin.Fetcher(), 116 | ), 117 | StoreReadResponse.Data( 118 | value = "three-1", 119 | origin = StoreReadResponseOrigin.Fetcher(), 120 | ), 121 | StoreReadResponse.Data( 122 | value = "three-2", 123 | origin = StoreReadResponseOrigin.Fetcher(), 124 | ), 125 | ), 126 | twoItemsNoRefresh.await(), 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.FlowPreview 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.flow.flowOf 8 | import kotlinx.coroutines.test.TestScope 9 | import kotlinx.coroutines.test.runTest 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | 13 | @ExperimentalCoroutinesApi 14 | @FlowPreview 15 | class ValueFetcherTests { 16 | private val testScope = TestScope() 17 | 18 | @Test 19 | fun givenValueFetcherWhenInvokeThenResultIsWrapped() = 20 | testScope.runTest { 21 | val fetcher = Fetcher.ofFlow { flowOf(it * it) } 22 | 23 | fetcher(3).test { 24 | assertEquals(FetcherResult.Data(value = 9), awaitItem()) 25 | awaitComplete() 26 | } 27 | } 28 | 29 | @Test 30 | fun givenValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = 31 | testScope.runTest { 32 | val e = Exception() 33 | val fetcher = 34 | Fetcher.ofFlow { 35 | flow { 36 | throw e 37 | } 38 | } 39 | fetcher(3).test { 40 | assertEquals(FetcherResult.Error.Exception(e), awaitItem()) 41 | awaitComplete() 42 | } 43 | } 44 | 45 | @Test 46 | fun givenNonFlowValueFetcherWhenInvokeThenResultIsWrapped() = 47 | testScope.runTest { 48 | val fetcher = Fetcher.of { it * it } 49 | 50 | fetcher(3).test { 51 | assertEquals(FetcherResult.Data(value = 9), awaitItem()) 52 | awaitComplete() 53 | } 54 | } 55 | 56 | @Test 57 | fun givenNonFlowValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = 58 | testScope.runTest { 59 | val e = Exception() 60 | val fetcher = 61 | Fetcher.of { 62 | throw e 63 | } 64 | fetcher(3).test { 65 | assertEquals(FetcherResult.Error.Exception(e), awaitItem()) 66 | awaitComplete() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import org.mobilenativefoundation.store.cache5.Cache 4 | 5 | @Suppress("UNCHECKED_CAST") 6 | class TestCache : Cache { 7 | private val map = HashMap() 8 | var getIfPresentCalls = 0 9 | var getOrPutCalls = 0 10 | var getAllPresentCalls = 0 11 | var putCalls = 0 12 | var putAllCalls = 0 13 | var invalidateCalls = 0 14 | var invalidateAllKeysCalls = 0 15 | var invalidateAllCalls = 0 16 | var sizeCalls = 0 17 | 18 | override fun getIfPresent(key: Key): Value? { 19 | getIfPresentCalls++ 20 | return map[key] 21 | } 22 | 23 | override fun getOrPut( 24 | key: Key, 25 | valueProducer: () -> Value, 26 | ): Value { 27 | getOrPutCalls++ 28 | return map.getOrPut(key, valueProducer) 29 | } 30 | 31 | override fun getAllPresent(keys: List<*>): Map { 32 | getAllPresentCalls++ 33 | return keys.mapNotNull { it as? Key }.associateWithNotNull { key -> map[key] } 34 | } 35 | 36 | override fun put( 37 | key: Key, 38 | value: Value, 39 | ) { 40 | putCalls++ 41 | map[key] = value 42 | } 43 | 44 | override fun putAll(map: Map) { 45 | putAllCalls++ 46 | map.forEach { (k, v) -> put(k, v) } 47 | } 48 | 49 | override fun invalidate(key: Key) { 50 | invalidateCalls++ 51 | map.remove(key) 52 | } 53 | 54 | override fun invalidateAll(keys: List) { 55 | invalidateAllKeysCalls++ 56 | keys.forEach { map.remove(it) } 57 | } 58 | 59 | override fun invalidateAll() { 60 | invalidateAllCalls++ 61 | map.clear() 62 | } 63 | 64 | override fun size(): Long { 65 | sizeCalls++ 66 | return map.size.toLong() 67 | } 68 | 69 | private inline fun Iterable.associateWithNotNull(transform: (K) -> V?): Map { 70 | val destination = mutableMapOf() 71 | for (element in this) { 72 | transform(element)?.let { destination[element] = it } 73 | } 74 | return destination 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import org.mobilenativefoundation.store.store5.Converter 4 | 5 | @Suppress("UNCHECKED_CAST") 6 | class TestConverter( 7 | private val defaultNetworkToLocalConverter: ((Network) -> Local)? = null, 8 | private val defaultOutputToLocalConverter: ((Output) -> Local)? = null, 9 | ) : Converter { 10 | private val networkToLocalMap: HashMap = HashMap() 11 | private val outputToLocalMap: HashMap = HashMap() 12 | 13 | fun wheneverNetwork( 14 | network: Network, 15 | block: () -> Local, 16 | ) { 17 | networkToLocalMap[network] = block() 18 | } 19 | 20 | fun wheneverOutput( 21 | output: Output, 22 | block: () -> Local, 23 | ) { 24 | outputToLocalMap[output] = block() 25 | } 26 | 27 | override fun fromNetworkToLocal(network: Network): Local { 28 | return networkToLocalMap[network] ?: defaultNetworkToLocalConverter?.invoke(network) ?: network as Local 29 | } 30 | 31 | override fun fromOutputToLocal(output: Output): Local { 32 | return outputToLocalMap[output] ?: defaultOutputToLocalConverter?.invoke(output) ?: output as Local 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mobilenativefoundation.store.store5.Fetcher 5 | import org.mobilenativefoundation.store.store5.FetcherResult 6 | 7 | class TestFetcher( 8 | override val name: String? = null, 9 | override val fallback: Fetcher? = null, 10 | ) : Fetcher { 11 | private val faked = HashMap>>() 12 | 13 | fun whenever( 14 | key: Key, 15 | block: () -> Flow>, 16 | ) { 17 | faked[key] = block() 18 | } 19 | 20 | override operator fun invoke(key: Key): Flow> { 21 | return requireNotNull(faked[key]) { 22 | "No fetcher result provided for key=$key" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import org.mobilenativefoundation.store.store5.Bookkeeper 4 | 5 | class TestInMemoryBookkeeper : Bookkeeper { 6 | private val failedSyncMap = mutableMapOf() 7 | 8 | override suspend fun getLastFailedSync(key: Key): Long? { 9 | return failedSyncMap[key] 10 | } 11 | 12 | override suspend fun setLastFailedSync( 13 | key: Key, 14 | timestamp: Long, 15 | ): Boolean { 16 | failedSyncMap[key] = timestamp 17 | return true 18 | } 19 | 20 | override suspend fun clear(key: Key): Boolean { 21 | return failedSyncMap.remove(key) != null 22 | } 23 | 24 | override suspend fun clearAll(): Boolean { 25 | failedSyncMap.clear() 26 | return true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import org.mobilenativefoundation.store.store5.Logger 4 | 5 | class TestLogger : Logger { 6 | val debugLogs = mutableListOf() 7 | val errorLogs = mutableListOf>() 8 | 9 | override fun debug(message: String) { 10 | debugLogs.add(message) 11 | } 12 | 13 | override fun error( 14 | message: String, 15 | throwable: Throwable?, 16 | ) { 17 | errorLogs.add(message to throwable) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.MutableSharedFlow 5 | import kotlinx.coroutines.flow.emitAll 6 | import kotlinx.coroutines.flow.flow 7 | import org.mobilenativefoundation.store.store5.SourceOfTruth 8 | 9 | @Suppress("UNCHECKED_CAST") 10 | class TestSourceOfTruth : SourceOfTruth { 11 | private val storage = HashMap() 12 | private val flows = HashMap>() 13 | private var readError: Throwable? = null 14 | private var writeError: Throwable? = null 15 | private var deleteError: Throwable? = null 16 | private var deleteAllError: Throwable? = null 17 | 18 | fun throwOnRead( 19 | key: Key, 20 | block: () -> Throwable, 21 | ) { 22 | readError = block() 23 | } 24 | 25 | fun throwOnWrite( 26 | key: Key, 27 | block: () -> Throwable, 28 | ) { 29 | writeError = block() 30 | } 31 | 32 | fun throwOnDelete( 33 | key: Key?, 34 | block: () -> Throwable, 35 | ) { 36 | if (key != null) deleteError = block() else deleteAllError = block() 37 | } 38 | 39 | override fun reader(key: Key): Flow = 40 | flow { 41 | readError?.let { throw SourceOfTruth.ReadException(key, it) } 42 | val sharedFlow = flows.getOrPut(key) { MutableSharedFlow(replay = 1) } 43 | emit(storage[key] as Output?) 44 | emitAll(sharedFlow) 45 | } 46 | 47 | override suspend fun write( 48 | key: Key, 49 | value: Local, 50 | ) { 51 | writeError?.let { throw SourceOfTruth.WriteException(key, value, it) } 52 | storage[key] = value 53 | flows[key]?.emit(value as Output?) 54 | } 55 | 56 | override suspend fun delete(key: Key) { 57 | deleteError?.let { throw it } 58 | storage.remove(key) 59 | flows.remove(key) 60 | } 61 | 62 | override suspend fun deleteAll() { 63 | deleteAllError?.let { throw it } 64 | storage.clear() 65 | flows.clear() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import org.mobilenativefoundation.store.cache5.Cache 7 | import org.mobilenativefoundation.store.store5.Converter 8 | import org.mobilenativefoundation.store.store5.Fetcher 9 | import org.mobilenativefoundation.store.store5.SourceOfTruth 10 | import org.mobilenativefoundation.store.store5.Validator 11 | import org.mobilenativefoundation.store.store5.impl.RealStore 12 | 13 | internal fun testStore( 14 | dispatcher: CoroutineDispatcher = Dispatchers.Default, 15 | scope: CoroutineScope = CoroutineScope(dispatcher), 16 | fetcher: Fetcher = TestFetcher(), 17 | sourceOfTruth: SourceOfTruth = TestSourceOfTruth(), 18 | converter: Converter = TestConverter(), 19 | validator: Validator = TestValidator(), 20 | memoryCache: Cache = TestCache(), 21 | ): RealStore = 22 | RealStore( 23 | scope = scope, 24 | fetcher = fetcher, 25 | sourceOfTruth = sourceOfTruth, 26 | converter = converter, 27 | validator = validator, 28 | memCache = memoryCache, 29 | ) 30 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import org.mobilenativefoundation.store.store5.OnUpdaterCompletion 4 | import org.mobilenativefoundation.store.store5.Updater 5 | import org.mobilenativefoundation.store.store5.UpdaterResult 6 | 7 | class TestUpdater : Updater { 8 | var exception: Throwable? = null 9 | var errorMessage: String? = null 10 | var successValue: Response? = null 11 | 12 | override suspend fun post( 13 | key: Key, 14 | value: Output, 15 | ): UpdaterResult { 16 | exception?.let { return UpdaterResult.Error.Exception(it) } 17 | errorMessage?.let { return UpdaterResult.Error.Message(it) } 18 | successValue?.let { return UpdaterResult.Success.Typed(it) } 19 | return UpdaterResult.Success.Untyped(value) 20 | } 21 | 22 | override val onCompletion: OnUpdaterCompletion? = null 23 | } 24 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.mutablestore.util 2 | 3 | import org.mobilenativefoundation.store.store5.Validator 4 | 5 | class TestValidator : Validator { 6 | private val map: HashMap = HashMap() 7 | 8 | fun whenever( 9 | item: Output, 10 | block: () -> Boolean, 11 | ) { 12 | map[item] = block() 13 | } 14 | 15 | override suspend fun isValid(item: Output): Boolean { 16 | return map[item] != false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util 2 | 3 | import kotlinx.coroutines.NonCancellable 4 | import kotlinx.coroutines.channels.BufferOverflow 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.MutableSharedFlow 7 | import kotlinx.coroutines.flow.emitAll 8 | import kotlinx.coroutines.flow.flow 9 | import kotlinx.coroutines.sync.Mutex 10 | import kotlinx.coroutines.sync.withLock 11 | import kotlinx.coroutines.withContext 12 | import org.mobilenativefoundation.store.store5.SourceOfTruth 13 | 14 | /** 15 | * Only used in FlowStoreTest. We should get rid of it eventually. 16 | */ 17 | class SimplePersisterAsFlowable( 18 | private val reader: suspend (Key) -> Output?, 19 | private val writer: suspend (Key, Output) -> Unit, 20 | private val delete: (suspend (Key) -> Unit)? = null, 21 | ) { 22 | val supportsDelete: Boolean 23 | get() = delete != null 24 | 25 | private val versionTracker = KeyTracker() 26 | 27 | fun flowReader(key: Key): Flow = 28 | flow { 29 | versionTracker.keyFlow(key).collect { 30 | emit(reader(key)) 31 | } 32 | } 33 | 34 | suspend fun flowWriter( 35 | key: Key, 36 | value: Output, 37 | ) { 38 | writer(key, value) 39 | versionTracker.invalidate(key) 40 | } 41 | 42 | suspend fun flowDelete(key: Key) { 43 | delete?.let { 44 | it(key) 45 | versionTracker.invalidate(key) 46 | } 47 | } 48 | } 49 | 50 | fun SimplePersisterAsFlowable.asSourceOfTruth() = 51 | SourceOfTruth.of( 52 | reader = ::flowReader, 53 | writer = ::flowWriter, 54 | delete = ::flowDelete.takeIf { supportsDelete }, 55 | ) 56 | 57 | /** 58 | * helper class which provides Flows for Keys that can be tracked. 59 | */ 60 | internal class KeyTracker { 61 | private val lock = Mutex() 62 | 63 | // list of open key flows 64 | private val flows = mutableMapOf() 65 | 66 | // for testing 67 | internal fun activeKeyCount() = flows.size 68 | 69 | /** 70 | * invalidates the given key. If there are flows returned from [keyFlow] for the given [key], 71 | * they'll receive a new emission 72 | */ 73 | suspend fun invalidate(key: Key) { 74 | lock.withLock { 75 | flows[key] 76 | }?.flow?.emit(Unit) 77 | } 78 | 79 | /** 80 | * Returns a Flow that emits once and then every time the given [key] is invalidated via 81 | * [invalidate] 82 | */ 83 | suspend fun keyFlow(key: Key): Flow { 84 | // it is important to allocate KeyFlow lazily (ony when the returned flow is collected 85 | // from). Otherwise, we might just create many of them that are never observed hence never 86 | // cleaned up 87 | return flow { 88 | val keyFlow = 89 | lock.withLock { 90 | flows.getOrPut(key) { KeyFlow() }.also { 91 | it.acquire() 92 | } 93 | } 94 | emit(Unit) 95 | try { 96 | emitAll(keyFlow.flow) 97 | } finally { 98 | withContext(NonCancellable) { 99 | lock.withLock { 100 | if (keyFlow.release()) { 101 | flows.remove(key) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * A data structure to count how many active flows we have on this flow 111 | */ 112 | private class KeyFlow { 113 | val flow = 114 | MutableSharedFlow( 115 | extraBufferCapacity = 1, 116 | onBufferOverflow = BufferOverflow.DROP_OLDEST, 117 | ) 118 | private var collectors: Int = 0 119 | 120 | fun acquire() { 121 | collectors++ 122 | } 123 | 124 | fun release() = (--collectors) == 0 125 | } 126 | } 127 | 128 | fun InMemoryPersister.asFlowable() = 129 | SimplePersisterAsFlowable( 130 | reader = this::read, 131 | writer = this::write, 132 | ) 133 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 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 | * https://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 org.mobilenativefoundation.store.store5.util 17 | 18 | import kotlinx.coroutines.delay 19 | import kotlinx.coroutines.flow.Flow 20 | import kotlinx.coroutines.flow.flow 21 | import kotlinx.coroutines.flow.flowOf 22 | import org.mobilenativefoundation.store.store5.Fetcher 23 | import org.mobilenativefoundation.store.store5.FetcherResult 24 | import kotlin.test.assertEquals 25 | 26 | class FakeFetcher( 27 | private vararg val responses: Pair, 28 | ) : Fetcher { 29 | private var index = 0 30 | override val name: String? = null 31 | override val fallback: Fetcher? = null 32 | 33 | override fun invoke(key: Key): Flow> { 34 | if (index >= responses.size) { 35 | throw AssertionError("unexpected fetch request") 36 | } 37 | val pair = responses[index++] 38 | assertEquals(pair.first, key) 39 | return flowOf(FetcherResult.Data(pair.second)) 40 | } 41 | } 42 | 43 | class FakeFlowingFetcher( 44 | private vararg val responses: Pair, 45 | ) : Fetcher { 46 | override val name: String? = null 47 | override val fallback: Fetcher? = null 48 | 49 | override fun invoke(key: Key) = 50 | flow { 51 | responses.filter { 52 | it.first == key 53 | }.forEach { 54 | // we delay here to avoid collapsing fetcher values, otherwise, there is a 55 | // possibility that consumer won't be fast enough to get both values before new 56 | // value overrides the previous one. 57 | delay(1) 58 | emit(FetcherResult.Data(it.second)) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util 2 | 3 | import org.mobilenativefoundation.store.store5.SourceOfTruth 4 | 5 | /** 6 | * An in-memory non-flowing persister for testing. 7 | */ 8 | open class InMemoryPersister { 9 | private val data = mutableMapOf() 10 | var preWriteCallback: (suspend (key: Key, value: Output) -> Output)? = null 11 | var postReadCallback: (suspend (key: Key, value: Output?) -> Output?)? = null 12 | 13 | @Suppress("RedundantSuspendModifier") // for function reference 14 | suspend fun read(key: Key): Output? { 15 | val value = data[key] 16 | postReadCallback?.let { 17 | return it(key, value) 18 | } 19 | return value 20 | } 21 | 22 | @Suppress("RedundantSuspendModifier") // for function reference 23 | open suspend fun write(key: Key, output: Output) { 24 | val value = preWriteCallback?.invoke(key, output) ?: output 25 | data[key] = value 26 | } 27 | 28 | @Suppress("RedundantSuspendModifier") // for function reference 29 | suspend fun deleteByKey(key: Key) { 30 | data.remove(key) 31 | } 32 | 33 | @Suppress("RedundantSuspendModifier") // for function reference 34 | suspend fun deleteAll() { 35 | data.clear() 36 | } 37 | 38 | fun peekEntry(key: Key): Output? { 39 | return data[key] 40 | } 41 | } 42 | 43 | fun InMemoryPersister.asSourceOfTruth() = 44 | SourceOfTruth.of( 45 | nonFlowReader = ::read, 46 | writer = ::write, 47 | delete = ::deleteByKey, 48 | deleteAll = ::deleteAll, 49 | ) 50 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util 2 | 3 | internal interface TestApi { 4 | fun get( 5 | key: Key, 6 | fail: Boolean = false, 7 | ttl: Long? = null, 8 | ): Network? 9 | 10 | fun post( 11 | key: Key, 12 | value: Output, 13 | fail: Boolean = false, 14 | ): Response 15 | } 16 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util 2 | 3 | import kotlinx.coroutines.flow.filterNot 4 | import kotlinx.coroutines.flow.first 5 | import org.mobilenativefoundation.store.store5.Store 6 | import org.mobilenativefoundation.store.store5.StoreReadRequest 7 | import org.mobilenativefoundation.store.store5.StoreReadResponse 8 | import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed 9 | 10 | /** 11 | * Helper factory that will return [StoreReadResponse.Data] for [key] 12 | * if it is cached otherwise will return fresh/network data (updating your caches) 13 | */ 14 | suspend fun Store.getData(key: Key) = 15 | stream( 16 | StoreReadRequest.cached(key, refresh = false), 17 | ).filterNot { 18 | it is StoreReadResponse.Loading 19 | }.mapIndexed { index, value -> 20 | value 21 | }.first().let { 22 | StoreReadResponse.Data(it.requireData(), it.origin) 23 | } 24 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.util.model.NoteData 4 | 5 | internal object NoteCollections { 6 | object Keys { 7 | const val OneAndTwo = "ONE_AND_TWO" 8 | } 9 | 10 | val OneAndTwo = NoteData.Collection(listOf(Notes.One, Notes.Two)) 11 | } 12 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.util.model.Note 4 | 5 | internal object Notes { 6 | val One = Note("1", "Title-1", "Content-1") 7 | val Two = Note("2", "Title-2", "Content-2") 8 | val Three = Note("3", "Title-3", "Content-3") 9 | val Four = Note("4", "Title-4", "Content-4") 10 | val Five = Note("5", "Title-5", "Content-5") 11 | val Six = Note("6", "Title-6", "Content-6") 12 | val Seven = Note("7", "Title-7", "Content-7") 13 | val Eight = Note("8", "Title-8", "Content-8") 14 | val Nine = Note("9", "Title-9", "Content-9") 15 | val Ten = Note("10", "Title-10", "Content-10") 16 | } 17 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.util.TestApi 4 | import org.mobilenativefoundation.store.store5.util.model.InputNote 5 | import org.mobilenativefoundation.store.store5.util.model.NetworkNote 6 | import org.mobilenativefoundation.store.store5.util.model.NoteData 7 | import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse 8 | 9 | internal class NotesApi : TestApi { 10 | internal val db = mutableMapOf() 11 | 12 | init { 13 | seed() 14 | } 15 | 16 | override fun get( 17 | key: NotesKey, 18 | fail: Boolean, 19 | ttl: Long?, 20 | ): NetworkNote { 21 | if (fail) { 22 | throw Exception() 23 | } 24 | 25 | val networkNote = db[key]!! 26 | return if (ttl != null) { 27 | networkNote.copy(ttl = ttl) 28 | } else { 29 | networkNote 30 | } 31 | } 32 | 33 | override fun post( 34 | key: NotesKey, 35 | value: InputNote, 36 | fail: Boolean, 37 | ): NotesWriteResponse { 38 | if (fail) { 39 | throw Exception() 40 | } 41 | 42 | db[key] = NetworkNote(value.data) 43 | 44 | return NotesWriteResponse(key, true) 45 | } 46 | 47 | private fun seed() { 48 | db[NotesKey.Single(Notes.One.id)] = NetworkNote(NoteData.Single(Notes.One)) 49 | db[NotesKey.Single(Notes.Two.id)] = NetworkNote(NoteData.Single(Notes.Two)) 50 | db[NotesKey.Single(Notes.Three.id)] = NetworkNote(NoteData.Single(Notes.Three)) 51 | db[NotesKey.Single(Notes.Four.id)] = NetworkNote(NoteData.Single(Notes.Four)) 52 | db[NotesKey.Single(Notes.Five.id)] = NetworkNote(NoteData.Single(Notes.Five)) 53 | db[NotesKey.Single(Notes.Six.id)] = NetworkNote(NoteData.Single(Notes.Six)) 54 | db[NotesKey.Single(Notes.Seven.id)] = NetworkNote(NoteData.Single(Notes.Seven)) 55 | db[NotesKey.Single(Notes.Eight.id)] = NetworkNote(NoteData.Single(Notes.Eight)) 56 | db[NotesKey.Single(Notes.Nine.id)] = NetworkNote(NoteData.Single(Notes.Nine)) 57 | db[NotesKey.Single(Notes.Ten.id)] = NetworkNote(NoteData.Single(Notes.Ten)) 58 | db[NotesKey.Collection(NoteCollections.Keys.OneAndTwo)] = NetworkNote(NoteCollections.OneAndTwo) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | class NotesBookkeeping { 4 | private val log: MutableMap = mutableMapOf() 5 | 6 | fun setLastFailedSync( 7 | key: NotesKey, 8 | timestamp: Long, 9 | fail: Boolean = false, 10 | ): Boolean { 11 | if (fail) { 12 | throw Exception() 13 | } 14 | log[key] = timestamp 15 | return true 16 | } 17 | 18 | fun getLastFailedSync( 19 | key: NotesKey, 20 | fail: Boolean = false, 21 | ): Long? { 22 | if (fail) { 23 | throw Exception() 24 | } 25 | 26 | return log[key] 27 | } 28 | 29 | fun clear( 30 | key: NotesKey, 31 | fail: Boolean = false, 32 | ): Boolean { 33 | if (fail) { 34 | throw Exception() 35 | } 36 | log.remove(key) 37 | return true 38 | } 39 | 40 | fun clear(fail: Boolean = false): Boolean { 41 | if (fail) { 42 | throw Exception() 43 | } 44 | log.clear() 45 | return true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.Converter 4 | import org.mobilenativefoundation.store.store5.impl.extensions.inHours 5 | import org.mobilenativefoundation.store.store5.util.model.InputNote 6 | import org.mobilenativefoundation.store.store5.util.model.NetworkNote 7 | import org.mobilenativefoundation.store.store5.util.model.OutputNote 8 | 9 | internal class NotesConverterProvider { 10 | fun provide(): Converter = 11 | Converter.Builder() 12 | .fromOutputToLocal { value -> InputNote(data = value.data, ttl = value.ttl) } 13 | .fromNetworkToLocal { value: NetworkNote -> 14 | InputNote( 15 | data = value.data, 16 | ttl = value.ttl ?: inHours(12), 17 | ) 18 | } 19 | .build() 20 | } 21 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.util.model.InputNote 4 | import org.mobilenativefoundation.store.store5.util.model.OutputNote 5 | 6 | internal class NotesDatabase { 7 | private val db: MutableMap = mutableMapOf() 8 | 9 | fun put( 10 | key: NotesKey, 11 | input: InputNote, 12 | fail: Boolean = false, 13 | ): Boolean { 14 | if (fail) { 15 | throw Exception() 16 | } 17 | 18 | db[key] = OutputNote(input.data, input.ttl ?: 0) 19 | return true 20 | } 21 | 22 | fun get( 23 | key: NotesKey, 24 | fail: Boolean = false, 25 | ): OutputNote? { 26 | if (fail) { 27 | throw Exception() 28 | } 29 | 30 | return db[key] 31 | } 32 | 33 | fun clear( 34 | key: NotesKey, 35 | fail: Boolean = false, 36 | ): Boolean { 37 | if (fail) { 38 | throw Exception() 39 | } 40 | db.remove(key) 41 | return true 42 | } 43 | 44 | fun clear(fail: Boolean = false): Boolean { 45 | if (fail) { 46 | throw Exception() 47 | } 48 | db.clear() 49 | return true 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | sealed class NotesKey { 4 | data class Single(val id: String) : NotesKey() 5 | 6 | data class Collection(val id: String) : NotesKey() 7 | } 8 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.Updater 4 | import org.mobilenativefoundation.store.store5.UpdaterResult 5 | import org.mobilenativefoundation.store.store5.util.model.InputNote 6 | import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse 7 | import org.mobilenativefoundation.store.store5.util.model.OutputNote 8 | 9 | internal class NotesUpdaterProvider(private val api: NotesApi) { 10 | fun provide(): Updater = 11 | Updater.by( 12 | post = { key, input -> 13 | val response = api.post(key, InputNote(input.data, input.ttl ?: 0)) 14 | if (response.ok) { 15 | UpdaterResult.Success.Typed(response) 16 | } else { 17 | UpdaterResult.Error.Message("Failed to sync") 18 | } 19 | }, 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake 2 | 3 | import org.mobilenativefoundation.store.store5.Validator 4 | import org.mobilenativefoundation.store.store5.impl.extensions.now 5 | import org.mobilenativefoundation.store.store5.util.model.OutputNote 6 | 7 | internal class NotesValidator(private val expiration: Long = now()) : Validator { 8 | override suspend fun isValid(item: OutputNote): Boolean = 9 | when { 10 | item.ttl == 0L -> true 11 | else -> item.ttl > expiration 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake.fallback 2 | 3 | class HardcodedPages { 4 | val name = "HardcodedPages" 5 | val db = mutableMapOf() 6 | 7 | init { 8 | seed() 9 | } 10 | 11 | private fun seed() { 12 | db["1"] = Page.Data("1") 13 | db["2"] = Page.Data("2") 14 | db["3"] = Page.Data("3") 15 | } 16 | 17 | fun get(key: String) = db[key] ?: throw Exception() 18 | } 19 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake.fallback 2 | 3 | sealed class Page { 4 | data class Data( 5 | val title: String, 6 | val ttl: Long? = null, 7 | ) : Page() 8 | 9 | object Empty : Page() 10 | } 11 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake.fallback 2 | 3 | class PagesDatabase { 4 | private val db: MutableMap = mutableMapOf() 5 | 6 | fun put( 7 | key: String, 8 | input: Page, 9 | ): Boolean { 10 | db[key] = input 11 | return true 12 | } 13 | 14 | fun get(key: String): Page? = db[key] 15 | } 16 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake.fallback 2 | 3 | class PrimaryPagesApi { 4 | val name = "PrimaryPagesApi" 5 | 6 | internal val db = mutableMapOf() 7 | 8 | init { 9 | seed() 10 | } 11 | 12 | private fun seed() { 13 | db["1"] = Page.Data("1") 14 | db["2"] = Page.Data("2") 15 | db["3"] = Page.Data("3") 16 | } 17 | 18 | fun fetch( 19 | key: String, 20 | fail: Boolean, 21 | ttl: Long?, 22 | ): Page { 23 | if (fail) { 24 | throw Exception() 25 | } 26 | 27 | return db[key] ?: Page.Empty 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.fake.fallback 2 | 3 | class SecondaryPagesApi() { 4 | val name: String = "SecondaryPagesApi" 5 | internal val db = mutableMapOf() 6 | 7 | init { 8 | seed() 9 | } 10 | 11 | fun get(key: String) = db[key] ?: throw Exception() 12 | 13 | private fun seed() { 14 | db["1"] = Page.Data("1") 15 | db["2"] = Page.Data("2") 16 | db["3"] = Page.Data("3") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt: -------------------------------------------------------------------------------- 1 | package org.mobilenativefoundation.store.store5.util.model 2 | 3 | import org.mobilenativefoundation.store.store5.util.fake.NotesKey 4 | 5 | internal sealed class NoteData { 6 | data class Single(val item: Note) : NoteData() 7 | 8 | data class Collection(val items: List) : NoteData() 9 | } 10 | 11 | internal data class NotesWriteResponse( 12 | val key: NotesKey, 13 | val ok: Boolean, 14 | ) 15 | 16 | internal data class NetworkNote( 17 | val data: NoteData? = null, 18 | val ttl: Long? = null, 19 | ) 20 | 21 | internal data class InputNote( 22 | val data: NoteData? = null, 23 | val ttl: Long? = null, 24 | ) 25 | 26 | internal data class OutputNote( 27 | val data: NoteData? = null, 28 | val ttl: Long, 29 | ) 30 | 31 | internal data class Note( 32 | val id: String, 33 | val title: String, 34 | val content: String, 35 | ) 36 | -------------------------------------------------------------------------------- /tooling/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | org.gradle.caching=true 3 | org.gradle.configureondemand=true 4 | -------------------------------------------------------------------------------- /tooling/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MobileNativeFoundation/Store/d7af217c08dca0630719d9c99ccfb532035eb2ed/tooling/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /tooling/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /tooling/plugins/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | group = "org.mobilenativefoundation.store" 6 | 7 | java { 8 | sourceCompatibility = JavaVersion.VERSION_11 9 | targetCompatibility = JavaVersion.VERSION_11 10 | 11 | toolchain { 12 | languageVersion.set(JavaLanguageVersion.of(11)) 13 | } 14 | } 15 | 16 | dependencies { 17 | compileOnly(libs.android.gradle.plugin) 18 | compileOnly(libs.kotlin.gradle.plugin) 19 | compileOnly(libs.dokka.gradle.plugin) 20 | compileOnly(libs.maven.publish.plugin) 21 | compileOnly(libs.kmmBridge.gradle.plugin) 22 | compileOnly(libs.atomic.fu.gradle.plugin) 23 | } 24 | 25 | gradlePlugin { 26 | plugins { 27 | register("kotlinMultiplatformConventionPlugin") { 28 | id = "org.mobilenativefoundation.store.multiplatform" 29 | implementationClass = "org.mobilenativefoundation.store.tooling.plugins.KotlinMultiplatformConventionPlugin" 30 | } 31 | 32 | register("androidConventionPlugin") { 33 | id = "org.mobilenativefoundation.store.android" 34 | implementationClass = "org.mobilenativefoundation.store.tooling.plugins.AndroidConventionPlugin" 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /tooling/plugins/src/main/kotlin/org/mobilenativefoundation/store/tooling/plugins/AndroidConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | package org.mobilenativefoundation.store.tooling.plugins 4 | 5 | import com.android.build.api.dsl.LibraryExtension 6 | import org.gradle.api.JavaVersion 7 | import org.gradle.api.Plugin 8 | import org.gradle.api.Project 9 | import org.gradle.kotlin.dsl.configure 10 | 11 | class AndroidConventionPlugin : Plugin { 12 | override fun apply(project: Project) = with(project) { 13 | with(pluginManager) { 14 | apply("org.jetbrains.kotlin.android") 15 | apply("com.android.library") 16 | apply("com.vanniktech.maven.publish") 17 | apply("org.jetbrains.dokka") 18 | apply("maven-publish") 19 | apply("org.jetbrains.kotlin.native.cocoapods") 20 | apply("org.jetbrains.kotlinx.binary-compatibility-validator") 21 | } 22 | 23 | 24 | extensions.configure { 25 | 26 | compileSdk = 34 27 | 28 | defaultConfig { 29 | minSdk = 24 30 | targetSdk = 34 31 | } 32 | 33 | lint { 34 | disable += "ComposableModifierFactory" 35 | disable += "ModifierFactoryExtensionFunction" 36 | disable += "ModifierFactoryReturnType" 37 | disable += "ModifierFactoryUnreferencedReceiver" 38 | } 39 | 40 | compileOptions { 41 | sourceCompatibility = JavaVersion.VERSION_11 42 | targetCompatibility = JavaVersion.VERSION_11 43 | } 44 | } 45 | 46 | configureKotlin() 47 | configureDokka() 48 | configureMavenPublishing() 49 | } 50 | } 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /tooling/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("UnstableApiUsage") 2 | dependencyResolutionManagement { 3 | repositories { 4 | gradlePluginPortal() 5 | google() 6 | mavenCentral() 7 | } 8 | 9 | versionCatalogs { 10 | create("libs") { 11 | from(files("../gradle/libs.versions.toml")) 12 | } 13 | } 14 | } 15 | 16 | rootProject.name = "tooling" 17 | 18 | include(":plugins") 19 | --------------------------------------------------------------------------------