├── .github
├── get-it-on-github.png
└── workflows
│ ├── build.yml
│ └── pr.yaml
├── .gitignore
├── .gitmodules
├── LICENSE
├── README-zh-CN.md
├── README.md
├── TERMS_OF_SERVICE_AND_PRIVACY_POLICY.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── org
│ │ └── dianqk
│ │ └── ruslin
│ │ └── ExampleInstrumentedTest.kt
│ ├── debug
│ └── res
│ │ ├── drawable
│ │ └── ic_launcher_background.xml
│ │ └── values
│ │ └── strings.xml
│ ├── main
│ ├── AndroidManifest.xml
│ ├── assets
│ │ └── github-markdown.min.css
│ ├── java
│ │ └── org
│ │ │ └── dianqk
│ │ │ └── ruslin
│ │ │ ├── MainActivity.kt
│ │ │ ├── RuslinApplication.kt
│ │ │ ├── data
│ │ │ ├── NotesRepository.kt
│ │ │ ├── RuslinNotesRepository.kt
│ │ │ ├── Settings.kt
│ │ │ ├── SyncWorker.kt
│ │ │ └── preference
│ │ │ │ ├── DarkThemePreference.kt
│ │ │ │ ├── HighContrastDarkThemePreference.kt
│ │ │ │ ├── LanguagesPreference.kt
│ │ │ │ ├── TextDirectionPreference.kt
│ │ │ │ └── ThemeIndexPreference.kt
│ │ │ ├── di
│ │ │ ├── CoroutineScopeModule.kt
│ │ │ ├── CoroutinesDispatchersModule.kt
│ │ │ ├── CoroutinesQualifiers.kt
│ │ │ ├── DataModules.kt
│ │ │ └── WorkerModule.kt
│ │ │ └── ui
│ │ │ ├── RuslinApp.kt
│ │ │ ├── RuslinNavGraph.kt
│ │ │ ├── RuslinNavigation.kt
│ │ │ ├── component
│ │ │ ├── BottomDrawer.kt
│ │ │ ├── Buttons.kt
│ │ │ ├── CombinedClickableSurface.kt
│ │ │ ├── ContentState.kt
│ │ │ ├── EditorToolbar.kt
│ │ │ ├── MarkdownRichText.kt
│ │ │ ├── MarkdownTextEditor.kt
│ │ │ ├── NavigationBarSpacer.kt
│ │ │ ├── PrimaryTextTabs.kt
│ │ │ ├── RadioDialog.kt
│ │ │ ├── SettingItem.kt
│ │ │ ├── SubTitle.kt
│ │ │ └── SuspendConfirmAlertDialog.kt
│ │ │ ├── ext
│ │ │ ├── AnimatedComposable.kt
│ │ │ ├── ContextExt.kt
│ │ │ └── DateFormat.kt
│ │ │ ├── page
│ │ │ ├── login
│ │ │ │ ├── LoginPage.kt
│ │ │ │ └── LoginViewModel.kt
│ │ │ ├── note_detail
│ │ │ │ ├── NoteDetailPage.kt
│ │ │ │ └── NoteDetailViewModel.kt
│ │ │ ├── notes
│ │ │ │ ├── NoteAbbrCard.kt
│ │ │ │ ├── NotesDrawerSheet.kt
│ │ │ │ ├── NotesPage.kt
│ │ │ │ └── NotesViewModel.kt
│ │ │ ├── search
│ │ │ │ ├── SearchPage.kt
│ │ │ │ └── SearchViewModel.kt
│ │ │ └── settings
│ │ │ │ ├── AboutPage.kt
│ │ │ │ ├── AppearancePage.kt
│ │ │ │ ├── CreditsPage.kt
│ │ │ │ ├── DarkThemePage.kt
│ │ │ │ ├── LanguagesPage.kt
│ │ │ │ ├── SettingsPage.kt
│ │ │ │ ├── TextDirectionPage.kt
│ │ │ │ ├── accounts
│ │ │ │ ├── AccountDetailPage.kt
│ │ │ │ └── AccountDetailViewModel.kt
│ │ │ │ └── tools
│ │ │ │ ├── ToolsPage.kt
│ │ │ │ ├── database
│ │ │ │ ├── DatabaseStatusPage.kt
│ │ │ │ └── DatabaseStatusViewModel.kt
│ │ │ │ └── log
│ │ │ │ ├── LogPage.kt
│ │ │ │ └── LogViewModel.kt
│ │ │ └── theme
│ │ │ ├── Color.kt
│ │ │ ├── Shapes.kt
│ │ │ ├── Theme.kt
│ │ │ ├── Type.kt
│ │ │ └── palette
│ │ │ ├── DynamicTonalPalette.kt
│ │ │ ├── MaterialYouStandard.kt
│ │ │ ├── TonalPalettes.kt
│ │ │ ├── colorspace
│ │ │ ├── cielab
│ │ │ │ ├── CieLab.kt
│ │ │ │ └── CieLch.kt
│ │ │ ├── ciexyz
│ │ │ │ └── CieXyz.kt
│ │ │ ├── jzazbz
│ │ │ │ ├── Jzazbz.kt
│ │ │ │ └── Jzczhz.kt
│ │ │ ├── oklab
│ │ │ │ ├── Oklab.kt
│ │ │ │ └── Oklch.kt
│ │ │ ├── rgb
│ │ │ │ ├── Rgb.kt
│ │ │ │ ├── RgbColorSpace.kt
│ │ │ │ └── transferfunction
│ │ │ │ │ ├── GammaTransferFunction.kt
│ │ │ │ │ ├── HLGTransferFunction.kt
│ │ │ │ │ ├── PQTransferFunction.kt
│ │ │ │ │ └── TransferFunction.kt
│ │ │ └── zcam
│ │ │ │ ├── Izazbz.kt
│ │ │ │ └── Zcam.kt
│ │ │ ├── core
│ │ │ ├── ColorSpaces.kt
│ │ │ ├── ColorUtils.kt
│ │ │ ├── CompositionLocals.kt
│ │ │ └── ZcamLch.kt
│ │ │ ├── data
│ │ │ └── Illuminant.kt
│ │ │ ├── dynamic
│ │ │ ├── Harmonies.kt
│ │ │ └── WallpaperColors.kt
│ │ │ └── util
│ │ │ └── MathUtils.kt
│ └── res
│ │ ├── drawable
│ │ ├── format_h1.xml
│ │ ├── format_h2.xml
│ │ ├── format_h3.xml
│ │ ├── format_h4.xml
│ │ ├── format_h5.xml
│ │ ├── format_h6.xml
│ │ ├── ic_database.xml
│ │ ├── ic_launcher_background.xml
│ │ └── ic_launcher_foreground.xml
│ │ ├── mipmap-anydpi
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── values-fa
│ │ └── strings.xml
│ │ ├── values-ru
│ │ └── strings.xml
│ │ ├── values-zh-rCN
│ │ └── strings.xml
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ ├── data_extraction_rules.xml
│ │ └── filepaths.xml
│ └── test
│ └── java
│ └── org
│ └── dianqk
│ └── ruslin
│ └── ExampleUnitTest.kt
├── build.gradle.kts
├── fastlane
└── metadata
│ └── android
│ ├── en-US
│ ├── changelogs
│ │ ├── 1003.txt
│ │ ├── 503.txt
│ │ ├── 603.txt
│ │ ├── 703.txt
│ │ ├── 803.txt
│ │ └── 903.txt
│ ├── full_description.txt
│ ├── images
│ │ ├── featureGraphic.png
│ │ └── icon.png
│ └── short_description.txt
│ ├── ru
│ ├── full_description.txt
│ └── short_description.txt
│ └── zh-CN
│ ├── changelogs
│ ├── 503.txt
│ ├── 603.txt
│ ├── 703.txt
│ ├── 803.txt
│ └── 903.txt
│ ├── full_description.txt
│ ├── images
│ ├── account.png
│ ├── editor.png
│ ├── folders.png
│ ├── notes.png
│ └── search.png
│ └── short_description.txt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── mdrender
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── org
│ │ └── dianqk
│ │ └── mdrender
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ └── java
│ │ └── org
│ │ └── dianqk
│ │ └── mdrender
│ │ └── MarkdownVisualTransformation.kt
│ └── test
│ └── java
│ └── org
│ └── dianqk
│ └── mdrender
│ └── ExampleUnitTest.kt
├── mdrenderbenchmark
├── .gitignore
├── benchmark-proguard-rules.pro
├── build.gradle.kts
└── src
│ ├── androidTest
│ ├── AndroidManifest.xml
│ └── java
│ │ └── org
│ │ └── dianqk
│ │ └── mdrenderbenchmark
│ │ └── MarkdownRenderBenchmark.kt
│ └── main
│ ├── AndroidManifest.xml
│ └── res
│ └── raw
│ └── text1.txt
├── ruslin-data-uniffi
├── .cargo
│ └── config.toml
├── .gitignore
├── .vscode
│ ├── settings.json.template
│ └── tasks.json
├── Cargo.lock
├── Cargo.toml
├── build.rs
├── build.sh
├── release.sh
├── rust-toolchain.toml
└── src
│ ├── ffi
│ ├── folder.rs
│ ├── mod.rs
│ ├── note.rs
│ ├── resource.rs
│ ├── status.rs
│ └── sync_info.rs
│ ├── html.rs
│ ├── lib.rs
│ └── ruslin.udl
├── scripts
├── gcloud_benchmark.sh
├── local_device_benchmark.sh
├── prepare_artifacts.sh
└── prepare_release.js
├── settings.gradle.kts
└── uniffi
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
├── androidTest
└── java
│ └── uniffi
│ └── ruslin
│ └── ExampleInstrumentedTest.kt
├── main
├── AndroidManifest.xml
└── java
│ └── uniffi
│ └── ruslin
│ └── .gitkeep
└── test
└── java
└── uniffi
└── ruslin
└── ExampleUnitTest.kt
/.github/get-it-on-github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/.github/get-it-on-github.png
--------------------------------------------------------------------------------
/.github/workflows/pr.yaml:
--------------------------------------------------------------------------------
1 | name: PR
2 |
3 | on:
4 | pull_request:
5 | branches: [ "main" ]
6 |
7 | env:
8 | CARGO_TERM_COLOR: always
9 |
10 | jobs:
11 | build:
12 |
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v3
17 | with:
18 | submodules: 'recursive'
19 | fetch-depth: 0
20 | - name: Set up JDK 17
21 | uses: actions/setup-java@v3
22 | with:
23 | java-version: '17'
24 | distribution: 'temurin'
25 | cache: gradle
26 | - name: Set up Rust
27 | uses: dtolnay/rust-toolchain@stable
28 | with:
29 | targets: 'aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android'
30 | # See https://github.com/Bromeon/godot-rust/blob/master/.github/workflows/full-ci.yml
31 | - name: "Write Android NDK version"
32 | run: |
33 | echo $ANDROID_SDK_ROOT
34 | HIGHEST_NDK_VERSION=$(ls $ANDROID_SDK_ROOT/ndk | tail -n1)
35 | echo "Highest Android NDK: $HIGHEST_NDK_VERSION"
36 | EXPECTED_NDK_VERSION=27.1.12297006
37 | echo "Expected Android NDK: $EXPECTED_NDK_VERSION"
38 | echo "ANDROID_NDK_VERSION=$EXPECTED_NDK_VERSION" >> $GITHUB_ENV
39 | - name: Check ruslin-data-uniffi
40 | working-directory: ./ruslin-data-uniffi
41 | run: |
42 | ANDROID_NDK_TOOLCHAIN_BIN=$ANDROID_SDK_ROOT/ndk/$ANDROID_NDK_VERSION/toolchains/llvm/prebuilt/linux-x86_64/bin
43 | export AR=$ANDROID_NDK_TOOLCHAIN_BIN/llvm-ar
44 |
45 | echo "Building aarch64-linux-android"
46 | export CC=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang
47 | export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang++
48 | export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang
49 | cargo fmt --check
50 | cargo clippy --no-deps
51 | cargo build --target aarch64-linux-android --verbose
52 | - name: Setup Gradle
53 | uses: gradle/gradle-build-action@v2.4.2
54 | - name: Execute Gradle build debug
55 | run: ./gradlew buildDebug
56 | - name: Execute Gradle lint report debug
57 | run: ./gradlew lintReportDebug
58 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.envrc
2 | /keystore.properties
3 | /uniffi/src/main/java/uniffi/ruslin/ruslin.kt
4 | /uniffi/src/main/jniLibs
5 | *.iml
6 | .gradle
7 | /local.properties
8 | /.idea/caches
9 | /.idea/libraries
10 | /.idea/modules.xml
11 | /.idea/workspace.xml
12 | /.idea/navEditor.xml
13 | /.idea/assetWizardSettings.xml
14 | /.idea/androidTestResultsUserPreferences.xml
15 | .DS_Store
16 | /build
17 | /captures
18 | .externalNativeBuild
19 | .cxx
20 | local.properties
21 |
22 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
23 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
24 |
25 | # User-specific stuff
26 | /.idea
27 | .idea/**/workspace.xml
28 | .idea/**/tasks.xml
29 | .idea/**/usage.statistics.xml
30 | .idea/**/dictionaries
31 | .idea/**/shelf
32 |
33 | # AWS User-specific
34 | .idea/**/aws.xml
35 |
36 | # Generated files
37 | .idea/**/contentModel.xml
38 |
39 | # Sensitive or high-churn files
40 | .idea/**/dataSources/
41 | .idea/**/dataSources.ids
42 | .idea/**/dataSources.local.xml
43 | .idea/**/sqlDataSources.xml
44 | .idea/**/dynamic.xml
45 | .idea/**/uiDesigner.xml
46 | .idea/**/dbnavigator.xml
47 |
48 | # Gradle
49 | .idea/**/gradle.xml
50 | .idea/**/libraries
51 |
52 | # Gradle and Maven with auto-import
53 | # When using Gradle or Maven with auto-import, you should exclude module files,
54 | # since they will be recreated, and may cause churn. Uncomment if using
55 | # auto-import.
56 | # .idea/artifacts
57 | # .idea/compiler.xml
58 | # .idea/jarRepositories.xml
59 | # .idea/modules.xml
60 | # .idea/*.iml
61 | # .idea/modules
62 | # *.iml
63 | # *.ipr
64 |
65 | # CMake
66 | cmake-build-*/
67 |
68 | # Mongo Explorer plugin
69 | .idea/**/mongoSettings.xml
70 |
71 | # File-based project format
72 | *.iws
73 |
74 | # IntelliJ
75 | out/
76 |
77 | # mpeltonen/sbt-idea plugin
78 | .idea_modules/
79 |
80 | # JIRA plugin
81 | atlassian-ide-plugin.xml
82 |
83 | # Cursive Clojure plugin
84 | .idea/replstate.xml
85 |
86 | # SonarLint plugin
87 | .idea/sonarlint/
88 |
89 | # Crashlytics plugin (for Android Studio and IntelliJ)
90 | com_crashlytics_export_strings.xml
91 | crashlytics.properties
92 | crashlytics-build.properties
93 | fabric.properties
94 |
95 | # Editor-based Rest Client
96 | .idea/httpRequests
97 |
98 | # Android studio 3.1+ serialized cache file
99 | .idea/caches/build_file_checksums.ser
100 |
101 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "ruslin-data-uniffi/ruslin-data"]
2 | path = ruslin-data-uniffi/ruslin-data
3 | url = git@github.com:ruslin-note/ruslin-data.git
4 |
--------------------------------------------------------------------------------
/README-zh-CN.md:
--------------------------------------------------------------------------------
1 |
2 |
Ruslin
3 |
一个简单的笔记应用,支持使用自部署的 Joplin 服务器同步笔记。
4 |
5 |

6 |

7 |

8 |

9 |

10 |
11 |
12 |
13 |
14 | 🚧 目前处于 Pre-alpha 阶段,不建议在生产环境使用,请注意做好备份。 🚧
15 |
16 | 已支持的功能:
17 |
18 | - ✅ 支持 Markdown 编辑和预览
19 | - ✅ 使用 jieba-rs 完成的全文搜索(支持中文和英文)
20 | - ✅ 使用自部署的 Joplin 服务器同步笔记
21 | - ✅ 手动和自动同步
22 | - 🚧 可能兼容 Joplin 的同步格式(不支持端到端加密)
23 |
24 | ## 下载
25 |
26 | [
](https://f-droid.org/packages/org.dianqk.ruslin/)
29 | [
](https://play.google.com/store/apps/details?id=org.dianqk.ruslin)
32 | [
](https://github.com/DianQK/ruslin-android/releases)
35 | 或者[每日构建版](https://github.com/ruslin-note/ruslin-android/releases/tag/nightly).
36 |
37 | > Ruslin 是一个可重复构建的应用,你不需要担心 F-Droid 和其他应用商店签名问题,参见:[向可重现的 F-Droid 前进](https://f-droid.org/zh_Hans/2023/01/15/towards-a-reproducible-fdroid.html)。
38 |
39 | ## 感谢
40 |
41 | - [Joplin](https://github.com/laurent22/joplin): [AGPL-3.0](https://github.com/laurent22/joplin/blob/dev/LICENSE)
42 | - [ReadYou](https://github.com/Ashinch/ReadYou): [GPL-3.0](https://github.com/Ashinch/ReadYou/blob/main/LICENSE)
43 | - [Seal](https://github.com/JunkFood02/Seal): [GPL-3.0](https://github.com/JunkFood02/Seal/blob/main/LICENSE)
44 |
45 | ## 许可证
46 |
47 | [GNU GPL v3.0](https://github.com/DianQK/ruslin-android/blob/main/LICENSE)
48 |
--------------------------------------------------------------------------------
/TERMS_OF_SERVICE_AND_PRIVACY_POLICY.md:
--------------------------------------------------------------------------------
1 | # Terms of Service and Privacy Policy
2 |
3 | ### Privacy Policy
4 |
5 | I take your privacy very seriously. **Ruslin** does not collect any user data, and all sensitive information (passwords and other account information) is securely stored in the local application database on your device.
6 |
7 | **Ruslin** will use the following permissions to provide you with the service.
8 |
9 | - Access Network permission (for accessing online content as you specify)
10 | - Get network status permission (for getting whether the device currently has available network conditions)
11 | - Background service permission (to automatically sync your notes)
12 |
13 | ### Third Party Services
14 |
15 | This policy does not apply to third-party services that you use with **Ruslin**. You can review the privacy policies of the third-party services you use on their websites.
16 |
17 | ### Disclaimers
18 |
19 | **Ruslin** is a note tool only. Your use of **Ruslin** is subject to the laws and regulations of your country and region, and any liability arising from your actions will be borne by you personally.
20 |
21 | ### Open Source License
22 |
23 | **Ruslin** is an open source project under the GNU GPL 3.0 Open Source License ①, which allows you to use, reference, and modify the source code of **Ruslin** for free, but does not allow the modified and derived code to be distributed and sold as closed-source commercial software. For details, please see the full GNU GPL 3.0 Open Source License ②.
24 |
25 | ### Appendix
26 |
27 | 1. [https://github.com/DianQK/ruslin-android](https://github.com/DianQK/ruslin-android)
28 | 2. [https://www.gnu.org/licenses/gpl-3.0.html](https://www.gnu.org/licenses/gpl-3.0.html)
29 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | -keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | -renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/org/dianqk/ruslin/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.*
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("org.dianqk.ruslin", appContext.packageName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/debug/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/debug/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Ruslin (d)
4 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
20 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
33 |
34 |
35 |
38 |
41 |
42 |
46 |
47 |
52 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
7 | import androidx.core.view.WindowCompat
8 | import dagger.hilt.android.AndroidEntryPoint
9 | import org.dianqk.ruslin.data.DataStoreKeys
10 | import org.dianqk.ruslin.data.SettingsProvider
11 | import org.dianqk.ruslin.data.dataStore
12 | import org.dianqk.ruslin.data.get
13 | import org.dianqk.ruslin.data.languages
14 | import org.dianqk.ruslin.data.preference.DarkThemePreference
15 | import org.dianqk.ruslin.data.preference.LanguagesPreference
16 | import org.dianqk.ruslin.ui.RuslinApp
17 |
18 | @AndroidEntryPoint
19 | class MainActivity : ComponentActivity() {
20 |
21 | override fun onCreate(savedInstanceState: Bundle?) {
22 | // Manually set the postSplashScreenTheme
23 | setTheme(
24 | when (DarkThemePreference.fromInt(dataStore.get(DataStoreKeys.DarkTheme))) {
25 | DarkThemePreference.UseDeviceTheme -> R.style.Theme_Ruslin
26 | DarkThemePreference.ON -> R.style.Theme_Ruslin_Dark
27 | DarkThemePreference.OFF -> R.style.Theme_Ruslin_Light
28 | }
29 | )
30 | installSplashScreen()
31 | super.onCreate(savedInstanceState)
32 | WindowCompat.setDecorFitsSystemWindows(window, false)
33 |
34 | LanguagesPreference.fromValue(languages).let {
35 | if (it == LanguagesPreference.UseDeviceLanguages) return@let
36 | it.setLocale(this)
37 | }
38 |
39 | setContent {
40 | SettingsProvider {
41 | RuslinApp()
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/RuslinApplication.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin
2 |
3 | import android.app.Application
4 | import android.util.Log
5 | import androidx.hilt.work.HiltWorkerFactory
6 | import androidx.work.Configuration
7 | import dagger.hilt.android.HiltAndroidApp
8 | import kotlinx.coroutines.CoroutineScope
9 | import org.dianqk.ruslin.data.NotesRepository
10 | import org.dianqk.ruslin.di.ApplicationScope
11 | import javax.inject.Inject
12 |
13 | @HiltAndroidApp
14 | class RuslinApplication : Application(), Configuration.Provider {
15 |
16 | @Inject
17 | lateinit var workerFactory: HiltWorkerFactory
18 |
19 | @Inject
20 | lateinit var notesRepository: NotesRepository
21 |
22 | @Inject
23 | @ApplicationScope
24 | lateinit var applicationScope: CoroutineScope
25 |
26 | override val workManagerConfiguration: Configuration
27 | get() = Configuration.Builder()
28 | .setWorkerFactory(workerFactory)
29 | .setMinimumLoggingLevel(Log.DEBUG)
30 | .build()
31 |
32 | override fun onCreate() {
33 | super.onCreate()
34 | notesRepository.doSync(isOnStart = true, fromScratch = false)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/NotesRepository.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data
2 |
3 | import kotlinx.coroutines.flow.SharedFlow
4 | import uniffi.ruslin.FfiAbbrNote
5 | import uniffi.ruslin.FfiFolder
6 | import uniffi.ruslin.FfiNote
7 | import uniffi.ruslin.FfiResource
8 | import uniffi.ruslin.FfiSearchNote
9 | import uniffi.ruslin.FfiStatus
10 | import uniffi.ruslin.FfiSyncInfo
11 | import uniffi.ruslin.SyncConfig
12 | import java.io.File
13 |
14 | interface NotesRepository {
15 |
16 | fun syncConfigExists(): Boolean
17 |
18 | suspend fun saveSyncConfig(config: SyncConfig): Result
19 |
20 | suspend fun getSyncConfig(): Result
21 |
22 | suspend fun synchronize(fromScratch: Boolean): Result
23 |
24 | val isSyncing: SharedFlow
25 | val syncFinished: SharedFlow>
26 |
27 | val notesChangedManually: SharedFlow
28 |
29 | val resourceDir: File
30 |
31 | fun doSync(isOnStart: Boolean, fromScratch: Boolean)
32 |
33 | fun newFolder(parentId: String?, title: String): FfiFolder
34 |
35 | suspend fun replaceFolder(folder: FfiFolder): Result
36 |
37 | suspend fun loadFolders(): Result>
38 |
39 | suspend fun deleteFolder(id: String): Result
40 |
41 | suspend fun loadAbbrNotes(parentId: String?): Result>
42 |
43 | fun newNote(parentId: String?, title: String, body: String): FfiNote
44 |
45 | suspend fun loadNote(id: String): Result
46 |
47 | suspend fun replaceNote(note: FfiNote): Result
48 |
49 | suspend fun deleteNote(id: String): Result
50 |
51 | suspend fun deleteNotes(ids: List): Result
52 |
53 | suspend fun conflictNoteExists(): Result
54 |
55 | suspend fun loadAbbrConflictNotes(): Result>
56 |
57 | suspend fun readLog(): String
58 |
59 | suspend fun readDatabaseStatus(): Result
60 |
61 | suspend fun search(searchTerm: String): Result>
62 |
63 | fun createResource(title: String, mime: String, fileExtension: String, size: Int): FfiResource
64 |
65 | suspend fun saveResource(resource: FfiResource): Result
66 |
67 | fun loadResource(id: String): Result
68 |
69 | suspend fun parseMarkdownToPreviewHtml(text: String): String
70 |
71 | suspend fun prepareJieba(): Result
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/SyncWorker.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data
2 |
3 | import android.content.Context
4 | import androidx.hilt.work.HiltWorker
5 | import androidx.work.Constraints
6 | import androidx.work.CoroutineWorker
7 | import androidx.work.ExistingPeriodicWorkPolicy
8 | import androidx.work.NetworkType
9 | import androidx.work.OneTimeWorkRequestBuilder
10 | import androidx.work.PeriodicWorkRequestBuilder
11 | import androidx.work.WorkManager
12 | import androidx.work.WorkerParameters
13 | import androidx.work.workDataOf
14 | import dagger.assisted.Assisted
15 | import dagger.assisted.AssistedInject
16 | import kotlinx.coroutines.Dispatchers
17 | import kotlinx.coroutines.withContext
18 | import java.util.concurrent.TimeUnit
19 |
20 | @HiltWorker
21 | class SyncWorker @AssistedInject constructor(
22 | @Assisted appContext: Context,
23 | @Assisted workerParams: WorkerParameters,
24 | private val notesRepository: NotesRepository
25 | ) : CoroutineWorker(appContext, workerParams) {
26 |
27 | override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
28 | // If the sync configuration does not exist, skip the sync task directly
29 | if (notesRepository.syncConfigExists()) {
30 | val fromScratch = inputData.getBoolean(FROM_SCRATCH, false)
31 | val syncResult = notesRepository.synchronize(fromScratch = fromScratch)
32 | return@withContext if (syncResult.isSuccess) Result.success() else Result.failure()
33 | } else {
34 | return@withContext Result.success()
35 | }
36 | }
37 |
38 | companion object {
39 | private const val FROM_SCRATCH = "fromScratch"
40 | private const val WORK_NAME = "Ruslin"
41 |
42 | fun enqueueOneTimeWork(workerManager: WorkManager, fromScratch: Boolean) {
43 | workerManager.enqueue(
44 | OneTimeWorkRequestBuilder().addTag(WORK_NAME)
45 | .setInputData(workDataOf(FROM_SCRATCH to fromScratch)).build()
46 | )
47 | }
48 |
49 | fun enqueuePeriodicWork(
50 | workManager: WorkManager,
51 | syncInterval: Long,
52 | syncOnlyWhenCharging: Boolean,
53 | syncOnlyOnWiFi: Boolean
54 | ) {
55 | workManager.enqueueUniquePeriodicWork(
56 | WORK_NAME,
57 | ExistingPeriodicWorkPolicy.UPDATE,
58 | PeriodicWorkRequestBuilder(syncInterval, TimeUnit.MINUTES)
59 | .setConstraints(
60 | Constraints.Builder()
61 | .setRequiresCharging(syncOnlyWhenCharging)
62 | .setRequiredNetworkType(
63 | if (syncOnlyOnWiFi) NetworkType.UNMETERED else NetworkType.CONNECTED
64 | )
65 | .build()
66 | )
67 | .addTag(WORK_NAME)
68 | .setInputData(workDataOf(FROM_SCRATCH to false))
69 | .setInitialDelay(15, TimeUnit.MINUTES)
70 | .build()
71 | )
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/preference/DarkThemePreference.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data.preference
2 |
3 | import android.content.Context
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.ReadOnlyComposable
7 | import androidx.datastore.preferences.core.Preferences
8 | import androidx.datastore.preferences.core.edit
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.launch
11 | import org.dianqk.ruslin.R
12 | import org.dianqk.ruslin.data.DataStoreKeys
13 | import org.dianqk.ruslin.data.dataStore
14 |
15 | sealed class DarkThemePreference(val value: Int) {
16 | object UseDeviceTheme : DarkThemePreference(0)
17 | object ON : DarkThemePreference(1)
18 | object OFF : DarkThemePreference(2)
19 |
20 | fun put(context: Context, scope: CoroutineScope) {
21 | scope.launch {
22 | context.dataStore.edit {
23 | it[DataStoreKeys.DarkTheme.key] = value
24 | }
25 | }
26 | }
27 |
28 | fun toDesc(context: Context): String =
29 | when (this) {
30 | UseDeviceTheme -> context.getString(R.string.use_device_theme)
31 | ON -> context.getString(R.string.on)
32 | OFF -> context.getString(R.string.off)
33 | }
34 |
35 | @Composable
36 | @ReadOnlyComposable
37 | fun isDarkTheme(): Boolean = when (this) {
38 | UseDeviceTheme -> isSystemInDarkTheme()
39 | ON -> true
40 | OFF -> false
41 | }
42 |
43 | companion object {
44 |
45 | val default = UseDeviceTheme
46 | val values = listOf(UseDeviceTheme, ON, OFF)
47 |
48 | fun fromInt(i: Int?) = when (i) {
49 | 0 -> UseDeviceTheme
50 | 1 -> ON
51 | 2 -> OFF
52 | else -> default
53 | }
54 |
55 | fun fromPreferences(preferences: Preferences) =
56 | fromInt(preferences[DataStoreKeys.DarkTheme.key])
57 | }
58 | }
59 |
60 | @Composable
61 | operator fun DarkThemePreference.not(): DarkThemePreference =
62 | when (this) {
63 | DarkThemePreference.UseDeviceTheme -> if (isSystemInDarkTheme()) {
64 | DarkThemePreference.OFF
65 | } else {
66 | DarkThemePreference.ON
67 | }
68 |
69 | DarkThemePreference.ON -> DarkThemePreference.OFF
70 | DarkThemePreference.OFF -> DarkThemePreference.ON
71 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/preference/HighContrastDarkThemePreference.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data.preference
2 |
3 | import android.content.Context
4 | import androidx.datastore.preferences.core.Preferences
5 | import androidx.datastore.preferences.core.edit
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.launch
8 | import org.dianqk.ruslin.data.DataStoreKeys
9 | import org.dianqk.ruslin.data.dataStore
10 |
11 | sealed class HighContrastDarkThemePreference(val value: Boolean) {
12 | object ON : HighContrastDarkThemePreference(true)
13 | object OFF : HighContrastDarkThemePreference(false)
14 |
15 | fun put(context: Context, scope: CoroutineScope) {
16 | scope.launch {
17 | context.dataStore.edit {
18 | it[DataStoreKeys.HighContrastDarkTheme.key] = value
19 | }
20 | }
21 | }
22 |
23 | companion object {
24 |
25 | val default = OFF
26 | val values = listOf(ON, OFF)
27 |
28 | fun fromPreferences(preferences: Preferences) =
29 | when (preferences[DataStoreKeys.HighContrastDarkTheme.key]) {
30 | true -> ON
31 | false -> OFF
32 | else -> default
33 | }
34 | }
35 | }
36 |
37 | operator fun HighContrastDarkThemePreference.not(): HighContrastDarkThemePreference =
38 | when (value) {
39 | true -> HighContrastDarkThemePreference.OFF
40 | false -> HighContrastDarkThemePreference.ON
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/preference/LanguagesPreference.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data.preference
2 |
3 | import android.content.Context
4 | import android.os.LocaleList
5 | import androidx.datastore.preferences.core.Preferences
6 | import androidx.datastore.preferences.core.edit
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.launch
9 | import org.dianqk.ruslin.R
10 | import org.dianqk.ruslin.data.DataStoreKeys
11 | import org.dianqk.ruslin.data.dataStore
12 | import java.util.Locale
13 |
14 | // See https://github.com/Ashinch/ReadYou/blob/435a6ea57704f45871565cb8980e1e45b69ff884/app/src/main/java/me/ash/reader/data/model/preference/LanguagesPreference.kt#L17.
15 |
16 | sealed class LanguagesPreference(val value: Int) {
17 | object UseDeviceLanguages : LanguagesPreference(0)
18 | object English : LanguagesPreference(1)
19 | object ChineseSimplified : LanguagesPreference(2)
20 | object Farsi : LanguagesPreference(3)
21 | object Russian : LanguagesPreference(4)
22 |
23 | fun put(context: Context, scope: CoroutineScope) {
24 | scope.launch {
25 | context.dataStore.edit {
26 | it[DataStoreKeys.Languages.key] = value
27 | }
28 | setLocale(context)
29 | }
30 | }
31 |
32 | fun getDesc(context: Context): String =
33 | when (this) {
34 | UseDeviceLanguages -> context.getString(R.string.use_device_languages)
35 | English -> context.getString(R.string.english)
36 | ChineseSimplified -> context.getString(R.string.chinese_simplified)
37 | Farsi -> context.getString(R.string.farsi)
38 | Russian -> context.getString(R.string.russian)
39 | }
40 |
41 | fun getLocale(): Locale =
42 | when (this) {
43 | UseDeviceLanguages -> LocaleList.getDefault().get(0)
44 | English -> Locale("en", "US")
45 | ChineseSimplified -> Locale("zh", "CN")
46 | Farsi -> Locale("fa", "IR")
47 | Russian -> Locale("ru", "RU")
48 | }
49 |
50 | fun setLocale(context: Context) {
51 | val locale = getLocale()
52 | val resources = context.resources
53 | val metrics = resources.displayMetrics
54 | val configuration = resources.configuration
55 | configuration.setLocale(locale)
56 | configuration.setLocales(LocaleList(locale))
57 | context.createConfigurationContext(configuration)
58 | resources.updateConfiguration(configuration, metrics)
59 |
60 | val appResources = context.applicationContext.resources
61 | val appMetrics = appResources.displayMetrics
62 | val appConfiguration = appResources.configuration
63 | appConfiguration.setLocale(locale)
64 | appConfiguration.setLocales(LocaleList(locale))
65 | context.applicationContext.createConfigurationContext(appConfiguration)
66 | appResources.updateConfiguration(appConfiguration, appMetrics)
67 | }
68 |
69 | companion object {
70 | val default = UseDeviceLanguages
71 | val values = listOf(
72 | UseDeviceLanguages,
73 | English,
74 | ChineseSimplified,
75 | Farsi,
76 | Russian
77 | )
78 |
79 | fun fromPreferences(preferences: Preferences): LanguagesPreference =
80 | when (preferences[DataStoreKeys.Languages.key]) {
81 | 0 -> UseDeviceLanguages
82 | 1 -> English
83 | 2 -> ChineseSimplified
84 | 3 -> Farsi
85 | 4 -> Russian
86 | else -> default
87 | }
88 |
89 | fun fromValue(value: Int): LanguagesPreference =
90 | when (value) {
91 | 0 -> UseDeviceLanguages
92 | 1 -> English
93 | 2 -> ChineseSimplified
94 | 3 -> Farsi
95 | 4 -> Russian
96 | else -> default
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/preference/TextDirectionPreference.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data.preference
2 |
3 | import android.content.Context
4 | import androidx.compose.ui.text.style.TextDirection
5 | import androidx.datastore.preferences.core.Preferences
6 | import androidx.datastore.preferences.core.edit
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.launch
9 | import org.dianqk.ruslin.R
10 | import org.dianqk.ruslin.data.DataStoreKeys
11 | import org.dianqk.ruslin.data.dataStore
12 |
13 | sealed class TextDirectionPreference(val value: Int) {
14 | object Ltr : TextDirectionPreference(1)
15 | object Rtl : TextDirectionPreference(2)
16 | object Auto : TextDirectionPreference(3)
17 |
18 | fun put(context: Context, scope: CoroutineScope) {
19 | scope.launch {
20 | context.dataStore.edit {
21 | it[DataStoreKeys.TextDirection.key] = value
22 | }
23 | }
24 | }
25 |
26 | fun toDesc(context: Context): String = when (this) {
27 | Ltr -> context.getString(R.string.ltr)
28 | Rtl -> context.getString(R.string.rtl)
29 | Auto -> context.getString(R.string.auto)
30 | }
31 |
32 | fun toHtmlDirAttribute(): String = when (this) {
33 | Ltr -> "ltr"
34 | Rtl -> "rtl"
35 | Auto -> "auto"
36 | }
37 |
38 | fun getTextDirection(): TextDirection = when (this) {
39 | Ltr -> TextDirection.Ltr
40 | Rtl -> TextDirection.Rtl
41 | Auto -> TextDirection.Content
42 | }
43 |
44 | companion object {
45 | val default = Ltr
46 | val values = listOf(
47 | Ltr,
48 | Rtl,
49 | Auto
50 | )
51 |
52 | fun fromPreferences(preferences: Preferences): TextDirectionPreference =
53 | when (preferences[DataStoreKeys.TextDirection.key]) {
54 | 1 -> Ltr
55 | 2 -> Rtl
56 | 3 -> Auto
57 | else -> default
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/data/preference/ThemeIndexPreference.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.data.preference
2 |
3 | import android.content.Context
4 | import androidx.datastore.preferences.core.Preferences
5 | import androidx.datastore.preferences.core.edit
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.launch
9 | import org.dianqk.ruslin.data.DataStoreKeys
10 | import org.dianqk.ruslin.data.dataStore
11 |
12 | object ThemeIndexPreference {
13 |
14 | const val default = 0
15 |
16 | fun put(context: Context, scope: CoroutineScope, value: Int) {
17 | scope.launch(Dispatchers.IO) {
18 | context.dataStore.edit {
19 | it[DataStoreKeys.ThemeIndex.key] = value
20 | }
21 | }
22 | }
23 |
24 | fun fromPreferences(preferences: Preferences) =
25 | preferences[DataStoreKeys.ThemeIndex.key] ?: default
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/di/CoroutineScopeModule.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.CoroutineScope
9 | import kotlinx.coroutines.SupervisorJob
10 | import javax.inject.Qualifier
11 | import javax.inject.Singleton
12 |
13 | // https://medium.com/androiddevelopers/create-an-application-coroutinescope-using-hilt-dd444e721528
14 |
15 | @Retention(AnnotationRetention.RUNTIME)
16 | @Qualifier
17 | annotation class ApplicationScope
18 |
19 | @InstallIn(SingletonComponent::class)
20 | @Module
21 | object CoroutinesScopesModule {
22 |
23 | @Singleton
24 | @ApplicationScope
25 | @Provides
26 | fun providesCoroutineScope(
27 | @DefaultDispatcher defaultDispatcher: CoroutineDispatcher
28 | ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher)
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/di/CoroutinesDispatchersModule.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.Dispatchers
9 |
10 | @InstallIn(SingletonComponent::class)
11 | @Module
12 | object CoroutinesDispatchersModule {
13 |
14 | @DefaultDispatcher
15 | @Provides
16 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
17 |
18 | @IoDispatcher
19 | @Provides
20 | fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
21 |
22 | @MainDispatcher
23 | @Provides
24 | fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
25 |
26 | @MainImmediateDispatcher
27 | @Provides
28 | fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/di/CoroutinesQualifiers.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.di
2 |
3 | import javax.inject.Qualifier
4 |
5 | @Retention(AnnotationRetention.RUNTIME)
6 | @Qualifier
7 | annotation class DefaultDispatcher
8 |
9 | @Retention(AnnotationRetention.RUNTIME)
10 | @Qualifier
11 | annotation class IoDispatcher
12 |
13 | @Retention(AnnotationRetention.RUNTIME)
14 | @Qualifier
15 | annotation class MainDispatcher
16 |
17 | @Retention(AnnotationRetention.BINARY)
18 | @Qualifier
19 | annotation class MainImmediateDispatcher
20 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/di/DataModules.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.di
2 |
3 | import android.content.Context
4 | import android.util.Log
5 | import androidx.work.WorkManager
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import kotlinx.coroutines.CoroutineScope
12 | import org.dianqk.ruslin.BuildConfig
13 | import org.dianqk.ruslin.data.NotesRepository
14 | import org.dianqk.ruslin.data.RuslinNotesRepository
15 | import javax.inject.Singleton
16 |
17 | @Module
18 | @InstallIn(SingletonComponent::class)
19 | object RepositoryModel {
20 |
21 | @Singleton
22 | @Provides
23 | fun provideNotesRepository(
24 | @ApplicationContext appContext: Context,
25 | @ApplicationScope applicationScope: CoroutineScope
26 | ): NotesRepository {
27 | val databaseDir = appContext.getDatabasePath("database.sql").parent!!
28 | val logTxtFile = appContext.filesDir.resolve("log.txt")
29 | if (logTxtFile.exists() && logTxtFile.length() >= 1024 * 100) {
30 | logTxtFile.delete()
31 | }
32 | if (BuildConfig.DEBUG) {
33 | if (logTxtFile.exists()) {
34 | logTxtFile.delete()
35 | }
36 | }
37 | val resourceDir = appContext.filesDir.resolve("resource")
38 | if (!resourceDir.exists()) {
39 | resourceDir.mkdirs()
40 | }
41 | Log.d("RepositoryModel", "provideNotesRepository $databaseDir")
42 | return RuslinNotesRepository(
43 | resourceDir = resourceDir,
44 | databaseDir = databaseDir,
45 | logTxtFile = logTxtFile,
46 | workManager = WorkManager.getInstance(appContext),
47 | appContext = appContext,
48 | applicationScope = applicationScope
49 | )
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/di/WorkerModule.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.di
2 |
3 | import android.content.Context
4 | import androidx.work.WorkManager
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.android.qualifiers.ApplicationContext
9 | import dagger.hilt.components.SingletonComponent
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | class WorkerModule {
15 |
16 | @Provides
17 | @Singleton
18 | fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
19 | WorkManager.getInstance(context)
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/RuslinApp.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.material3.ExperimentalMaterial3Api
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.ui.tooling.preview.Preview
9 | import androidx.navigation.compose.rememberNavController
10 | import org.dianqk.ruslin.ui.theme.RuslinTheme
11 |
12 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
13 | @Composable
14 | fun RuslinApp() {
15 | RuslinTheme {
16 | val navController = rememberNavController()
17 | val navigationActions = remember(navController) {
18 | RuslinNavigationActions(navController)
19 | }
20 | RuslinNavGraph(
21 | navController = navController,
22 | navigationActions = navigationActions
23 | )
24 | }
25 | }
26 |
27 | @Composable
28 | fun Greeting(name: String) {
29 | Text(text = "Hello $name!")
30 | }
31 |
32 | @Preview(showBackground = true)
33 | @Composable
34 | fun DefaultPreview() {
35 | RuslinTheme {
36 | Greeting("Android")
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/BottomDrawer.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.shape.CircleShape
6 | import androidx.compose.foundation.shape.RoundedCornerShape
7 | import androidx.compose.material.ExperimentalMaterialApi
8 | import androidx.compose.material.ModalBottomSheetDefaults
9 | import androidx.compose.material.ModalBottomSheetState
10 | import androidx.compose.material.ModalBottomSheetValue
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.Surface
13 | import androidx.compose.material3.Text
14 | import androidx.compose.material3.surfaceColorAtElevation
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.graphics.Color
20 | import androidx.compose.ui.unit.dp
21 | import androidx.compose.ui.zIndex
22 |
23 | @OptIn(ExperimentalMaterialApi::class)
24 | @Composable
25 | fun BottomDrawer(
26 | modifier: Modifier = Modifier,
27 | drawerState: ModalBottomSheetState = androidx.compose.material.rememberModalBottomSheetState(
28 | ModalBottomSheetValue.Hidden
29 | ),
30 | sheetContent: @Composable ColumnScope.() -> Unit = {},
31 | content: @Composable () -> Unit = {}
32 | ) {
33 | androidx.compose.material.ModalBottomSheetLayout(
34 | modifier = modifier,
35 | sheetShape = RoundedCornerShape(
36 | topStart = 28.0.dp,
37 | topEnd = 28.0.dp,
38 | bottomEnd = 0.0.dp,
39 | bottomStart = 0.0.dp
40 | ),
41 | sheetState = drawerState,
42 | sheetBackgroundColor = MaterialTheme.colorScheme.surface,
43 | sheetElevation = if (drawerState.isVisible) ModalBottomSheetDefaults.Elevation else 0.dp,
44 | sheetContent = {
45 | Column {
46 | Surface(
47 | color = MaterialTheme.colorScheme.surface,
48 | tonalElevation = 6.dp
49 | ) {
50 | Box(modifier = Modifier.padding(horizontal = 28.dp)) {
51 | Row(
52 | modifier = modifier
53 | .padding(top = 8.dp)
54 | .fillMaxWidth(),
55 | horizontalArrangement = Arrangement.Center,
56 | verticalAlignment = Alignment.CenterVertically
57 | ) {
58 | Row(
59 | modifier = modifier
60 | .size(32.dp, 4.dp)
61 | .clip(CircleShape)
62 | .background(
63 | MaterialTheme.colorScheme.onSurfaceVariant.copy(
64 | alpha = 0.4f
65 | )
66 | )
67 | .zIndex(1f)
68 | ) {}
69 | }
70 | Column {
71 | Spacer(modifier = Modifier.height(40.dp))
72 | sheetContent()
73 | Spacer(modifier = Modifier.height(28.dp))
74 | }
75 | }
76 | }
77 | NavigationBarSpacer(
78 | modifier = Modifier
79 | .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
80 | .fillMaxWidth()
81 | )
82 | }
83 | },
84 | content = content
85 | )
86 | }
87 |
88 | @Composable
89 | fun DrawerSheetSubtitle(
90 | modifier: Modifier = Modifier,
91 | text: String,
92 | color: Color = MaterialTheme.colorScheme.primary
93 | ) {
94 | Text(
95 | text = text,
96 | modifier = modifier
97 | .fillMaxWidth()
98 | .padding(start = 4.dp, top = 16.dp, bottom = 8.dp),
99 | color = color,
100 | style = MaterialTheme.typography.labelLarge
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/Buttons.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.foundation.layout.size
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.filled.ArrowBack
7 | import androidx.compose.material3.Button
8 | import androidx.compose.material3.FilledTonalButton
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.IconButton
11 | import androidx.compose.material3.OutlinedButton
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.graphics.vector.ImageVector
16 | import androidx.compose.ui.res.stringResource
17 | import androidx.compose.ui.unit.dp
18 | import org.dianqk.ruslin.R
19 |
20 | @Composable
21 | fun OutlinedButtonWithIcon(
22 | modifier: Modifier = Modifier,
23 | onClick: () -> Unit,
24 | icon: ImageVector,
25 | text: String
26 | ) {
27 | OutlinedButton(
28 | modifier = modifier,
29 | onClick = onClick
30 | ) {
31 | Icon(
32 | modifier = Modifier.size(18.dp),
33 | imageVector = icon,
34 | contentDescription = null
35 | )
36 | Text(
37 | modifier = Modifier.padding(start = 8.dp),
38 | text = text
39 | )
40 | }
41 | }
42 |
43 | @Composable
44 | fun FilledTonalButtonWithIcon(
45 | modifier: Modifier = Modifier,
46 | onClick: () -> Unit,
47 | icon: ImageVector,
48 | text: String
49 | ) {
50 | FilledTonalButton(
51 | modifier = modifier,
52 | onClick = onClick
53 | ) {
54 | Icon(
55 | modifier = Modifier.size(18.dp),
56 | imageVector = icon,
57 | contentDescription = null
58 | )
59 | Text(
60 | modifier = Modifier.padding(start = 8.dp),
61 | text = text
62 | )
63 | }
64 | }
65 |
66 | @Composable
67 | fun FilledButtonWithIcon(
68 | modifier: Modifier = Modifier,
69 | onClick: () -> Unit,
70 | enabled: Boolean = true,
71 | icon: @Composable () -> Unit,
72 | text: String
73 | ) {
74 | Button(
75 | modifier = modifier,
76 | onClick = onClick,
77 | enabled = enabled
78 | ) {
79 | icon()
80 | Text(
81 | modifier = Modifier.padding(start = 6.dp),
82 | text = text
83 | )
84 | }
85 | }
86 |
87 | @Composable
88 | fun BackButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
89 | IconButton(modifier = modifier, onClick = onClick) {
90 | Icon(Icons.Default.ArrowBack, stringResource(id = R.string.back))
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/CombinedClickableSurface.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.foundation.*
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.material.ripple.rememberRipple
7 | import androidx.compose.material3.*
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.CompositionLocalProvider
10 | import androidx.compose.runtime.NonRestartableComposable
11 | import androidx.compose.runtime.remember
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.draw.clip
14 | import androidx.compose.ui.draw.shadow
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.graphics.RectangleShape
17 | import androidx.compose.ui.graphics.Shape
18 | import androidx.compose.ui.semantics.Role
19 | import androidx.compose.ui.unit.Dp
20 | import androidx.compose.ui.unit.dp
21 |
22 | // See Surface
23 | @OptIn(ExperimentalFoundationApi::class)
24 | @ExperimentalMaterial3Api
25 | @Composable
26 | @NonRestartableComposable
27 | fun CombinedClickableSurface(
28 | onClick: () -> Unit,
29 | onLongClick: () -> Unit,
30 | modifier: Modifier = Modifier,
31 | enabled: Boolean = true,
32 | shape: Shape = RectangleShape,
33 | color: Color = MaterialTheme.colorScheme.surface,
34 | contentColor: Color = contentColorFor(color),
35 | tonalElevation: Dp = 0.dp,
36 | shadowElevation: Dp = 0.dp,
37 | border: BorderStroke? = null,
38 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
39 | content: @Composable () -> Unit
40 | ) {
41 | val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
42 | CompositionLocalProvider(
43 | LocalContentColor provides contentColor,
44 | LocalAbsoluteTonalElevation provides absoluteElevation
45 | ) {
46 | Box(
47 | modifier = modifier
48 | .minimumInteractiveComponentSize()
49 | .surface(
50 | shape = shape,
51 | backgroundColor = surfaceColorAtElevation(
52 | color = color,
53 | elevation = absoluteElevation
54 | ),
55 | border = border,
56 | shadowElevation = shadowElevation
57 | )
58 | .combinedClickable(
59 | interactionSource = interactionSource,
60 | indication = rememberRipple(),
61 | enabled = enabled,
62 | role = Role.Tab,
63 | onLongClick = onLongClick,
64 | onClick = onClick
65 | ),
66 | propagateMinConstraints = true
67 | ) {
68 | content()
69 | }
70 | }
71 | }
72 |
73 | private fun Modifier.surface(
74 | shape: Shape,
75 | backgroundColor: Color,
76 | border: BorderStroke?,
77 | shadowElevation: Dp
78 | ) = this
79 | .shadow(shadowElevation, shape, clip = false)
80 | .then(if (border != null) Modifier.border(border, shape) else Modifier)
81 | .background(color = backgroundColor, shape = shape)
82 | .clip(shape)
83 |
84 | @Composable
85 | private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
86 | return if (color == MaterialTheme.colorScheme.surface) {
87 | MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
88 | } else {
89 | color
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/ContentState.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.animation.core.LinearEasing
4 | import androidx.compose.animation.core.animateFloat
5 | import androidx.compose.animation.core.infiniteRepeatable
6 | import androidx.compose.animation.core.rememberInfiniteTransition
7 | import androidx.compose.animation.core.tween
8 | import androidx.compose.foundation.layout.Arrangement
9 | import androidx.compose.foundation.layout.Column
10 | import androidx.compose.foundation.layout.Spacer
11 | import androidx.compose.foundation.layout.fillMaxSize
12 | import androidx.compose.foundation.layout.height
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.material.icons.Icons
15 | import androidx.compose.material.icons.outlined.Pending
16 | import androidx.compose.material.icons.outlined.Refresh
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.draw.rotate
23 | import androidx.compose.ui.unit.dp
24 |
25 | @Composable
26 | fun ContentState(
27 | modifier: Modifier = Modifier,
28 | icon: @Composable () -> Unit,
29 | text: @Composable () -> Unit,
30 | ) {
31 |
32 | Column(
33 | modifier = modifier.fillMaxSize(),
34 | verticalArrangement = Arrangement.Center,
35 | horizontalAlignment = Alignment.CenterHorizontally,
36 | ) {
37 | icon()
38 | Spacer(modifier = Modifier.height(20.dp))
39 | text()
40 | }
41 | }
42 |
43 | @Composable
44 | fun ContentLoadingState(
45 | modifier: Modifier = Modifier,
46 | text: @Composable () -> Unit,
47 | ) {
48 | val syncAngle by rememberInfiniteTransition().animateFloat(
49 | initialValue = 0f,
50 | targetValue = 360f,
51 | animationSpec = infiniteRepeatable(
52 | animation = tween(1000, easing = LinearEasing)
53 | )
54 | )
55 | ContentState(
56 | modifier = modifier,
57 | icon = {
58 | Icon(
59 | modifier = Modifier
60 | .size(60.dp)
61 | .rotate(syncAngle),
62 | imageVector = Icons.Outlined.Refresh,
63 | contentDescription = null
64 | )
65 | },
66 | text = text
67 | )
68 | }
69 |
70 | @Composable
71 | fun ContentEmptyState(
72 | modifier: Modifier = Modifier,
73 | text: @Composable () -> Unit,
74 | ) {
75 | ContentState(
76 | modifier = modifier,
77 | icon = {
78 | Icon(
79 | modifier = Modifier
80 | .size(60.dp),
81 | imageVector = Icons.Outlined.Pending,
82 | contentDescription = null
83 | )
84 | },
85 | text = text
86 | )
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/NavigationBarSpacer.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.foundation.layout.Spacer
4 | import androidx.compose.foundation.layout.WindowInsets
5 | import androidx.compose.foundation.layout.asPaddingValues
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.navigationBars
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 |
12 | @Composable
13 | fun NavigationBarSpacer(modifier: Modifier = Modifier) {
14 | Spacer(
15 | modifier = modifier.height(
16 | with(WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) {
17 | if (this.value > 30f) this else 0f.dp
18 | }
19 | )
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/RadioDialog.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.lazy.LazyColumn
8 | import androidx.compose.foundation.lazy.items
9 | import androidx.compose.foundation.shape.CircleShape
10 | import androidx.compose.material3.AlertDialog
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.material3.RadioButton
13 | import androidx.compose.material3.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.Immutable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.text.TextStyle
20 | import androidx.compose.ui.text.style.BaselineShift
21 | import androidx.compose.ui.unit.dp
22 |
23 | @Composable
24 | fun RadioDialog(
25 | modifier: Modifier = Modifier,
26 | title: String = "",
27 | options: List = emptyList(),
28 | onDismissRequest: () -> Unit = {}
29 | ) {
30 | AlertDialog(
31 | modifier = modifier,
32 | onDismissRequest = onDismissRequest,
33 | title = {
34 | Text(text = title)
35 | },
36 | text = {
37 | LazyColumn {
38 | items(options) { option ->
39 | Row(
40 | modifier = Modifier
41 | .fillMaxWidth()
42 | .clip(CircleShape)
43 | .clickable {
44 | option.onClick()
45 | onDismissRequest()
46 | },
47 | verticalAlignment = Alignment.CenterVertically
48 | ) {
49 | RadioButton(selected = option.selected, onClick = {
50 | option.onClick()
51 | onDismissRequest()
52 | })
53 | Text(
54 | modifier = Modifier.padding(start = 6.dp),
55 | text = option.text,
56 | style = MaterialTheme.typography.bodyLarge.copy(
57 | baselineShift = BaselineShift.None
58 | ).merge(other = option.style),
59 | color = MaterialTheme.colorScheme.onSurface
60 | )
61 | }
62 | }
63 | }
64 | },
65 | confirmButton = {
66 | },
67 | dismissButton = {
68 | }
69 | )
70 | }
71 |
72 | @Immutable
73 | data class RadioDialogOption(
74 | val text: String = "",
75 | val style: TextStyle? = null,
76 | val selected: Boolean = false,
77 | val onClick: () -> Unit = {}
78 | )
79 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/SubTitle.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.graphics.Color
10 | import androidx.compose.ui.unit.dp
11 |
12 | @Composable
13 | fun SubTitle(
14 | modifier: Modifier = Modifier,
15 | text: String,
16 | color: Color = MaterialTheme.colorScheme.primary
17 | ) {
18 | Text(
19 | text = text,
20 | modifier = modifier
21 | .fillMaxWidth()
22 | .padding(),
23 | color = color,
24 | style = MaterialTheme.typography.labelLarge
25 | )
26 | }
27 |
28 | @Composable
29 | fun PreferenceSubtitle(
30 | modifier: Modifier = Modifier,
31 | text: String,
32 | color: Color = MaterialTheme.colorScheme.primary
33 | ) {
34 | Text(
35 | text = text,
36 | modifier = modifier
37 | .fillMaxWidth()
38 | .padding(start = 24.dp, top = 28.dp, bottom = 12.dp),
39 | color = color,
40 | style = MaterialTheme.typography.labelLarge
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/component/SuspendConfirmAlertDialog.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.component
2 |
3 | import androidx.compose.animation.core.LinearEasing
4 | import androidx.compose.animation.core.animateFloat
5 | import androidx.compose.animation.core.infiniteRepeatable
6 | import androidx.compose.animation.core.rememberInfiniteTransition
7 | import androidx.compose.animation.core.tween
8 | import androidx.compose.material3.AlertDialog
9 | import androidx.compose.material3.Text
10 | import androidx.compose.material3.TextButton
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateOf
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.runtime.rememberCoroutineScope
16 | import androidx.compose.runtime.setValue
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.alpha
19 | import androidx.compose.ui.res.stringResource
20 | import kotlinx.coroutines.CoroutineScope
21 | import kotlinx.coroutines.launch
22 | import org.dianqk.ruslin.R
23 |
24 | @Composable
25 | fun SuspendConfirmAlertDialog(
26 | scope: CoroutineScope = rememberCoroutineScope(),
27 | icon: @Composable (() -> Unit)? = null,
28 | inProgressIcon: @Composable ((Modifier) -> Unit)? = null,
29 | title: @Composable (() -> Unit)? = null,
30 | text: @Composable (() -> Unit)? = null,
31 | onDismissRequest: () -> Unit,
32 | onConfirm: suspend () -> Unit,
33 | onConfirmFinished: () -> Unit,
34 | ) {
35 | var inProgress by remember { mutableStateOf(false) }
36 | val inProgressAnimation by rememberInfiniteTransition().animateFloat(
37 | initialValue = 1f,
38 | targetValue = 0f,
39 | animationSpec = infiniteRepeatable(
40 | animation = tween(300, easing = LinearEasing)
41 | )
42 | )
43 | AlertDialog(
44 | onDismissRequest = {
45 | if (!inProgress) {
46 | onDismissRequest()
47 | }
48 | },
49 | confirmButton = {
50 | TextButton(enabled = !inProgress, onClick = {
51 | inProgress = true
52 | scope.launch {
53 | onConfirm()
54 | inProgress = false
55 | onConfirmFinished()
56 | }
57 | }) {
58 | Text(text = stringResource(id = R.string.confirm))
59 | }
60 | },
61 | dismissButton = {
62 | TextButton(enabled = !inProgress, onClick = {
63 | onDismissRequest()
64 | }) {
65 | Text(text = stringResource(id = R.string.cancel))
66 | }
67 | },
68 | icon = {
69 | if (inProgress) {
70 | if (inProgressIcon != null) {
71 | inProgressIcon(Modifier.alpha(inProgressAnimation))
72 | }
73 | } else {
74 | if (icon != null) {
75 | icon()
76 | }
77 | }
78 | },
79 | title = title,
80 | text = text
81 | )
82 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/ext/AnimatedComposable.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.ext
2 |
3 | import androidx.compose.animation.*
4 | import androidx.compose.animation.core.tween
5 | import androidx.compose.runtime.Composable
6 | import androidx.navigation.NamedNavArgument
7 | import androidx.navigation.NavBackStackEntry
8 | import androidx.navigation.NavDeepLink
9 | import androidx.navigation.NavGraphBuilder
10 | import com.google.accompanist.navigation.animation.composable
11 |
12 | // https://github.com/Ashinch/ReadYou/blob/5b22b469129ed2accb7e62f71dfba6c8579e9ef5/app/src/main/java/me/ash/reader/ui/ext/NavGraphBuilderExt.kt
13 |
14 | @OptIn(ExperimentalAnimationApi::class)
15 | fun NavGraphBuilder.animatedComposable(
16 | route: String,
17 | arguments: List = emptyList(),
18 | deepLinks: List = emptyList(),
19 | content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit
20 | ) = composable(
21 | route = route,
22 | arguments = arguments,
23 | deepLinks = deepLinks,
24 | enterTransition = {
25 | fadeIn(animationSpec = tween(220, delayMillis = 90)) +
26 | scaleIn(
27 | initialScale = 0.92f,
28 | animationSpec = tween(220, delayMillis = 90)
29 | )
30 | },
31 | exitTransition = {
32 | fadeOut(animationSpec = tween(90))
33 | },
34 | popEnterTransition = {
35 | fadeIn(animationSpec = tween(220, delayMillis = 90)) +
36 | scaleIn(
37 | initialScale = 0.92f,
38 | animationSpec = tween(220, delayMillis = 90)
39 | )
40 | },
41 | popExitTransition = {
42 | fadeOut(animationSpec = tween(90))
43 | },
44 | content = content
45 | )
46 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/ext/ContextExt.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.ext
2 |
3 | import android.content.Context
4 | import android.widget.Toast
5 | import org.dianqk.ruslin.R
6 | import java.io.File
7 |
8 | private var toast: Toast? = null
9 |
10 | fun Context.showToast(message: String?, duration: Int = Toast.LENGTH_SHORT) {
11 | toast?.cancel()
12 | toast = Toast.makeText(this, message, duration)
13 | toast?.show()
14 | }
15 |
16 | fun Context.showComingSoon() {
17 | showToast(this.getString(R.string.coming_soon))
18 | }
19 |
20 | fun Context.getCacheSharedDir(): File {
21 | val sharedDir = cacheDir.resolve("shared")
22 | if (!sharedDir.exists()) {
23 | sharedDir.mkdirs()
24 | }
25 | return sharedDir
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/ext/DateFormat.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.ext
2 |
3 | import android.content.Context
4 | import androidx.core.os.ConfigurationCompat
5 | import java.text.SimpleDateFormat
6 | import java.util.Date
7 |
8 | fun Date.formatAsYmdHms(context: Context): String {
9 | val locale = ConfigurationCompat.getLocales(context.resources.configuration)[0]
10 | val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", locale)
11 | return df.format(this)
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/login/LoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.login
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import kotlinx.coroutines.flow.asStateFlow
9 | import kotlinx.coroutines.flow.update
10 | import kotlinx.coroutines.launch
11 | import org.dianqk.ruslin.data.NotesRepository
12 | import uniffi.ruslin.SyncConfig
13 | import javax.inject.Inject
14 |
15 | data class LoginInfoUiState(
16 | val url: String = "",
17 | val email: String = "",
18 | val password: String = "",
19 | val errorMessage: String? = null,
20 | var loginSuccess: Boolean = false,
21 | val isLoggingIn: Boolean = false,
22 | )
23 |
24 | @HiltViewModel
25 | class LoginViewModel @Inject constructor(
26 | private val notesRepository: NotesRepository
27 | ) : ViewModel() {
28 |
29 | private val _uiState = MutableStateFlow(LoginInfoUiState())
30 | val uiState: StateFlow = _uiState.asStateFlow()
31 |
32 | init {
33 | viewModelScope.launch {
34 | notesRepository.getSyncConfig()
35 | .onSuccess { syncConfig ->
36 | syncConfig?.let {
37 | if (syncConfig is SyncConfig.JoplinServer) {
38 | _uiState.update {
39 | it.copy(
40 | email = syncConfig.email,
41 | url = syncConfig.host,
42 | password = syncConfig.password
43 | )
44 | }
45 | }
46 | }
47 | }
48 | .onFailure { e ->
49 | _uiState.update {
50 | it.copy(errorMessage = e.toString())
51 | }
52 | }
53 | }
54 | }
55 |
56 | fun setUrl(url: String) {
57 | _uiState.update {
58 | it.copy(
59 | url = url
60 | )
61 | }
62 | }
63 |
64 | fun setEmail(email: String) {
65 | _uiState.update {
66 | it.copy(
67 | email = email
68 | )
69 | }
70 | }
71 |
72 | fun setPassword(password: String) {
73 | _uiState.update {
74 | it.copy(
75 | password = password
76 | )
77 | }
78 | }
79 |
80 | fun dismissSnackBar() {
81 | _uiState.update {
82 | it.copy(errorMessage = null)
83 | }
84 | }
85 |
86 | fun login() {
87 | _uiState.update {
88 | it.copy(isLoggingIn = true)
89 | }
90 | viewModelScope.launch {
91 | val syncConfig = SyncConfig.JoplinServer(
92 | host = uiState.value.url,
93 | email = uiState.value.email,
94 | password = uiState.value.password
95 | )
96 | notesRepository.saveSyncConfig(syncConfig)
97 | .onSuccess {
98 | notesRepository.doSync(isOnStart = false, fromScratch = false)
99 | _uiState.update {
100 | it.copy(
101 | isLoggingIn = false,
102 | loginSuccess = true
103 | )
104 | }
105 | }
106 | .onFailure { e ->
107 | _uiState.update {
108 | it.copy(
109 | isLoggingIn = false,
110 | errorMessage = e.toString()
111 | )
112 | }
113 | }
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/notes/NoteAbbrCard.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.notes
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.foundation.ExperimentalFoundationApi
5 | import androidx.compose.foundation.combinedClickable
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.selection.selectable
12 | import androidx.compose.material.ExperimentalMaterialApi
13 | import androidx.compose.material3.Checkbox
14 | import androidx.compose.material3.MaterialTheme
15 | import androidx.compose.material3.Text
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.platform.LocalContext
20 | import androidx.compose.ui.text.style.TextDirection
21 | import androidx.compose.ui.text.style.TextOverflow
22 | import androidx.compose.ui.unit.dp
23 | import org.dianqk.ruslin.ui.ext.formatAsYmdHms
24 | import uniffi.ruslin.FfiAbbrNote
25 | import java.util.Date
26 |
27 | @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
28 | @Composable
29 | fun NoteAbbrCard(
30 | modifier: Modifier = Modifier,
31 | note: FfiAbbrNote,
32 | titleTextDirection: TextDirection,
33 | isSelectEnabled: () -> Boolean,
34 | isSelected: () -> Boolean,
35 | onSelect: () -> Unit,
36 | onClick: () -> Unit,
37 | onLongClick: () -> Unit,
38 | ) {
39 | Box(modifier = with(modifier) {
40 | if (isSelectEnabled()) {
41 | selectable(selected = isSelected(), onClick = onSelect)
42 | } else {
43 | combinedClickable(
44 | enabled = true,
45 | onClick = onClick,
46 | onLongClick = onLongClick,
47 | )
48 | }
49 | }
50 | .fillMaxWidth()
51 | .padding(horizontal = 16.dp, vertical = 10.dp)) {
52 | Row(modifier = Modifier) {
53 | AnimatedVisibility(
54 | modifier = Modifier.align(Alignment.CenterVertically),
55 | visible = isSelectEnabled(),
56 | ) {
57 | Checkbox(
58 | modifier = Modifier.padding(start = 4.dp, end = 16.dp),
59 | checked = isSelected(),
60 | onCheckedChange = null
61 | )
62 | }
63 | Column {
64 | Text(
65 | modifier = Modifier.fillMaxWidth(),
66 | text = note.title,
67 | style = MaterialTheme.typography.titleMedium.copy(textDirection = titleTextDirection),
68 | maxLines = 2,
69 | overflow = TextOverflow.Ellipsis
70 | )
71 | Text(
72 | text = Date(note.userUpdatedTime).formatAsYmdHms(LocalContext.current),
73 | style = MaterialTheme.typography.bodySmall
74 | )
75 | }
76 | }
77 | }
78 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/search/SearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.search
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import kotlinx.coroutines.flow.asStateFlow
9 | import kotlinx.coroutines.flow.update
10 | import kotlinx.coroutines.launch
11 | import org.dianqk.ruslin.data.NotesRepository
12 | import uniffi.ruslin.FfiSearchNote
13 | import javax.inject.Inject
14 |
15 | data class SearchUiState(
16 | val status: String = "",
17 | val searchTerm: String = "",
18 | val searchingTerm: String = "",
19 | val isSearching: Boolean = false,
20 | val searchedNotes: List = emptyList(),
21 | val notFound: Boolean = false,
22 | val showKeyboardOnFirstLoad: Boolean = true,
23 | )
24 |
25 | @HiltViewModel
26 | class SearchViewModel @Inject constructor(
27 | private val notesRepository: NotesRepository
28 | ) : ViewModel() {
29 | private val _uiState = MutableStateFlow(SearchUiState())
30 | val uiState: StateFlow = _uiState.asStateFlow()
31 |
32 | init {
33 | viewModelScope.launch {
34 | notesRepository.prepareJieba()
35 | }
36 | }
37 |
38 | fun changeSearchTerm(text: String) {
39 | _uiState.update {
40 | it.copy(searchTerm = text, notFound = false)
41 | }
42 | }
43 |
44 | fun hasShownKeyboardOnFirstLoad() {
45 | _uiState.update {
46 | it.copy(showKeyboardOnFirstLoad = false)
47 | }
48 | }
49 |
50 | fun search() {
51 | if (_uiState.value.isSearching) {
52 | return
53 | }
54 | val searchTerm = _uiState.value.searchTerm
55 | _uiState.update {
56 | it.copy(isSearching = true, notFound = false, searchingTerm = searchTerm)
57 | }
58 | viewModelScope.launch {
59 | notesRepository.search(searchTerm = searchTerm)
60 | .onSuccess { notes ->
61 | _uiState.update {
62 | it.copy(
63 | searchedNotes = notes,
64 | isSearching = false,
65 | notFound = notes.isEmpty()
66 | )
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/DarkThemePage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings
2 |
3 | import androidx.compose.animation.core.CubicBezierEasing
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.foundation.lazy.LazyColumn
7 | import androidx.compose.foundation.lazy.items
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.*
10 | import androidx.compose.material.icons.outlined.Contrast
11 | import androidx.compose.material3.*
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.rememberCoroutineScope
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.input.nestedscroll.nestedScroll
16 | import androidx.compose.ui.platform.LocalContext
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.unit.dp
19 | import org.dianqk.ruslin.R
20 | import org.dianqk.ruslin.data.LocalDarkTheme
21 | import org.dianqk.ruslin.data.LocalHighContrastDarkTheme
22 | import org.dianqk.ruslin.data.preference.DarkThemePreference
23 | import org.dianqk.ruslin.data.preference.not
24 | import org.dianqk.ruslin.ui.component.BackButton
25 | import org.dianqk.ruslin.ui.component.PreferenceSubtitle
26 | import org.dianqk.ruslin.ui.component.PreferenceSwitch
27 | import org.dianqk.ruslin.ui.component.SettingItem
28 |
29 | @OptIn(ExperimentalMaterial3Api::class)
30 | @Composable
31 | fun DarkThemePage(
32 | onPopBack: () -> Unit
33 | ) {
34 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
35 | val fraction =
36 | CubicBezierEasing(1f, 0f, 0.8f, 0.4f).transform(scrollBehavior.state.overlappedFraction)
37 | val context = LocalContext.current
38 | val darkTheme = LocalDarkTheme.current
39 | val scope = rememberCoroutineScope()
40 | val highContrastDarkTheme = LocalHighContrastDarkTheme.current
41 |
42 | Scaffold(
43 | modifier = Modifier
44 | .fillMaxSize()
45 | .nestedScroll(scrollBehavior.nestedScrollConnection),
46 | topBar = {
47 | TopAppBar(
48 | title = {
49 | Text(
50 | text = stringResource(id = R.string.dark_theme),
51 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction)
52 | )
53 | },
54 | navigationIcon = { BackButton(onClick = onPopBack) },
55 | scrollBehavior = scrollBehavior
56 | )
57 | }
58 | ) { innerPadding ->
59 | LazyColumn(modifier = Modifier.padding(innerPadding)) {
60 | item {
61 | Text(
62 | modifier = Modifier.padding(24.dp),
63 | text = stringResource(id = R.string.dark_theme),
64 | style = MaterialTheme.typography.headlineLarge
65 | )
66 | }
67 | items(DarkThemePreference.values, key = { it.value }) {
68 | SettingItem(
69 | title = it.toDesc(context),
70 | onClick = { it.put(context = context, scope = scope) }
71 | ) {
72 | RadioButton(selected = it == darkTheme, onClick = {
73 | it.put(context = context, scope = scope)
74 | })
75 | }
76 | }
77 | item {
78 | PreferenceSubtitle(text = stringResource(id = R.string.other))
79 | }
80 | item {
81 | PreferenceSwitch(
82 | title = stringResource(id = R.string.high_contrast),
83 | icon = Icons.Outlined.Contrast,
84 | isChecked = highContrastDarkTheme.value,
85 | onClick = {
86 | (!highContrastDarkTheme).put(context, scope)
87 | }
88 | )
89 | }
90 | }
91 | }
92 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/LanguagesPage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings
2 |
3 | import androidx.compose.animation.core.CubicBezierEasing
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.foundation.lazy.LazyColumn
7 | import androidx.compose.foundation.lazy.items
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.*
10 | import androidx.compose.material.icons.outlined.FormatTextdirectionRToL
11 | import androidx.compose.material3.*
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.rememberCoroutineScope
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.input.nestedscroll.nestedScroll
16 | import androidx.compose.ui.platform.LocalContext
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.unit.dp
19 | import org.dianqk.ruslin.R
20 | import org.dianqk.ruslin.data.LocalLanguages
21 | import org.dianqk.ruslin.data.preference.LanguagesPreference
22 | import org.dianqk.ruslin.ui.component.BackButton
23 | import org.dianqk.ruslin.ui.component.PreferenceSubtitle
24 | import org.dianqk.ruslin.ui.component.SettingItem
25 |
26 | @OptIn(ExperimentalMaterial3Api::class)
27 | @Composable
28 | fun LanguagesPage(
29 | navigateToTextDirection: () -> Unit,
30 | onPopBack: () -> Unit
31 | ) {
32 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
33 | val fraction =
34 | CubicBezierEasing(1f, 0f, 0.8f, 0.4f).transform(scrollBehavior.state.overlappedFraction)
35 | val context = LocalContext.current
36 | val languages = LocalLanguages.current
37 | val scope = rememberCoroutineScope()
38 |
39 | Scaffold(
40 | modifier = Modifier
41 | .fillMaxSize()
42 | .nestedScroll(scrollBehavior.nestedScrollConnection),
43 | topBar = {
44 | TopAppBar(
45 | title = {
46 | Text(
47 | text = stringResource(id = R.string.languages),
48 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction)
49 | )
50 | },
51 | navigationIcon = { BackButton(onClick = onPopBack) },
52 | scrollBehavior = scrollBehavior
53 | )
54 | }
55 | ) { innerPadding ->
56 | LazyColumn(modifier = Modifier.padding(innerPadding)) {
57 | item(key = languages.value + 10000) {
58 | Text(
59 | modifier = Modifier.padding(24.dp),
60 | text = stringResource(id = R.string.languages),
61 | style = MaterialTheme.typography.headlineLarge
62 | )
63 | }
64 | items(LanguagesPreference.values, key = { it.value }) {
65 | SettingItem(
66 | title = it.getDesc(context),
67 | onClick = { it.put(context = context, scope = scope) }
68 | ) {
69 | RadioButton(selected = it == languages, onClick = {
70 | it.put(context = context, scope = scope)
71 | })
72 | }
73 | }
74 | item {
75 | PreferenceSubtitle(text = stringResource(id = R.string.other))
76 | }
77 | item {
78 | SettingItem(
79 | title = stringResource(id = R.string.content_text_direction),
80 | description = stringResource(id = R.string.content_text_direction_desc),
81 | icon = Icons.Outlined.FormatTextdirectionRToL,
82 | onClick = navigateToTextDirection,
83 | )
84 | }
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/SettingsPage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings
2 |
3 | import androidx.compose.animation.core.CubicBezierEasing
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.lazy.LazyColumn
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.*
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.input.nestedscroll.nestedScroll
12 | import androidx.compose.ui.res.stringResource
13 | import androidx.compose.ui.unit.dp
14 | import org.dianqk.ruslin.R
15 | import org.dianqk.ruslin.ui.component.BackButton
16 | import org.dianqk.ruslin.ui.component.SettingItem
17 |
18 | @OptIn(ExperimentalMaterial3Api::class)
19 | @Composable
20 | fun SettingsPage(
21 | navigateToAccountDetail: () -> Unit,
22 | navigateToTools: () -> Unit,
23 | navigateToAbout: () -> Unit,
24 | navigateToLanguages: () -> Unit,
25 | navigateToAppearance: () -> Unit,
26 | onPopBack: () -> Unit
27 | ) {
28 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
29 | val fraction =
30 | CubicBezierEasing(1f, 0f, 0.8f, 0.4f).transform(scrollBehavior.state.overlappedFraction)
31 |
32 | Scaffold(
33 | modifier = Modifier
34 | .fillMaxSize()
35 | .nestedScroll(scrollBehavior.nestedScrollConnection),
36 | topBar = {
37 | TopAppBar(
38 | title = {
39 | Text(
40 | text = stringResource(id = R.string.settings),
41 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction)
42 | )
43 | },
44 | navigationIcon = { BackButton(onClick = onPopBack) },
45 | scrollBehavior = scrollBehavior
46 | )
47 | }
48 | ) {
49 | LazyColumn(modifier = Modifier.padding(it)) {
50 | item {
51 | Text(
52 | modifier = Modifier.padding(24.dp),
53 | text = stringResource(id = R.string.settings),
54 | style = MaterialTheme.typography.headlineLarge
55 | )
56 | }
57 | item {
58 | SettingItem(
59 | title = stringResource(id = R.string.account),
60 | description = stringResource(id = R.string.account_setting_desc),
61 | icon = Icons.Filled.AccountCircle,
62 | onClick = navigateToAccountDetail
63 | )
64 | }
65 | item {
66 | SettingItem(
67 | title = stringResource(id = R.string.color_and_style),
68 | description = stringResource(id = R.string.color_and_style_setting_desc),
69 | icon = Icons.Filled.Palette,
70 | onClick = navigateToAppearance
71 | )
72 | }
73 | item {
74 | SettingItem(
75 | title = stringResource(id = R.string.languages),
76 | description = stringResource(id = R.string.languages_setting_desc),
77 | icon = Icons.Filled.Language,
78 | onClick = navigateToLanguages,
79 | )
80 | }
81 | item {
82 | SettingItem(
83 | title = stringResource(id = R.string.tools),
84 | description = stringResource(id = R.string.tools_setting_des),
85 | icon = Icons.Filled.Build,
86 | onClick = navigateToTools
87 | )
88 | }
89 | item {
90 | SettingItem(
91 | title = stringResource(id = R.string.about),
92 | description = stringResource(id = R.string.about_setting_desc),
93 | icon = Icons.Filled.TipsAndUpdates,
94 | onClick = { navigateToAbout() }
95 | )
96 | }
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/TextDirectionPage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings
2 |
3 | import androidx.compose.animation.core.CubicBezierEasing
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.foundation.lazy.LazyColumn
7 | import androidx.compose.foundation.lazy.items
8 | import androidx.compose.material.icons.filled.*
9 | import androidx.compose.material3.*
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.rememberCoroutineScope
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.input.nestedscroll.nestedScroll
14 | import androidx.compose.ui.platform.LocalContext
15 | import androidx.compose.ui.res.stringResource
16 | import androidx.compose.ui.unit.dp
17 | import org.dianqk.ruslin.R
18 | import org.dianqk.ruslin.data.LocalContentTextDirection
19 | import org.dianqk.ruslin.data.preference.TextDirectionPreference
20 | import org.dianqk.ruslin.ui.component.BackButton
21 | import org.dianqk.ruslin.ui.component.SettingItem
22 |
23 | @OptIn(ExperimentalMaterial3Api::class)
24 | @Composable
25 | fun TextDirectionPage(
26 | onPopBack: () -> Unit
27 | ) {
28 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
29 | val fraction =
30 | CubicBezierEasing(1f, 0f, 0.8f, 0.4f).transform(scrollBehavior.state.overlappedFraction)
31 | val context = LocalContext.current
32 | val scope = rememberCoroutineScope()
33 | val textDirection = LocalContentTextDirection.current
34 |
35 | Scaffold(
36 | modifier = Modifier
37 | .fillMaxSize()
38 | .nestedScroll(scrollBehavior.nestedScrollConnection),
39 | topBar = {
40 | TopAppBar(
41 | title = {
42 | Text(
43 | text = stringResource(id = R.string.content_text_direction),
44 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction)
45 | )
46 | },
47 | navigationIcon = { BackButton(onClick = onPopBack) },
48 | scrollBehavior = scrollBehavior
49 | )
50 | }
51 | ) { innerPadding ->
52 | LazyColumn(modifier = Modifier.padding(innerPadding)) {
53 | item {
54 | Text(
55 | modifier = Modifier.padding(24.dp),
56 | text = stringResource(id = R.string.content_text_direction),
57 | style = MaterialTheme.typography.headlineLarge
58 | )
59 | }
60 | items(TextDirectionPreference.values, key = { it.value }) {
61 | SettingItem(
62 | title = it.toDesc(context),
63 | onClick = { it.put(context = context, scope = scope) }
64 | ) {
65 | RadioButton(selected = it == textDirection, onClick = {
66 | it.put(context = context, scope = scope)
67 | })
68 | }
69 | }
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/accounts/AccountDetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings.accounts
2 |
3 | import android.content.Context
4 | import androidx.datastore.preferences.core.edit
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.viewModelScope
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import dagger.hilt.android.qualifiers.ApplicationContext
9 | import kotlinx.coroutines.Dispatchers
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import kotlinx.coroutines.flow.SharingStarted
12 | import kotlinx.coroutines.flow.StateFlow
13 | import kotlinx.coroutines.flow.asStateFlow
14 | import kotlinx.coroutines.flow.stateIn
15 | import kotlinx.coroutines.flow.update
16 | import kotlinx.coroutines.launch
17 | import kotlinx.coroutines.withContext
18 | import org.dianqk.ruslin.data.DataStoreKeys
19 | import org.dianqk.ruslin.data.NotesRepository
20 | import org.dianqk.ruslin.data.SyncStrategy
21 | import org.dianqk.ruslin.data.dataStore
22 | import org.dianqk.ruslin.data.syncStrategy
23 | import uniffi.ruslin.SyncConfig
24 | import javax.inject.Inject
25 |
26 | data class AccountDetailUiState(
27 | val email: String? = null,
28 | val url: String? = null
29 | )
30 |
31 | @HiltViewModel
32 | class AccountDetailViewModel @Inject constructor(
33 | private val notesRepository: NotesRepository,
34 | @ApplicationContext context: Context
35 | ) : ViewModel() {
36 |
37 | private val _uiState = MutableStateFlow(AccountDetailUiState())
38 | val uiState: StateFlow = _uiState.asStateFlow()
39 | private val dataStore = context.dataStore
40 | val syncStrategy: StateFlow = dataStore.syncStrategy()
41 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), SyncStrategy())
42 |
43 | init {
44 | loadSyncConfig()
45 | }
46 |
47 | fun setSyncInterval(syncInterval: Long) {
48 | viewModelScope.launch {
49 | withContext(Dispatchers.IO) {
50 | dataStore.edit {
51 | it[DataStoreKeys.SyncInterval.key] = syncInterval
52 | }
53 | }
54 | }
55 | }
56 |
57 | fun setSyncOnStart(syncOnStart: Boolean) {
58 | viewModelScope.launch {
59 | withContext(Dispatchers.IO) {
60 | dataStore.edit {
61 | it[DataStoreKeys.SyncOnStart.key] = syncOnStart
62 | }
63 | }
64 | }
65 | }
66 |
67 | fun setSyncOnlyWiFi(syncOnlyWiFi: Boolean) {
68 | viewModelScope.launch {
69 | withContext(Dispatchers.IO) {
70 | dataStore.edit {
71 | it[DataStoreKeys.SyncOnlyWiFi.key] = syncOnlyWiFi
72 | }
73 | }
74 | }
75 | }
76 |
77 | fun setSyncOnlyWhenCharging(syncOnlyWhenCharging: Boolean) {
78 | viewModelScope.launch {
79 | withContext(Dispatchers.IO) {
80 | dataStore.edit {
81 | it[DataStoreKeys.SyncOnlyWhenCharging.key] = syncOnlyWhenCharging
82 | }
83 | }
84 | }
85 | }
86 |
87 | private fun loadSyncConfig() {
88 | viewModelScope.launch {
89 | notesRepository.getSyncConfig()
90 | .onSuccess { syncConfig ->
91 | syncConfig?.let {
92 | if (syncConfig is SyncConfig.JoplinServer) {
93 | _uiState.update {
94 | it.copy(
95 | email = syncConfig.email,
96 | url = syncConfig.host
97 | )
98 | }
99 | }
100 | }
101 | }
102 | .onFailure { e ->
103 | _uiState.update {
104 | it.copy(
105 | email = e.toString(),
106 | url = e.toString(),
107 | )
108 | }
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/tools/ToolsPage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings.tools
2 |
3 | import androidx.compose.animation.core.CubicBezierEasing
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.lazy.LazyColumn
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.*
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.vector.ImageVector
12 | import androidx.compose.ui.input.nestedscroll.nestedScroll
13 | import androidx.compose.ui.res.stringResource
14 | import androidx.compose.ui.res.vectorResource
15 | import androidx.compose.ui.unit.dp
16 | import org.dianqk.ruslin.R
17 | import org.dianqk.ruslin.ui.component.BackButton
18 | import org.dianqk.ruslin.ui.component.SettingItem
19 |
20 | @OptIn(ExperimentalMaterial3Api::class)
21 | @Composable
22 | fun ToolsPage(
23 | navigateToLogDetail: () -> Unit,
24 | navigateToDatabaseStatus: () -> Unit,
25 | onPopBack: () -> Unit
26 | ) {
27 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
28 | val fraction =
29 | CubicBezierEasing(1f, 0f, 0.8f, 0.4f).transform(scrollBehavior.state.overlappedFraction)
30 |
31 | Scaffold(
32 | modifier = Modifier
33 | .fillMaxSize()
34 | .nestedScroll(scrollBehavior.nestedScrollConnection),
35 | topBar = {
36 | TopAppBar(
37 | title = {
38 | Text(
39 | text = stringResource(id = R.string.tools),
40 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction)
41 | )
42 | },
43 | navigationIcon = { BackButton(onClick = onPopBack) },
44 | scrollBehavior = scrollBehavior
45 | )
46 | }
47 | ) {
48 | LazyColumn(modifier = Modifier.padding(it)) {
49 | item {
50 | Text(
51 | modifier = Modifier.padding(24.dp),
52 | text = stringResource(id = R.string.tools),
53 | style = MaterialTheme.typography.headlineLarge
54 | )
55 | }
56 | item {
57 | SettingItem(
58 | title = stringResource(id = R.string.log),
59 | description = stringResource(id = R.string.view_logs),
60 | icon = Icons.Filled.Description,
61 | onClick = navigateToLogDetail
62 | )
63 | }
64 | item {
65 | SettingItem(
66 | title = stringResource(id = R.string.database_status),
67 | description = stringResource(id = R.string.database_status_desc),
68 | icon = ImageVector.vectorResource(id = R.drawable.ic_database),
69 | onClick = navigateToDatabaseStatus
70 | )
71 | }
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/tools/database/DatabaseStatusPage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings.tools.database
2 |
3 | import androidx.compose.animation.core.*
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.text.selection.SelectionContainer
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.outlined.Sync
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.rotate
13 | import androidx.compose.ui.res.stringResource
14 | import androidx.compose.ui.unit.dp
15 | import androidx.hilt.navigation.compose.hiltViewModel
16 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
17 | import org.dianqk.ruslin.R
18 | import org.dianqk.ruslin.ui.component.BackButton
19 |
20 | @OptIn(ExperimentalMaterial3Api::class)
21 | @Composable
22 | fun DatabaseStatusPage(
23 | viewModel: DatabaseStatusViewModel = hiltViewModel(),
24 | onPopBack: () -> Unit
25 | ) {
26 | val uiState by viewModel.uiState.collectAsStateWithLifecycle()
27 | val syncAngle by rememberInfiniteTransition().animateFloat(
28 | initialValue = 360f,
29 | targetValue = 0f,
30 | animationSpec = infiniteRepeatable(
31 | animation = tween(1000, easing = LinearEasing)
32 | )
33 | )
34 |
35 | Scaffold(
36 | modifier = Modifier
37 | .fillMaxSize(),
38 | topBar = {
39 | TopAppBar(
40 | title = {
41 | Text(
42 | text = stringResource(id = R.string.database_status),
43 | color = MaterialTheme.colorScheme.onSurface
44 | )
45 | },
46 | navigationIcon = { BackButton(onClick = onPopBack) }
47 | )
48 | }
49 | ) {
50 | Box(modifier = Modifier.padding(it)) {
51 | Column(modifier = Modifier.padding(horizontal = 20.dp)) {
52 | SelectionContainer {
53 | Text(
54 | text = uiState.status
55 | )
56 | }
57 | Spacer(modifier = Modifier.height(20.dp))
58 | uiState.syncResult?.let { result ->
59 | result
60 | .onSuccess { message ->
61 | Text(text = message, color = MaterialTheme.colorScheme.primary)
62 | }
63 | .onFailure { e ->
64 | Text(text = e.toString(), color = MaterialTheme.colorScheme.error)
65 | }
66 | }
67 | Spacer(modifier = Modifier.height(5.dp))
68 | ElevatedButton(
69 | enabled = !uiState.isSyncing,
70 | onClick = viewModel::resync
71 | ) {
72 | Icon(
73 | imageVector = Icons.Outlined.Sync,
74 | modifier = Modifier.rotate(if (uiState.isSyncing) syncAngle else 360f),
75 | contentDescription = null
76 | )
77 | Text(text = stringResource(id = R.string.resynchronize_from_scratch))
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/tools/database/DatabaseStatusViewModel.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings.tools.database
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import kotlinx.coroutines.flow.asStateFlow
9 | import kotlinx.coroutines.flow.update
10 | import kotlinx.coroutines.launch
11 | import org.dianqk.ruslin.data.NotesRepository
12 | import javax.inject.Inject
13 |
14 | data class DatabaseUiState(
15 | val status: String = "",
16 | val isSyncing: Boolean = false,
17 | val syncResult: Result? = null,
18 | )
19 |
20 | @HiltViewModel
21 | class DatabaseStatusViewModel @Inject constructor(
22 | private val notesRepository: NotesRepository
23 | ) : ViewModel() {
24 | private val _uiState = MutableStateFlow(DatabaseUiState())
25 | val uiState: StateFlow = _uiState.asStateFlow()
26 |
27 | init {
28 | updateStatus()
29 | viewModelScope.launch {
30 | notesRepository.syncFinished.collect { syncResult ->
31 | syncResult.onSuccess { syncInfo ->
32 | _uiState.update {
33 | it.copy(
34 | syncResult = Result.success("$syncInfo")
35 | )
36 | }
37 | }.onFailure { e ->
38 | _uiState.update {
39 | it.copy(
40 | syncResult = Result.failure(e)
41 | )
42 | }
43 | }
44 | }
45 | }
46 | viewModelScope.launch {
47 | notesRepository.isSyncing.collect { isSyncing ->
48 | _uiState.update {
49 | it.copy(isSyncing = isSyncing)
50 | }
51 | }
52 | }
53 | }
54 |
55 | fun resync() {
56 | _uiState.update {
57 | it.copy(isSyncing = true, syncResult = null)
58 | }
59 | notesRepository.doSync(isOnStart = false, fromScratch = true)
60 | }
61 |
62 | fun updateStatus() {
63 | viewModelScope.launch {
64 | notesRepository.readDatabaseStatus()
65 | .onSuccess { status ->
66 | val statusText = "Note: ${status.noteCount}\n" +
67 | "Folder: ${status.folderCount}\n" +
68 | "Resource: ${status.resourceCount}\n" +
69 | "Tag: ${status.tagCount}\n" +
70 | "NoteTag: ${status.noteTagCount}"
71 | _uiState.update {
72 | it.copy(status = statusText)
73 | }
74 | }
75 | }
76 |
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/tools/log/LogPage.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings.tools.log
2 |
3 | import androidx.compose.foundation.layout.fillMaxSize
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.foundation.rememberScrollState
6 | import androidx.compose.foundation.text.selection.SelectionContainer
7 | import androidx.compose.foundation.verticalScroll
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.res.stringResource
13 | import androidx.compose.ui.unit.dp
14 | import androidx.hilt.navigation.compose.hiltViewModel
15 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
16 | import org.dianqk.ruslin.R
17 | import org.dianqk.ruslin.ui.component.BackButton
18 |
19 | @OptIn(ExperimentalMaterial3Api::class)
20 | @Composable
21 | fun LogPage(
22 | viewModel: LogViewModel = hiltViewModel(),
23 | onPopBack: () -> Unit
24 | ) {
25 | val uiState by viewModel.uiState.collectAsStateWithLifecycle()
26 | val scroll = rememberScrollState()
27 | Scaffold(
28 | modifier = Modifier
29 | .fillMaxSize(),
30 | topBar = {
31 | TopAppBar(
32 | title = {
33 | Text(
34 | text = stringResource(id = R.string.log),
35 | color = MaterialTheme.colorScheme.onSurface
36 | )
37 | },
38 | navigationIcon = { BackButton(onClick = onPopBack) }
39 | )
40 | }
41 | ) {
42 | SelectionContainer(modifier = Modifier.padding(it)) {
43 | Text(
44 | modifier = Modifier
45 | .verticalScroll(scroll)
46 | .padding(horizontal = 5.dp),
47 | text = uiState.log
48 | )
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/page/settings/tools/log/LogViewModel.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.page.settings.tools.log
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import kotlinx.coroutines.flow.asStateFlow
9 | import kotlinx.coroutines.flow.update
10 | import kotlinx.coroutines.launch
11 | import org.dianqk.ruslin.data.NotesRepository
12 | import javax.inject.Inject
13 |
14 | data class LogUiState(
15 | val log: String = ""
16 | )
17 |
18 | @HiltViewModel
19 | class LogViewModel @Inject constructor(
20 | private val notesRepository: NotesRepository
21 | ) : ViewModel() {
22 |
23 | private val _uiState = MutableStateFlow(LogUiState())
24 | val uiState: StateFlow = _uiState.asStateFlow()
25 |
26 | init {
27 | viewModelScope.launch {
28 | val log = notesRepository.readLog()
29 | _uiState.update {
30 | it.copy(
31 | log = log
32 | )
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
12 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/Shapes.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material3.Shapes
5 | import androidx.compose.runtime.Stable
6 | import androidx.compose.ui.unit.dp
7 |
8 | val Shapes = Shapes(
9 | extraSmall = RoundedCornerShape(4.0.dp),
10 | small = RoundedCornerShape(8.0.dp),
11 | medium = RoundedCornerShape(12.0.dp),
12 | large = RoundedCornerShape(16.0.dp),
13 | extraLarge = RoundedCornerShape(28.0.dp)
14 | )
15 |
16 | @Stable
17 | val Shape20 = RoundedCornerShape(20.0.dp)
18 |
19 | @Stable
20 | val Shape24 = RoundedCornerShape(24.0.dp)
21 |
22 | @Stable
23 | val Shape32 = RoundedCornerShape(32.0.dp)
24 |
25 | @Stable
26 | val ShapeTop32 = RoundedCornerShape(32.0.dp, 32.0.dp, 0.0.dp, 0.0.dp)
27 |
28 | @Stable
29 | val ShapeBottom32 = RoundedCornerShape(0.0.dp, 0.0.dp, 32.0.dp, 32.0.dp)
30 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.theme
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.CompositionLocalProvider
6 | import androidx.compose.runtime.LaunchedEffect
7 | import androidx.compose.ui.graphics.Color
8 | import androidx.compose.ui.platform.LocalContext
9 | import com.google.accompanist.systemuicontroller.rememberSystemUiController
10 | import org.dianqk.ruslin.R
11 | import org.dianqk.ruslin.data.LocalDarkTheme
12 | import org.dianqk.ruslin.data.LocalThemeIndex
13 | import org.dianqk.ruslin.data.preference.DarkThemePreference
14 | import org.dianqk.ruslin.ui.theme.palette.LocalTonalPalettes
15 | import org.dianqk.ruslin.ui.theme.palette.TonalPalettes
16 | import org.dianqk.ruslin.ui.theme.palette.core.ProvideZcamViewingConditions
17 | import org.dianqk.ruslin.ui.theme.palette.dynamic.extractTonalPalettesFromUserWallpaper
18 | import org.dianqk.ruslin.ui.theme.palette.dynamicDarkColorScheme
19 | import org.dianqk.ruslin.ui.theme.palette.dynamicLightColorScheme
20 |
21 |
22 | fun Color.applyOpacity(enabled: Boolean): Color {
23 | return if (enabled) this else this.copy(alpha = 0.62f)
24 | }
25 |
26 | @Composable
27 | fun RuslinTheme(
28 | wallpaperPalettes: List = extractTonalPalettesFromUserWallpaper(),
29 | content: @Composable () -> Unit
30 | ) {
31 | val context = LocalContext.current
32 | val darkTheme = LocalDarkTheme.current
33 | val themeIndex = LocalThemeIndex.current
34 | val useDarkTheme = darkTheme.isDarkTheme()
35 |
36 | val tonalPalettes = wallpaperPalettes[
37 | if (themeIndex >= wallpaperPalettes.size) {
38 | 0
39 | } else {
40 | themeIndex
41 | }
42 | ]
43 |
44 | rememberSystemUiController().run {
45 | setStatusBarColor(Color.Transparent, !useDarkTheme)
46 | setSystemBarsColor(Color.Transparent, !useDarkTheme)
47 | setNavigationBarColor(Color.Transparent, !useDarkTheme)
48 | }
49 |
50 | LaunchedEffect(darkTheme) {
51 | context.setTheme(
52 | when (darkTheme) {
53 | DarkThemePreference.UseDeviceTheme -> R.style.Theme_Ruslin
54 | DarkThemePreference.ON -> R.style.Theme_Ruslin_Dark
55 | DarkThemePreference.OFF -> R.style.Theme_Ruslin_Light
56 | }
57 | )
58 | }
59 |
60 | ProvideZcamViewingConditions {
61 | CompositionLocalProvider(
62 | LocalTonalPalettes provides tonalPalettes.apply { Preparing() },
63 | ) {
64 | MaterialTheme(
65 | colorScheme = if (darkTheme.isDarkTheme()) dynamicDarkColorScheme() else dynamicLightColorScheme(),
66 | shapes = Shapes,
67 | typography = Typography,
68 | content = content,
69 | )
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
35 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/MaterialYouStandard.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette
9 |
10 | object MaterialYouStandard {
11 |
12 | // TODO: support RGB color spaces
13 | // calculated with error = 0.001, chroma = 100, hue in 0 until 360, hue step = 1
14 | val sRGBLightnessChromaMap = mapOf(
15 | 0 to 7.62939453125E-4,
16 | 1 to 3.5961087544759116,
17 | 2 to 4.780750274658203,
18 | 3 to 5.583519405788845,
19 | 4 to 6.202197604709202,
20 | 5 to 6.709673139784071,
21 | 6 to 7.141865624321832,
22 | 7 to 7.5197558932834205,
23 | 8 to 7.855508592393663,
24 | 9 to 8.170108795166016,
25 | 10 to 8.478755950927734,
26 | 11 to 8.78151363796658,
27 | 12 to 9.078428480360243,
28 | 13 to 9.368563758002388,
29 | 14 to 9.65309566921658,
30 | 15 to 9.932153489854601,
31 | 16 to 10.205938551161024,
32 | 17 to 10.474622514512804,
33 | 18 to 10.737465752495659,
34 | 19 to 10.994769202338325,
35 | 20 to 11.247151692708334,
36 | 21 to 11.494865417480469,
37 | 22 to 11.737997266981337,
38 | 23 to 11.97671890258789,
39 | 24 to 12.211305830213758,
40 | 25 to 12.440338134765625,
41 | 26 to 12.664968702528212,
42 | 27 to 12.885534498426649,
43 | 28 to 13.102118174235025,
44 | 29 to 13.314840528700087,
45 | 30 to 13.523866865370008,
46 | 31 to 13.729370964898003,
47 | 32 to 13.93154568142361,
48 | 33 to 14.129519992404514,
49 | 34 to 14.323438008626303,
50 | 35 to 14.513916439480251,
51 | 36 to 14.701114230685764,
52 | 37 to 14.885203043619791,
53 | 38 to 15.066161685519749,
54 | 39 to 15.244210561116537,
55 | 40 to 15.419385698106554,
56 | 41 to 15.591801537407768,
57 | 42 to 15.761661529541016,
58 | 43 to 15.928798251681858,
59 | 44 to 16.091954973008896,
60 | 45 to 16.25248803032769,
61 | 46 to 16.410503387451172,
62 | 47 to 16.566007402208115,
63 | 48 to 16.71794679429796,
64 | 49 to 16.8612183464898,
65 | 50 to 16.995349460177952,
66 | 51 to 17.11943096584744,
67 | 52 to 17.233810424804688,
68 | 53 to 17.34021716647678,
69 | 54 to 17.43742412990994,
70 | 55 to 17.525511847601997,
71 | 56 to 17.586015065511067,
72 | 57 to 17.615225050184463,
73 | 58 to 17.616180843777126,
74 | 59 to 17.59176466200087,
75 | 60 to 17.543212042914497,
76 | 61 to 17.472801208496094,
77 | 62 to 17.381820678710938,
78 | 63 to 17.271283467610676,
79 | 64 to 17.14224073621962,
80 | 65 to 16.99536853366428,
81 | 66 to 16.831576029459637,
82 | 67 to 16.651276482476128,
83 | 68 to 16.45496368408203,
84 | 69 to 16.24362309773763,
85 | 70 to 16.024080912272137,
86 | 71 to 15.799829694959852,
87 | 72 to 15.570498572455513,
88 | 73 to 15.336426628960503,
89 | 74 to 15.097156100802952,
90 | 75 to 14.85240724351671,
91 | 76 to 14.601781633165148,
92 | 77 to 14.345453050401476,
93 | 78 to 14.08291286892361,
94 | 79 to 13.814093271891275,
95 | 80 to 13.538227081298828,
96 | 81 to 13.255418141682943,
97 | 82 to 12.964892917209202,
98 | 83 to 12.657786475287544,
99 | 84 to 12.30661392211914,
100 | 85 to 11.908126407199436,
101 | 86 to 11.460111406114367,
102 | 87 to 10.960358513726128,
103 | 88 to 10.406063927544487,
104 | 89 to 9.794313642713758,
105 | 90 to 9.12274890475803,
106 | 91 to 8.394618564181858,
107 | 92 to 7.635277642144097,
108 | 93 to 6.844251420762804,
109 | 94 to 6.025793287489149,
110 | 95 to 5.196952819824219,
111 | 96 to 4.350443945990668,
112 | 97 to 3.472722371419271,
113 | 98 to 2.5394566853841147,
114 | 99 to 1.4974000718858507,
115 | 100 to 7.62939453125E-4,
116 | )
117 | }
118 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/cielab/CieLab.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.cielab
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
11 | import kotlin.math.pow
12 |
13 | // TODO: test
14 | data class CieLab(
15 | val L: Double,
16 | val a: Double,
17 | val b: Double,
18 | ) {
19 |
20 | fun toXyz(
21 | whitePoint: CieXyz,
22 | luminance: Double,
23 | ): CieXyz {
24 | val lp = (L + 16.0) / 116.0
25 | val absoluteWhitePoint = whitePoint * luminance
26 | return CieXyz(
27 | x = absoluteWhitePoint.x * fInv(lp + (a / 500.0)),
28 | y = absoluteWhitePoint.y * fInv(lp),
29 | z = absoluteWhitePoint.z * fInv(lp - (b / 200.0)),
30 | )
31 | }
32 |
33 | companion object {
34 |
35 | private fun f(x: Double) = when {
36 | x > 216.0 / 24389.0 -> x.pow(1.0 / 3.0)
37 | else -> x / (108.0 / 841.0) + 4.0 / 29.0
38 | }
39 |
40 | private fun fInv(x: Double): Double = when {
41 | x > 6.0 / 29.0 -> x.pow(3.0)
42 | else -> 108.0 / 841.0 * (x - 4.0 / 29.0)
43 | }
44 |
45 | fun CieXyz.toCieLab(
46 | whitePoint: CieXyz,
47 | luminance: Double,
48 | ): CieLab {
49 | val relativeWhitePoint = whitePoint / luminance
50 | return CieLab(
51 | L = 116.0 * f(y / relativeWhitePoint.y) - 16.0,
52 | a = 500.0 * (f(x / relativeWhitePoint.x) - f(y / relativeWhitePoint.y)),
53 | b = 200.0 * (f(y / relativeWhitePoint.y) - f(z / relativeWhitePoint.z)),
54 | )
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/cielab/CieLch.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.cielab
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.util.square
11 | import org.dianqk.ruslin.ui.theme.palette.util.toDegrees
12 | import org.dianqk.ruslin.ui.theme.palette.util.toRadians
13 | import kotlin.math.atan2
14 | import kotlin.math.cos
15 | import kotlin.math.sin
16 | import kotlin.math.sqrt
17 |
18 | data class CieLch(
19 | val L: Double,
20 | val C: Double,
21 | val h: Double,
22 | ) {
23 |
24 | fun toCieLab(): CieLab {
25 | val hRad = h.toRadians()
26 | return CieLab(
27 | L = L,
28 | a = C * cos(hRad),
29 | b = C * sin(hRad),
30 | )
31 | }
32 |
33 | companion object {
34 |
35 | fun CieLab.toCieLch(): CieLch = CieLch(
36 | L = L,
37 | C = sqrt(square(a) + square(b)),
38 | h = atan2(b, a).toDegrees().mod(360.0),
39 | )
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/ciexyz/CieXyz.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.util.div
11 | import org.dianqk.ruslin.ui.theme.palette.util.times
12 |
13 | data class CieXyz(
14 | val x: Double,
15 | val y: Double,
16 | val z: Double,
17 | ) {
18 |
19 | inline val xyz: DoubleArray
20 | get() = doubleArrayOf(x, y, z)
21 |
22 | inline val luminance: Double
23 | get() = y
24 |
25 | operator fun times(luminance: Double): CieXyz = (xyz * luminance).asXyz()
26 |
27 | operator fun div(luminance: Double): CieXyz = (xyz / luminance).asXyz()
28 |
29 | companion object {
30 |
31 | internal fun DoubleArray.asXyz(): CieXyz = CieXyz(this[0], this[1], this[2])
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/jzazbz/Jzazbz.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.jzazbz
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
11 | import org.dianqk.ruslin.ui.theme.palette.util.Matrix3
12 | import kotlin.math.pow
13 |
14 | data class Jzazbz(
15 | val Jz: Double,
16 | val az: Double,
17 | val bz: Double,
18 | ) {
19 |
20 | fun toXyz(): CieXyz {
21 | val (x_, y_, z) = lmsToXyz * (
22 | IzazbzToLms * doubleArrayOf(
23 | (Jz + d_0) / (1.0 + d - d * (Jz + d_0)),
24 | az,
25 | bz,
26 | )
27 | ).map {
28 | 10000.0 * ((c_1 - it.pow(1.0 / p)) / (c_3 * it.pow(1.0 / p) - c_2)).pow(1.0 / n)
29 | }.toDoubleArray()
30 | val x = (x_ + (b - 1.0) * z) / b
31 | val y = (y_ + (g - 1.0) * x) / g
32 | return CieXyz(
33 | x = x,
34 | y = y,
35 | z = z,
36 | )
37 | }
38 |
39 | companion object {
40 |
41 | private const val b = 1.15
42 | private const val g = 0.66
43 | private const val c_1 = 3424.0 / 4096.0
44 | private const val c_2 = 2413.0 / 128.0
45 | private const val c_3 = 2392.0 / 128.0
46 | private const val n = 2610.0 / 16384.0
47 | private const val p = 1.7 * 2523.0 / 32.0
48 | private const val d = -0.56
49 | private const val d_0 = 1.6295499532821566E-11
50 |
51 | private val xyzToLms: Matrix3 = Matrix3(
52 | doubleArrayOf(0.41478972, 0.579999, 0.01464800),
53 | doubleArrayOf(-0.2015100, 1.120649, 0.05310080),
54 | doubleArrayOf(-0.0166008, 0.264800, 0.66847990),
55 | )
56 | private val lmsToXyz: Matrix3 = xyzToLms.inverse()
57 | private val lmsToIzazbz: Matrix3 = Matrix3(
58 | doubleArrayOf(0.5, 0.5, 0.0),
59 | doubleArrayOf(3.524000, -4.066708, 0.542708),
60 | doubleArrayOf(0.199076, 1.096799, -1.295875),
61 | )
62 | private val IzazbzToLms: Matrix3 = lmsToIzazbz.inverse()
63 |
64 | fun CieXyz.toJzazbz(): Jzazbz {
65 | val (Iz, az, bz) = lmsToIzazbz * (
66 | xyzToLms * doubleArrayOf(
67 | b * x - (b - 1.0) * z,
68 | g * y - (g - 1.0) * x,
69 | z,
70 | )
71 | ).map {
72 | ((c_1 + c_2 * (it / 10000.0).pow(n)) / (1.0 + c_3 * (it / 10000.0).pow(n))).pow(
73 | p
74 | )
75 | }.toDoubleArray()
76 | return Jzazbz(
77 | Jz = (1.0 + d) * Iz / (1.0 + d * Iz) - d_0,
78 | az = az,
79 | bz = bz,
80 | )
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/jzazbz/Jzczhz.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.jzazbz
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.util.square
11 | import org.dianqk.ruslin.ui.theme.palette.util.toDegrees
12 | import org.dianqk.ruslin.ui.theme.palette.util.toRadians
13 | import kotlin.math.atan2
14 | import kotlin.math.cos
15 | import kotlin.math.sin
16 | import kotlin.math.sqrt
17 |
18 | data class Jzczhz(
19 | val Jz: Double,
20 | val Cz: Double,
21 | val hz: Double,
22 | ) {
23 |
24 | fun toJzazbz(): Jzazbz {
25 | val hRad = hz.toRadians()
26 | return Jzazbz(
27 | Jz = Jz,
28 | az = Cz * cos(hRad),
29 | bz = Cz * sin(hRad),
30 | )
31 | }
32 |
33 | fun dE(other: Jzczhz): Double =
34 | sqrt(square(Jz - other.Jz) + square(Cz - other.Cz) + 4.0 * Cz * other.Cz * square(sin((hz - other.hz) / 2.0)))
35 |
36 | companion object {
37 |
38 | fun Jzazbz.toJzczhz(): Jzczhz = Jzczhz(
39 | Jz = Jz,
40 | Cz = sqrt(square(az) + square(bz)),
41 | hz = atan2(bz, az).toDegrees().mod(360.0),
42 | )
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/oklab/Oklab.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.oklab
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
11 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz.Companion.asXyz
12 | import org.dianqk.ruslin.ui.theme.palette.util.Matrix3
13 | import kotlin.math.pow
14 |
15 | data class Oklab(
16 | val L: Double,
17 | val a: Double,
18 | val b: Double,
19 | ) {
20 |
21 | fun toXyz(): CieXyz = (lmsToXyz * (oklabToLms * doubleArrayOf(L, a, b)).map { it.pow(3.0) }
22 | .toDoubleArray()).asXyz()
23 |
24 | companion object {
25 |
26 | private val xyzToLms: Matrix3 = Matrix3(
27 | doubleArrayOf(0.8189330101, 0.3618667424, -0.1288597137),
28 | doubleArrayOf(0.0329845436, 0.9293118715, 0.0361456387),
29 | doubleArrayOf(0.0482003018, 0.2643662691, 0.6338517070),
30 | )
31 | private val lmsToXyz: Matrix3 = xyzToLms.inverse()
32 | private val lmsToOklab: Matrix3 = Matrix3(
33 | doubleArrayOf(0.2104542553, 0.7936177850, -0.0040720468),
34 | doubleArrayOf(1.9779984951, -2.4285922050, 0.4505937099),
35 | doubleArrayOf(0.0259040371, 0.7827717662, -0.8086757660),
36 | )
37 | private val oklabToLms: Matrix3 = lmsToOklab.inverse()
38 |
39 | fun CieXyz.toOklab(): Oklab =
40 | (lmsToOklab * (xyzToLms * xyz).map { it.pow(1.0 / 3.0) }.toDoubleArray()).asOklab()
41 |
42 | internal fun DoubleArray.asOklab(): Oklab = Oklab(this[0], this[1], this[2])
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/oklab/Oklch.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.oklab
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.Rgb
11 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.Rgb.Companion.toRgb
12 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.RgbColorSpace
13 | import org.dianqk.ruslin.ui.theme.palette.util.square
14 | import org.dianqk.ruslin.ui.theme.palette.util.toDegrees
15 | import org.dianqk.ruslin.ui.theme.palette.util.toRadians
16 | import kotlin.math.atan2
17 | import kotlin.math.cos
18 | import kotlin.math.sin
19 | import kotlin.math.sqrt
20 |
21 | data class Oklch(
22 | val L: Double,
23 | val C: Double,
24 | val h: Double,
25 | ) {
26 |
27 | fun toOklab(): Oklab {
28 | val hRad = h.toRadians()
29 | return Oklab(
30 | L = L,
31 | a = C * cos(hRad),
32 | b = C * sin(hRad),
33 | )
34 | }
35 |
36 | fun clampToRgb(colorSpace: RgbColorSpace): Rgb =
37 | toOklab().toXyz().toRgb(1.0, colorSpace).takeIf { it.isInGamut() } ?: copy(
38 | C = findChromaBoundaryInRgb(
39 | colorSpace,
40 | 0.001
41 | )
42 | ).toOklab().toXyz().toRgb(1.0, colorSpace).clamp()
43 |
44 | private fun findChromaBoundaryInRgb(
45 | colorSpace: RgbColorSpace,
46 | error: Double,
47 | ): Double = chromaBoundary.getOrPut(Triple(colorSpace.hashCode(), h, L)) {
48 | var low = 0.0
49 | var high = C
50 | var current = this
51 | while (high - low >= error) {
52 | val mid = (low + high) / 2.0
53 | current = copy(C = mid)
54 | if (!current.toOklab().toXyz().toRgb(1.0, colorSpace).isInGamut()) {
55 | high = mid
56 | } else {
57 | val next = current.copy(C = mid + error).toOklab().toXyz().toRgb(1.0, colorSpace)
58 | if (next.isInGamut()) {
59 | low = mid
60 | } else {
61 | break
62 | }
63 | }
64 | }
65 | current.C
66 | }
67 |
68 | companion object {
69 |
70 | fun Oklab.toOklch(): Oklch = Oklch(
71 | L = L,
72 | C = sqrt(square(a) + square(b)),
73 | h = atan2(b, a).toDegrees().mod(360.0),
74 | )
75 |
76 | private val chromaBoundary: MutableMap, Double> = mutableMapOf()
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/rgb/Rgb.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.rgb
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
11 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz.Companion.asXyz
12 | import org.dianqk.ruslin.ui.theme.palette.util.div
13 |
14 | data class Rgb(
15 | val r: Double,
16 | val g: Double,
17 | val b: Double,
18 | val colorSpace: RgbColorSpace,
19 | ) {
20 |
21 | inline val rgb: DoubleArray
22 | get() = doubleArrayOf(r, g, b)
23 |
24 | fun isInGamut(): Boolean = rgb.map { it in colorSpace.componentRange }.all { it }
25 |
26 | fun clamp(): Rgb =
27 | rgb.map { it.coerceIn(colorSpace.componentRange) }.toDoubleArray().asRgb(colorSpace)
28 |
29 | fun toXyz(luminance: Double): CieXyz = (
30 | colorSpace.rgbToXyzMatrix * rgb.map {
31 | colorSpace.transferFunction.EOTF(it)
32 | }.toDoubleArray()
33 | ).asXyz() * luminance
34 |
35 | override fun toString(): String = "Rgb(r=$r, g=$g, b=$b, colorSpace=${colorSpace.name})"
36 |
37 | companion object {
38 |
39 | fun CieXyz.toRgb(
40 | luminance: Double,
41 | colorSpace: RgbColorSpace,
42 | ): Rgb = (colorSpace.rgbToXyzMatrix.inverse() * (xyz / luminance))
43 | .map { colorSpace.transferFunction.OETF(it) }
44 | .toDoubleArray().asRgb(colorSpace)
45 |
46 | internal fun DoubleArray.asRgb(colorSpace: RgbColorSpace): Rgb =
47 | Rgb(this[0], this[1], this[2], colorSpace)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/rgb/transferfunction/GammaTransferFunction.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.transferfunction
9 |
10 | import kotlin.math.pow
11 |
12 | class GammaTransferFunction(
13 | val gamma: Double, // decoding gamma γ
14 | val alpha: Double, // offset a, α = a + 1
15 | val beta: Double, // linear-domain threshold β = K_0 / φ = E_t
16 | val delta: Double, // linear gain δ = φ
17 | ) : TransferFunction {
18 |
19 | override fun EOTF(x: Double): Double = when {
20 | x >= beta * delta -> ((x + alpha - 1.0) / alpha).pow(gamma) // transition point βδ = K_0
21 | else -> x / delta
22 | }
23 |
24 | override fun OETF(x: Double): Double = when {
25 | x >= beta -> alpha * (x.pow(1.0 / gamma) - 1.0) + 1.0
26 | else -> x * delta
27 | }
28 |
29 | companion object {
30 |
31 | /**
32 | * [Wikipedia: sRGB - Computing the transfer function](https://en.wikipedia.org/wiki/SRGB#Computing_the_transfer_function)
33 | */
34 | val sRGB = GammaTransferFunction(
35 | gamma = 2.4,
36 | alpha = 1.055,
37 | beta = 0.055 / 1.4 / ((1.055 / 2.4).pow(2.4) * (1.4 / 0.055).pow(1.4)),
38 | // ~0.03928571428571429 / ~12.923210180787857 = ~0.0030399346397784314 -> 0.003130804935
39 | delta = (1.055 / 2.4).pow(2.4) * (1.4 / 0.055).pow(1.4),
40 | // ~12.923210180787857 -> 12.920020442059
41 | )
42 |
43 | /**
44 | * [Rec. 709](https://www.itu.int/rec/R-REC-BT.709)
45 | */
46 | val Rec709 = GammaTransferFunction(
47 | gamma = 2.4,
48 | alpha = 1.0 + 5.5 * 0.018053968510807, // ~1.09929682680944
49 | beta = 0.018053968510807,
50 | delta = 4.5,
51 | )
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/rgb/transferfunction/HLGTransferFunction.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.transferfunction
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.util.square
11 | import kotlin.math.exp
12 | import kotlin.math.ln
13 | import kotlin.math.sqrt
14 |
15 | /**
16 | * [Rec. 2100](https://www.itu.int/rec/R-REC-BT.2100)
17 | */
18 | class HLGTransferFunction : TransferFunction {
19 |
20 | companion object {
21 |
22 | private val a = 0.17883277
23 | private val b = 1.0 - 4.0 * a // 0.28466892
24 | private val c = 0.5 - a * ln(4.0 * a) // 0.55991073
25 | }
26 |
27 | override fun EOTF(x: Double): Double = when (x) {
28 | in 0.0..1.0 / 2.0 -> 3.0 * square(x)
29 | in 1.0 / 2.0..1.0 -> (exp((x - c) / a) + b) / 12.0
30 | else -> Double.NaN
31 | }
32 |
33 | override fun OETF(x: Double): Double = when (x) {
34 | in 0.0..1.0 / 12.0 -> sqrt(3.0 * x)
35 | in 1.0 / 12.0..1.0 -> a * ln(12.0 * x - b) + c
36 | else -> Double.NaN
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/rgb/transferfunction/PQTransferFunction.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.transferfunction
9 |
10 | import kotlin.math.pow
11 |
12 | /**
13 | * [Rec. 2100](https://www.itu.int/rec/R-REC-BT.2100)
14 | */
15 | class PQTransferFunction : TransferFunction {
16 |
17 | companion object {
18 |
19 | private val m_1 = 2610.0 / 16384.0 // 0.1593017578125
20 | private val m_2 = 2523.0 / 4096.0 * 128.0 // 78.84375
21 | private val c_1 = 3424.0 / 4096.0 // 0.8359375 = c_3 − c_2 + 1
22 | private val c_2 = 2413.0 / 4096.0 * 32.0 // 18.8515625
23 | private val c_3 = 2392.0 / 4096.0 * 32.0 // 18.6875
24 | }
25 |
26 | override fun EOTF(x: Double): Double =
27 | 10000.0 * ((x.pow(1.0 / m_2)
28 | .coerceAtLeast(0.0)) / (c_2 - c_3 * x.pow(1.0 / m_2))).pow(1.0 / m_1)
29 |
30 | override fun OETF(x: Double): Double =
31 | ((c_1 + c_2 * x / 10000.0) / (1 + c_3 * x / 10000.0)).pow(
32 | m_2
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/rgb/transferfunction/TransferFunction.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.transferfunction
9 |
10 | interface TransferFunction {
11 |
12 | // nonlinear -> linear
13 | fun EOTF(x: Double): Double
14 |
15 | // linear -> nonlinear
16 | fun OETF(x: Double): Double
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/colorspace/zcam/Izazbz.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.colorspace.zcam
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
11 | import org.dianqk.ruslin.ui.theme.palette.util.Matrix3
12 | import kotlin.math.pow
13 |
14 | data class Izazbz(
15 | val Iz: Double,
16 | val az: Double,
17 | val bz: Double,
18 | ) {
19 |
20 | fun toXyz(): CieXyz {
21 | val (x_, y_, z) = lmsToXyz * (IzazbzToLms * doubleArrayOf(Iz + epsilon, az, bz)).map {
22 | 10000.0 * ((c_1 - it.pow(1.0 / rho)) / (c_3 * it.pow(1.0 / rho) - c_2)).pow(1.0 / eta)
23 | }.toDoubleArray()
24 | val x = (x_ + (b - 1.0) * z) / b
25 | val y = (y_ + (g - 1.0) * x) / g
26 | return CieXyz(
27 | x = x,
28 | y = y,
29 | z = z,
30 | )
31 | }
32 |
33 | companion object {
34 |
35 | private const val b = 1.15
36 | private const val g = 0.66
37 | private const val c_1 = 3424.0 / 4096.0
38 | private const val c_2 = 2413.0 / 128.0
39 | private const val c_3 = 2392.0 / 128.0
40 | private const val eta = 2610.0 / 16384.0
41 | private const val rho = 1.7 * 2523.0 / 32.0
42 | private const val epsilon = 3.7035226210190005E-11
43 |
44 | private val xyzToLms: Matrix3 = Matrix3(
45 | doubleArrayOf(0.41478972, 0.579999, 0.01464800),
46 | doubleArrayOf(-0.2015100, 1.120649, 0.05310080),
47 | doubleArrayOf(-0.0166008, 0.264800, 0.66847990),
48 | )
49 | private val lmsToXyz: Matrix3 = xyzToLms.inverse()
50 | private val lmsToIzazbz: Matrix3 = Matrix3(
51 | doubleArrayOf(0.0, 1.0, 0.0),
52 | doubleArrayOf(3.524000, -4.066708, 0.542708),
53 | doubleArrayOf(0.199076, 1.096799, -1.295875),
54 | )
55 | private val IzazbzToLms: Matrix3 = lmsToIzazbz.inverse()
56 |
57 | fun CieXyz.toIzazbz(): Izazbz {
58 | val (I, az, bz) = lmsToIzazbz * (
59 | xyzToLms * doubleArrayOf(
60 | b * x - (b - 1.0) * z,
61 | g * y - (g - 1.0) * x,
62 | z,
63 | )
64 | ).map {
65 | ((c_1 + c_2 * (it / 10000.0).pow(eta)) / (1.0 + c_3 * (it / 10000.0).pow(eta))).pow(
66 | rho
67 | )
68 | }.toDoubleArray()
69 | return Izazbz(
70 | Iz = I - epsilon,
71 | az = az,
72 | bz = bz,
73 | )
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/core/ColorSpaces.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.core
9 |
10 | import androidx.compose.runtime.Composable
11 | import org.dianqk.ruslin.ui.theme.palette.colorspace.cielab.CieLab
12 | import org.dianqk.ruslin.ui.theme.palette.colorspace.cielab.CieLab.Companion.toCieLab
13 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
14 | import org.dianqk.ruslin.ui.theme.palette.colorspace.oklab.Oklch
15 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.Rgb
16 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.Rgb.Companion.toRgb
17 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Izazbz
18 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Izazbz.Companion.toIzazbz
19 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Zcam
20 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Zcam.Companion.toZcam
21 |
22 | @Composable
23 | fun rgb(
24 | r: Double,
25 | g: Double,
26 | b: Double,
27 | ): Rgb = Rgb(
28 | r = r,
29 | g = g,
30 | b = b,
31 | colorSpace = LocalRgbColorSpace.current,
32 | )
33 |
34 | @Composable
35 | fun zcamLch(
36 | L: Double,
37 | C: Double,
38 | h: Double,
39 | ): Zcam = Zcam(
40 | hz = h,
41 | Jz = L,
42 | Cz = C,
43 | cond = LocalZcamViewingConditions.current,
44 | )
45 |
46 | @Composable
47 | fun zcam(
48 | hue: Double = Double.NaN,
49 | brightness: Double = Double.NaN,
50 | lightness: Double = Double.NaN,
51 | colorfulness: Double = Double.NaN,
52 | chroma: Double = Double.NaN,
53 | saturation: Double = Double.NaN,
54 | vividness: Double = Double.NaN,
55 | blackness: Double = Double.NaN,
56 | whiteness: Double = Double.NaN,
57 | ): Zcam = Zcam(
58 | hz = hue,
59 | Qz = brightness,
60 | Jz = lightness,
61 | Mz = colorfulness,
62 | Cz = chroma,
63 | Sz = saturation,
64 | Vz = vividness,
65 | Kz = blackness,
66 | Wz = whiteness,
67 | cond = LocalZcamViewingConditions.current,
68 | )
69 |
70 | @Composable
71 | fun CieXyz.toRgb(): Rgb = toRgb(LocalLuminance.current, LocalRgbColorSpace.current)
72 |
73 | @Composable
74 | fun CieLab.toXyz(): CieXyz = toXyz(LocalWhitePoint.current, LocalLuminance.current)
75 |
76 | @Composable
77 | fun CieXyz.toCieLab(): CieLab = toCieLab(LocalWhitePoint.current, LocalLuminance.current)
78 |
79 | @Composable
80 | fun Rgb.toXyz(): CieXyz = toXyz(LocalLuminance.current)
81 |
82 | @Composable
83 | fun Rgb.toZcam(): Zcam = toXyz().toIzazbz().toZcam()
84 |
85 | @Composable
86 | fun Oklch.clampToRgb(): Rgb = clampToRgb(LocalRgbColorSpace.current)
87 |
88 | @Composable
89 | fun Izazbz.toZcam(): Zcam = toZcam(LocalZcamViewingConditions.current)
90 |
91 | @Composable
92 | fun Zcam.toRgb(): Rgb =
93 | toIzazbz().toXyz()
94 | .toRgb(LocalZcamViewingConditions.current.luminance, LocalRgbColorSpace.current)
95 |
96 | @Composable
97 | fun Zcam.clampToRgb(): Rgb = clampToRgb(LocalRgbColorSpace.current)
98 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/core/ColorUtils.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.core
9 |
10 | import androidx.compose.animation.core.AnimationSpec
11 | import androidx.compose.animation.core.AnimationVector3D
12 | import androidx.compose.animation.core.TwoWayConverter
13 | import androidx.compose.animation.core.animateValueAsState
14 | import androidx.compose.animation.core.spring
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.State
17 | import androidx.compose.runtime.remember
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.graphics.colorspace.ColorSpaces
20 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.Rgb
21 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.RgbColorSpace
22 |
23 | fun Rgb.toColor(): Color = if (!r.isNaN() && !g.isNaN() && !b.isNaN())
24 | Color(
25 | red = r.toFloat(),
26 | green = g.toFloat(),
27 | blue = b.toFloat(),
28 | colorSpace = when (colorSpace) {
29 | RgbColorSpace.Srgb -> ColorSpaces.Srgb
30 | RgbColorSpace.DisplayP3 -> ColorSpaces.DisplayP3
31 | RgbColorSpace.BT2020 -> ColorSpaces.Bt2020
32 | else -> ColorSpaces.Srgb
33 | }
34 | ) else Color.Black
35 |
36 | @Composable
37 | fun Color.toRgb(): Rgb {
38 | val color = convert(
39 | when (LocalRgbColorSpace.current) {
40 | RgbColorSpace.Srgb -> ColorSpaces.Srgb
41 | RgbColorSpace.DisplayP3 -> ColorSpaces.DisplayP3
42 | RgbColorSpace.BT2020 -> ColorSpaces.Bt2020
43 | else -> ColorSpaces.Srgb
44 | }
45 | )
46 | return Rgb(
47 | r = color.red.toDouble(),
48 | g = color.green.toDouble(),
49 | b = color.blue.toDouble(),
50 | colorSpace = LocalRgbColorSpace.current
51 | )
52 | }
53 |
54 | @Composable
55 | fun animateZcamLchAsState(
56 | targetValue: ZcamLch,
57 | animationSpec: AnimationSpec = spring(),
58 | finishedListener: ((ZcamLch) -> Unit)? = null,
59 | ): State {
60 | val converter = remember {
61 | TwoWayConverter(
62 | convertToVector = {
63 | AnimationVector3D(it.L.toFloat(), it.C.toFloat(), it.h.toFloat())
64 | },
65 | convertFromVector = {
66 | ZcamLch(L = it.v1.toDouble(), C = it.v2.toDouble(), h = it.v3.toDouble())
67 | }
68 | )
69 | }
70 | return animateValueAsState(
71 | targetValue,
72 | converter,
73 | animationSpec,
74 | finishedListener = finishedListener
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/core/CompositionLocals.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.core
9 |
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.CompositionLocalProvider
12 | import androidx.compose.runtime.staticCompositionLocalOf
13 | import org.dianqk.ruslin.ui.theme.palette.colorspace.cielab.CieLab
14 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
15 | import org.dianqk.ruslin.ui.theme.palette.colorspace.rgb.RgbColorSpace
16 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Zcam
17 | import org.dianqk.ruslin.ui.theme.palette.data.Illuminant
18 |
19 | val LocalWhitePoint = staticCompositionLocalOf {
20 | Illuminant.D65
21 | }
22 |
23 | val LocalLuminance = staticCompositionLocalOf {
24 | 1.0
25 | }
26 |
27 | val LocalRgbColorSpace = staticCompositionLocalOf {
28 | RgbColorSpace.Srgb
29 | }
30 |
31 | val LocalZcamViewingConditions = staticCompositionLocalOf {
32 | createZcamViewingConditions()
33 | }
34 |
35 | @Composable
36 | fun ProvideZcamViewingConditions(
37 | whitePoint: CieXyz = Illuminant.D65,
38 | luminance: Double = 203.0, // BT.2408-4, HDR white luminance
39 | surroundFactor: Double = 0.69, // average surround
40 | content: @Composable () -> Unit,
41 | ) {
42 | CompositionLocalProvider(
43 | LocalWhitePoint provides whitePoint,
44 | LocalLuminance provides luminance,
45 | LocalZcamViewingConditions provides createZcamViewingConditions(
46 | whitePoint = whitePoint,
47 | luminance = luminance,
48 | surroundFactor = surroundFactor,
49 | )
50 | ) {
51 | content()
52 | }
53 | }
54 |
55 | fun createZcamViewingConditions(
56 | whitePoint: CieXyz = Illuminant.D65,
57 | luminance: Double = 203.0,
58 | surroundFactor: Double = 0.69,
59 | ): Zcam.Companion.ViewingConditions = Zcam.Companion.ViewingConditions(
60 | whitePoint = whitePoint,
61 | luminance = luminance,
62 | F_s = surroundFactor,
63 | L_a = 0.4 * luminance,
64 | Y_b = CieLab(50.0, 0.0, 0.0).toXyz(whitePoint, luminance).luminance,
65 | )
66 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/core/ZcamLch.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.core
9 |
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.graphics.Color
12 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Zcam
13 |
14 | data class ZcamLch(
15 | val L: Double,
16 | val C: Double,
17 | val h: Double,
18 | ) {
19 |
20 | @Composable
21 | fun toZcam(): Zcam = zcamLch(L = L, C = C, h = h)
22 |
23 | companion object {
24 |
25 | @Composable
26 | fun Color.toZcamLch(): ZcamLch = toRgb().toZcam().toZcamLch()
27 |
28 | fun Zcam.toZcamLch(): ZcamLch = ZcamLch(L = Jz, C = Cz, h = hz)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/data/Illuminant.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.data
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.ciexyz.CieXyz
11 |
12 | object Illuminant {
13 |
14 | /** CIE Illuminant D65 - standard 2º observer. 6504 K color temperature.
15 | * Values are calculated from [this table](https://github.com/gpmarques/colorimetry/blob/master/all_1nm_data.xls).
16 | */
17 | val D65: CieXyz by lazy {
18 | CieXyz(
19 | x = 10043.7000153676 / 10567.0816669881,
20 | y = 1.0,
21 | z = 11505.7421788588 / 10567.0816669881,
22 | )
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/dynamic/Harmonies.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | */
7 |
8 | package org.dianqk.ruslin.ui.theme.palette.dynamic
9 |
10 | import org.dianqk.ruslin.ui.theme.palette.colorspace.zcam.Zcam
11 | import kotlin.math.abs
12 | import kotlin.math.absoluteValue
13 | import kotlin.math.sign
14 |
15 | fun Zcam.harmonizeTowards(
16 | target: Zcam,
17 | factor: Double = 0.5,
18 | maxHueShift: Double = 15.0,
19 | ): Zcam = copy(
20 | hz = hz + (
21 | ((180.0 - abs(abs(hz - target.hz) - 180.0)) * factor).coerceAtMost(maxHueShift)
22 | ) * (
23 | listOf(
24 | target.hz - hz,
25 | target.hz - hz + 360.0,
26 | target.hz - hz - 360.0
27 | ).minOf {
28 | it.absoluteValue
29 | }.sign.takeIf { it != 0.0 } ?: 1.0
30 | )
31 | )
32 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/dynamic/WallpaperColors.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (C) 2021 Kyant0
3 | *
4 | * @link https://github.com/Kyant0/MusicYou
5 | * @author Kyant0
6 | * @modifier Ashinch
7 | */
8 |
9 | package org.dianqk.ruslin.ui.theme.palette.dynamic
10 |
11 | import android.app.WallpaperManager
12 | import android.os.Build
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.Stable
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.platform.LocalContext
17 | import androidx.compose.ui.platform.LocalView
18 | import org.dianqk.ruslin.ui.theme.palette.TonalPalettes
19 | import org.dianqk.ruslin.ui.theme.palette.TonalPalettes.Companion.getSystemTonalPalettes
20 | import org.dianqk.ruslin.ui.theme.palette.TonalPalettes.Companion.toTonalPalettes
21 |
22 | object PresetColor {
23 | val blue = Color(0xFF80BBFF)
24 | val pink = Color(0xFFFFD8E4)
25 | val purple = Color(0xFF62539f)
26 | val yellow = Color(0xFFE9B666)
27 | }
28 |
29 | @Composable
30 | @Stable
31 | fun extractTonalPalettesFromUserWallpaper(): List {
32 | val context = LocalContext.current
33 |
34 | val preset = mutableListOf(
35 | PresetColor.blue.toTonalPalettes(),
36 | PresetColor.pink.toTonalPalettes(),
37 | PresetColor.purple.toTonalPalettes(),
38 | PresetColor.yellow.toTonalPalettes(),
39 | )
40 |
41 | if (!LocalView.current.isInEditMode) {
42 | val colors = WallpaperManager.getInstance(LocalContext.current)
43 | .getWallpaperColors(WallpaperManager.FLAG_SYSTEM)
44 | val primary = colors?.primaryColor?.toArgb()
45 | // val secondary = colors?.secondaryColor?.toArgb()
46 | // val tertiary = colors?.tertiaryColor?.toArgb()
47 | if (primary != null) {
48 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
49 | preset.add(0, context.getSystemTonalPalettes())
50 | } else {
51 | preset.add(0, Color(primary).toTonalPalettes())
52 | }
53 | }
54 | // if (secondary != null) preset.add(Color(secondary).toTonalPalettes())
55 | // if (tertiary != null) preset.add(Color(tertiary).toTonalPalettes())
56 | }
57 | return preset
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/java/org/dianqk/ruslin/ui/theme/palette/util/MathUtils.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin.ui.theme.palette.util
2 |
3 | import kotlin.math.PI
4 |
5 | internal fun square(x: Double): Double = x * x
6 |
7 | internal fun Double.toRadians(): Double = this * PI / 180.0
8 | internal fun Double.toDegrees(): Double = this * 180.0 / PI
9 |
10 | operator fun DoubleArray.times(x: Double): DoubleArray = map { it * x }.toDoubleArray()
11 | operator fun DoubleArray.div(x: Double): DoubleArray = map { it / x }.toDoubleArray()
12 |
13 | class Matrix3(
14 | private val x: DoubleArray,
15 | private val y: DoubleArray,
16 | private val z: DoubleArray,
17 | ) {
18 |
19 | fun inverse(): Matrix3 {
20 | val det = determinant()
21 | return Matrix3(
22 | doubleArrayOf(
23 | (y[1] * z[2] - y[2] * z[1]) / det,
24 | (y[2] * z[0] - y[0] * z[2]) / det,
25 | (y[0] * z[1] - y[1] * z[0]) / det,
26 | ),
27 | doubleArrayOf(
28 | (x[2] * z[1] - x[1] * z[2]) / det,
29 | (x[0] * z[2] - x[2] * z[0]) / det,
30 | (x[1] * z[0] - x[0] * z[1]) / det,
31 | ),
32 | doubleArrayOf(
33 | (x[1] * y[2] - x[2] * y[1]) / det,
34 | (x[2] * y[0] - x[0] * y[2]) / det,
35 | (x[0] * y[1] - x[1] * y[0]) / det,
36 | ),
37 | ).transpose()
38 | }
39 |
40 | private fun determinant(): Double =
41 | x[0] * (y[1] * z[2] - y[2] * z[1]) -
42 | x[1] * (y[0] * z[2] - y[2] * z[0]) +
43 | x[2] * (y[0] * z[1] - y[1] * z[0])
44 |
45 | private fun transpose() = Matrix3(
46 | doubleArrayOf(x[0], y[0], z[0]),
47 | doubleArrayOf(x[1], y[1], z[1]),
48 | doubleArrayOf(x[2], y[2], z[2]),
49 | )
50 |
51 | operator fun get(i: Int): DoubleArray = when (i) {
52 | 0 -> x
53 | 1 -> y
54 | 2 -> z
55 | else -> throw IndexOutOfBoundsException("Index must be 0, 1 or 2")
56 | }
57 |
58 | operator fun times(vec: DoubleArray): DoubleArray = doubleArrayOf(
59 | x[0] * vec[0] + x[1] * vec[1] + x[2] * vec[2],
60 | y[0] * vec[0] + y[1] * vec[1] + y[2] * vec[2],
61 | z[0] * vec[0] + z[1] * vec[1] + z[2] * vec[2],
62 | )
63 |
64 | override fun toString(): String =
65 | "{" + arrayOf(x, y, z).joinToString { "[" + it.joinToString() + "]" } + "}"
66 | }
67 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/format_h1.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/format_h2.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/format_h3.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/format_h4.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/format_h5.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/format_h6.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_database.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rCN/strings.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 | 同步
26 | 颜色和样式
27 | 主题、色调样式
28 | 关于
29 | 版本、反馈、致谢
30 | 语言 (Languages)
31 | 英语、简体中文、更多
32 | Joplin 服务器
33 | 同步
34 | 每 15 分钟
35 | 每 30 分钟
36 | 每 1 小时
37 | 每 2 小时
38 | 每 3 小时
39 | 每 6 小时
40 | 每 12 小时
41 | 每 1 天
42 | 手动
43 | 同步频率
44 | 启动时同步一次
45 | 仅限连接 Wi-Fi 时
46 | 仅限连接充电器时
47 | 工具
48 | 日志、数据库、更多
49 | 日志
50 | 查看日志
51 | 正在路上
52 | 请输入你的 Joplin 服务器登录信息。
53 | 数据库状态
54 | 查看数据库状态
55 | 搜索
56 | 找不到 “%s” 相关笔记
57 | 正在查找 “%s” 相关的笔记
58 | 笔记加载中
59 | 创建一条笔记?
60 | 删除 "%s"?
61 | 这个文件夹下所有的笔记和子文件夹都会被删除。
62 | 编辑文件夹
63 | 从头开始重新同步
64 | 编辑
65 | 预览
66 | 删除选择的笔记?
67 | 查看 GitHub 项目地址与应用说明
68 | 版本发布
69 | 查看最新版本与更新日记
70 | 当前版本
71 | GitHub 议题
72 | 提交错误报告或改进建议
73 | 信息已复制到剪贴板
74 | 致谢
75 | 资源与开源软件
76 | 同步失败
77 | 跟随系统设置
78 | 深色主题
79 | 高对比度深色主题
80 | 跟随系统设置
81 | 开启
82 | 关闭
83 | 其他
84 | 内容文本方向
85 | 笔记、标题等文本方向
86 | 自动
87 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 | #FFA72145
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
17 |
18 |
24 |
25 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/filepaths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/test/java/org/dianqk/ruslin/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.ruslin
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | buildscript {
2 | repositories {
3 | mavenCentral()
4 | google()
5 | }
6 | }
7 |
8 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
9 | plugins {
10 | alias(libs.plugins.android.application) apply false
11 | alias(libs.plugins.android.library) apply false
12 | alias(libs.plugins.android.test) apply false
13 | alias(libs.plugins.hilt) apply false
14 | alias(libs.plugins.kotlin.gradlePlugin) apply false
15 | alias(libs.plugins.androidx.benchmark) apply false
16 | }
17 |
18 | tasks.register("clean", Delete::class) {
19 | delete(rootProject.layout.buildDirectory)
20 | }
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/1003.txt:
--------------------------------------------------------------------------------
1 | - Supports Farsi and Russian languages
2 | **Full Changelog**: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.2-beta.1
3 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/503.txt:
--------------------------------------------------------------------------------
1 | Currently in Pre-alpha, not ready for use in production environments. Please back up.
2 | ## Features
3 | - Add an editor toolbar
4 | - Add a preview page (by web)
5 | - Support view images and files (You need synchronization from scratch)
6 | - Support multi-select delete
7 | - Add sync failed text
8 | - Delay loading jieba to the search page
9 | - Add about and credits page
10 | **Full Changelog**: https://github.com/ruslin-note/ruslin-android/compare/v0.0.1-pre-alpha.2...v0.0.1-alpha.1
11 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/603.txt:
--------------------------------------------------------------------------------
1 | Currently in Pre-alpha, not ready for use in production environments. Please back up.
2 | - Support in-app translation
3 | **Full Changelog**: https://github.com/ruslin-note/ruslin-android/compare/v0.0.1-alpha.1...v0.0.1-alpha.2
4 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/703.txt:
--------------------------------------------------------------------------------
1 | - RTL language support
2 | - Theme switching support
3 | - Improved dark theme experience
4 | **Full Changelog**: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.0-beta.1
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/803.txt:
--------------------------------------------------------------------------------
1 | - RTL language support
2 | - Theme switching support
3 | - Improved dark theme experience
4 | **Full Changelog**: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.1-beta.1
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/903.txt:
--------------------------------------------------------------------------------
1 | - Preview page adds RTL support
2 | **Full Changelog**: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.1-beta.2
3 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Supported features:
2 |
3 | ✅ Create, modify and delete folders and notes
4 | ✅ Markdown editor with toolbar
5 | ✅ Full-text search using jieba-rs (Chinese and English supported)
6 | ✅ Sync notes using a self-hosted Joplin server
7 | ✅ Manual and automatic synchronization
8 | 🚧 Possible compatibility with Joplin's sync format (End-to-end encryption is not supported)
9 |
10 | It is currently in the beta stage, so take care to make backups.
11 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/featureGraphic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/en-US/images/featureGraphic.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/en-US/images/icon.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | A note application that supports syncing using a self-hosted Joplin server.
2 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/ru/full_description.txt:
--------------------------------------------------------------------------------
1 | Поддерживаемые возможности:
2 |
3 | ✅ Создавать, изменять и удалять папки и заметки
4 | ✅ Редактор Markdown с панелью инструментов
5 | ✅ Полнотекстовый поиск с использованием jieba-rs
6 | ✅ Синхронизируйте заметки со своим сервером Joplin
7 | ✅ Ручная и автоматическая синхронизация
8 | 🚧 Возможна совместимость с форматом синхронизации Joplin (сквозное шифрование не поддерживается)
9 |
10 | В настоящее время программа находится в стадии бета-тестирования, поэтому позаботьтесь о создании резервных копий.
11 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/ru/short_description.txt:
--------------------------------------------------------------------------------
1 | Приложение для заметок с поддержкой синхронизации со своим сервером Joplin
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/changelogs/503.txt:
--------------------------------------------------------------------------------
1 | 目前处于 Pre-alpha 阶段,不建议在生产环境使用,请注意做好备份。
2 | - 添加一个编辑器工具栏
3 | - 添加一个 Markdown 预览页
4 | - 支持查看图片和文件(你需要从头同步)
5 | - 添加同步失败的文字
6 | - 延迟 jieba 的加载时机到搜索页
7 | - 添加关于和感谢页
8 | 完整变更日志: https://github.com/ruslin-note/ruslin-android/compare/v0.0.1-pre-alpha.2...v0.0.1-alpha.1
9 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/changelogs/603.txt:
--------------------------------------------------------------------------------
1 | 目前处于 Pre-alpha 阶段,不建议在生产环境使用,请注意做好备份。
2 | - 支持应用内切换语言
3 | 完整变更日志: https://github.com/ruslin-note/ruslin-android/compare/v0.0.1-alpha.1...v0.0.1-alpha.2
4 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/changelogs/703.txt:
--------------------------------------------------------------------------------
1 | - 支持 RTL 语言
2 | - 支持主题切换
3 | - 改进暗色主题体验
4 | 完整变更日志: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.0-beta.1
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/changelogs/803.txt:
--------------------------------------------------------------------------------
1 | - 支持 RTL 语言
2 | - 支持主题切换
3 | - 改进暗色主题体验
4 | 完整变更日志: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.1-beta.1
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/changelogs/903.txt:
--------------------------------------------------------------------------------
1 | - 预览页支持 RTL 语言
2 | 完整变更日志: https://github.com/ruslin-note/ruslin-android/releases/tag/v0.1.1-beta.2
3 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/full_description.txt:
--------------------------------------------------------------------------------
1 | 已支持的功能:
2 |
3 | ✅ 创建、修改和删除文件夹和笔记
4 | ✅ 带有工具栏的 Markdown 编辑器
5 | ✅ 基于 jieba-rs 的全文搜索(支持中文和英文)
6 | ✅ 使用本地部署的 Joplin 服务器同步笔记
7 | ✅ 手动和自动同步
8 | 🚧 可能兼容 Joplin 的同步格式(不支持端到端加密)
9 |
10 | 目前处于测试阶段,注意做好备份。
11 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/account.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/zh-CN/images/account.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/editor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/zh-CN/images/editor.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/folders.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/zh-CN/images/folders.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/notes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/zh-CN/images/notes.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/fastlane/metadata/android/zh-CN/images/search.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/short_description.txt:
--------------------------------------------------------------------------------
1 | 一个简单的笔记应用,支持使用本地部署的 Joplin 服务器同步笔记。
2 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | # Enable non-final resource IDs for faster incremental compilation.
25 | android.nonFinalResIds=true
26 | android.enableR8.fullMode=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/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.5-bin.zip
4 | networkTimeout=10000
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/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.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
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.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
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 |
--------------------------------------------------------------------------------
/mdrender/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/mdrender/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | id("org.jetbrains.kotlin.android")
4 | }
5 |
6 | android {
7 | namespace = "org.dianqk.mdrender"
8 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
9 | ndkVersion = libs.versions.ndkVersion.get()
10 | buildToolsVersion = libs.versions.buildToolsVersion.get()
11 |
12 | defaultConfig {
13 | minSdk = libs.versions.minSdkVersion.get().toInt()
14 | // targetSdk = libs.versions.targetSdkVersion.get().toInt()
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles("consumer-rules.pro")
17 | }
18 |
19 | buildTypes {
20 | release {
21 | isMinifyEnabled = false
22 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
23 | }
24 | }
25 | compileOptions {
26 | sourceCompatibility = JavaVersion.VERSION_17
27 | targetCompatibility = JavaVersion.VERSION_17
28 | }
29 | kotlinOptions {
30 | jvmTarget = JavaVersion.VERSION_17.toString()
31 | // freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn -Xjvm-default=all"
32 | }
33 | kotlin {
34 | jvmToolchain(17)
35 | }
36 | }
37 |
38 | dependencies {
39 |
40 | // Replying on uniffi is not a good choice
41 | implementation(project(":uniffi"))
42 |
43 | implementation(libs.androidx.core.ktx)
44 | implementation(libs.androidx.appcompat)
45 | implementation(libs.android.material)
46 | implementation(libs.compose.ui)
47 | implementation(libs.compose.material3)
48 |
49 | androidTestImplementation(libs.junit)
50 | androidTestImplementation(libs.androidx.test.ext.junit)
51 | androidTestImplementation(libs.androidx.test.espresso.core)
52 | }
--------------------------------------------------------------------------------
/mdrender/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/mdrender/consumer-rules.pro
--------------------------------------------------------------------------------
/mdrender/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/mdrender/src/androidTest/java/org/dianqk/mdrender/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.mdrender
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.*
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("org.dianqk.mdrender.test", appContext.packageName)
21 | }
22 | }
--------------------------------------------------------------------------------
/mdrender/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/mdrender/src/test/java/org/dianqk/mdrender/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.mdrender
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
--------------------------------------------------------------------------------
/mdrenderbenchmark/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/mdrenderbenchmark/benchmark-proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
23 | -dontobfuscate
24 |
25 | -ignorewarnings
26 |
27 | -keepattributes *Annotation*
28 |
29 | -dontnote junit.framework.**
30 | -dontnote junit.runner.**
31 |
32 | -dontwarn androidx.test.**
33 | -dontwarn org.junit.**
34 | -dontwarn org.hamcrest.**
35 | #-dontwarn com.squareup.javawriter.JavaWriter
36 |
37 | #-keepclasseswithmembers @org.junit.runner.RunWith public class *
--------------------------------------------------------------------------------
/mdrenderbenchmark/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | id("androidx.benchmark")
4 | id("org.jetbrains.kotlin.android")
5 | }
6 |
7 | android {
8 | namespace = "org.dianqk.mdrenderbenchmark"
9 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
10 | ndkVersion = libs.versions.ndkVersion.get()
11 | buildToolsVersion = libs.versions.buildToolsVersion.get()
12 |
13 | defaultConfig {
14 | minSdk = libs.versions.minSdkVersion.get().toInt()
15 | // targetSdk = libs.versions.targetSdkVersion.get().toInt()
16 | testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
17 | }
18 |
19 | compileOptions {
20 | sourceCompatibility = JavaVersion.VERSION_17
21 | targetCompatibility = JavaVersion.VERSION_17
22 | }
23 |
24 | kotlinOptions {
25 | jvmTarget = JavaVersion.VERSION_17.toString()
26 | }
27 | kotlin {
28 | jvmToolchain(17)
29 | }
30 | testBuildType = "release"
31 |
32 | buildTypes {
33 | debug {
34 | // Since debuggable can"t be modified by gradle for library modules,
35 | // it must be done in a manifest - see src/androidTest/AndroidManifest.xml
36 | isMinifyEnabled = true
37 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro")
38 | }
39 | release {
40 | isDefault = true
41 | }
42 | }
43 | }
44 |
45 | dependencies {
46 | implementation(libs.androidx.core.ktx)
47 | implementation(libs.androidx.appcompat)
48 | implementation(libs.android.material)
49 | implementation(libs.compose.ui)
50 | implementation(libs.compose.material3)
51 |
52 | androidTestImplementation(libs.androidx.test.runner)
53 | androidTestImplementation(libs.junit)
54 | androidTestImplementation(libs.androidx.test.ext.junit)
55 | androidTestImplementation(libs.androidx.benchmark.junit4)
56 |
57 | // Add your dependencies here. Note that you cannot benchmark code
58 | // in an app module this way - you will need to move any code you
59 | // want to benchmark to a library module:
60 | // https://developer.android.com/studio/projects/android-library#Convert
61 | androidTestImplementation(project(":mdrender"))
62 | }
--------------------------------------------------------------------------------
/mdrenderbenchmark/src/androidTest/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
11 |
15 |
--------------------------------------------------------------------------------
/mdrenderbenchmark/src/androidTest/java/org/dianqk/mdrenderbenchmark/MarkdownRenderBenchmark.kt:
--------------------------------------------------------------------------------
1 | package org.dianqk.mdrenderbenchmark
2 |
3 | import android.content.Context
4 | import androidx.annotation.RawRes
5 | import androidx.benchmark.junit4.BenchmarkRule
6 | import androidx.benchmark.junit4.measureRepeated
7 | import androidx.compose.ui.text.AnnotatedString
8 | import androidx.test.ext.junit.runners.AndroidJUnit4
9 | import androidx.test.platform.app.InstrumentationRegistry
10 | import org.dianqk.mdrender.MarkdownVisualTransformation
11 | import org.junit.Before
12 | import org.junit.Rule
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 |
16 | /**
17 | * Testing markdown parsing and rendering performance.
18 | *
19 | * You man need to disable the root privileges of the shell(\[SharedUID\] Shell) in Magisk to avoid getting stuck in the test boot session.
20 | */
21 | @RunWith(AndroidJUnit4::class)
22 | class MarkdownRenderBenchmark {
23 |
24 | private lateinit var instrumentationContext: Context
25 |
26 | @get:Rule
27 | val benchmarkRule = BenchmarkRule()
28 |
29 | @Before
30 | fun setup() {
31 | instrumentationContext = InstrumentationRegistry.getInstrumentation().context
32 | }
33 |
34 | @Test
35 | fun benchmarkText1Filter() {
36 | // https://raw.githubusercontent.com/markdown-it/markdown-it/df4607f1d4d4be7fdc32e71c04109aea8cc373fa/support/demo_template/sample.md
37 | val text = readText(R.raw.text1)
38 | val markdownVisualTransformation = MarkdownVisualTransformation()
39 | benchmarkRule.measureRepeated {
40 | markdownVisualTransformation.invalid()
41 | markdownVisualTransformation.filter(text)
42 | }
43 | }
44 |
45 | @Test
46 | fun benchmarkText1Parse() {
47 | val text = readText(R.raw.text1)
48 | val markdownVisualTransformation = MarkdownVisualTransformation()
49 | benchmarkRule.measureRepeated {
50 | markdownVisualTransformation.parse(text)
51 | }
52 | }
53 |
54 | @Test
55 | fun benchmarkText1Render() {
56 | val text = readText(R.raw.text1)
57 | val markdownVisualTransformation = MarkdownVisualTransformation()
58 | val parsedTagRanges = markdownVisualTransformation.parse(text)
59 | benchmarkRule.measureRepeated {
60 | markdownVisualTransformation.render(parsedTagRanges, text)
61 | }
62 | }
63 |
64 | private fun readText(@RawRes id: Int): AnnotatedString {
65 | val textInputStream = instrumentationContext.resources.openRawResource(id)
66 | val text = textInputStream.bufferedReader().use { it.readText() }
67 | return AnnotatedString(text)
68 | }
69 | }
--------------------------------------------------------------------------------
/mdrenderbenchmark/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | rustflags = ["--cfg", "uuid_unstable"]
3 | target = "aarch64-linux-android"
4 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /.vscode/settings.json
3 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/.vscode/settings.json.template:
--------------------------------------------------------------------------------
1 | {
2 | "rust-analyzer.cargo.extraEnv": {
3 | "AR": "REPLACE_ANDROID_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar",
4 | "CC": "REPLACE_ANDROID_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang",
5 | "CXX": "ANDROIREPLACE_ANDROID_HOMED_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang++",
6 | "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER": "REPLACE_ANDROID_HOME/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang",
7 | },
8 | "rust-analyzer.cargo.target": "aarch64-linux-android",
9 | "terminal.integrated.env.linux": {
10 | "AR": "${env:ANDROID_HOME}/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar",
11 | "CC": "${env:ANDROID_HOME}/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang",
12 | "CXX": "${env:ANDROID_HOME}/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang++",
13 | "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER": "${env:ANDROID_HOME}/ndk/25.2.9519653/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang",
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "cargo",
6 | "command": "build",
7 | "problemMatcher": [
8 | "$rustc"
9 | ],
10 | "group": {
11 | "kind": "build",
12 | "isDefault": false
13 | },
14 | "label": "rust: cargo build"
15 | },
16 | {
17 | "type": "shell",
18 | "command": "rsync",
19 | "args": ["-avz", "--mkpath", "target/aarch64-linux-android/debug/libuniffi_ruslin.so", "../uniffi/src/main/jniLibs/arm64-v8a/libuniffi_ruslin.so"],
20 | "group": {
21 | "kind": "build",
22 | "isDefault": true
23 | },
24 | "dependsOn": ["rust: cargo build"],
25 | "label": "rust: build & copy"
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "ruslin-data-uniffi"
3 | version = "0.1.0"
4 | edition = "2021"
5 | license = "GPL-3.0"
6 |
7 | [lib]
8 | crate-type = ["cdylib"]
9 | name = "uniffi_ruslin"
10 |
11 | [dependencies]
12 | uniffi = { version = "0.25" }
13 | ruslin-data = { path = "./ruslin-data" }
14 | log = { version = "0.4", features = ["max_level_debug", "release_max_level_info"] }
15 | android_logger = "0.13"
16 | tokio = { version = "1.28", features = ["full"] }
17 | log4rs = "1.2"
18 | pulldown-cmark = { version = "0.9.3", default-features = false }
19 |
20 | [build-dependencies]
21 | uniffi = { version = "0.25", features = ["build", "cli"] }
22 | camino = "1.1.4"
23 |
24 | [profile.release]
25 | lto = true
26 | debug = 1
27 | codegen-units = 1
28 |
29 | [patch.crates-io]
30 | diesel = { git = 'https://github.com/DianQK/diesel.git', tag = "v2.0.4-p1" }
31 | diesel_migrations = { git = 'https://github.com/DianQK/diesel.git', tag = "v2.0.4-p1" }
32 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/build.rs:
--------------------------------------------------------------------------------
1 | use camino::Utf8Path;
2 | use std::{env, path::Path, process::Command};
3 | use uniffi::TargetLanguage;
4 |
5 | fn main() {
6 | let target = env::var("TARGET").unwrap();
7 | if target == "x86_64-linux-android" || target == "i686-linux-android" {
8 | let cc = env::var_os("CC").unwrap();
9 | let cc = Path::new(&cc);
10 | let output = Command::new(cc)
11 | .arg("-print-libgcc-file-name")
12 | .output()
13 | .unwrap();
14 | let rtlib_path = String::from_utf8(output.stdout).unwrap();
15 | println!("cargo:rustc-link-arg={}", rtlib_path.trim()); // https://github.com/termux/termux-packages/issues/8029#issuecomment-1369150244
16 | }
17 | let udl_file = "./src/ruslin.udl";
18 | uniffi::generate_scaffolding(udl_file).expect("generate_scaffolding error");
19 | generate_kotlin_bindings(udl_file);
20 | }
21 |
22 | pub fn generate_kotlin_bindings(udl_file: impl AsRef) {
23 | let udl_file = udl_file.as_ref();
24 | println!("cargo:rerun-if-changed={udl_file}");
25 | uniffi::generate_bindings(
26 | udl_file,
27 | None,
28 | vec![TargetLanguage::Kotlin],
29 | Some("../uniffi/src/main/java".as_ref()),
30 | None,
31 | None,
32 | true,
33 | )
34 | .unwrap();
35 | }
36 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | export RUSTFLAGS="--cfg uuid_unstable"
6 | export ANDROID_NDK_TOOLCHAIN_BIN=$ANDROID_HOME/ndk/$NDK_VERSION/toolchains/llvm/prebuilt/linux-x86_64/bin
7 | export AR=$ANDROID_NDK_TOOLCHAIN_BIN/llvm-ar
8 |
9 | # export CC=$ANDROID_NDK_TOOLCHAIN_BIN/armv7a-linux-androideabi28-clang
10 | # export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/armv7a-linux-androideabi28-clang++
11 | # export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/armv7a-linux-androideabi28-clang
12 | # cargo build --target armv7-linux-androideabi
13 |
14 | # export CC=$ANDROID_NDK_TOOLCHAIN_BIN/x86_64-linux-android28-clang
15 | # export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/x86_64-linux-android28-clang++
16 | # export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/x86_64-linux-android28-clang
17 | # cargo build --target x86_64-linux-android
18 |
19 | # export CC=$ANDROID_NDK_TOOLCHAIN_BIN/i686-linux-android28-clang
20 | # export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/i686-linux-android28-clang++
21 | # export CARGO_TARGET_I686_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/i686-linux-android28-clang
22 | # cargo build --target i686-linux-android
23 |
24 | export CC=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang
25 | export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang++
26 | # export CARGO_TARGET_AARCH64_LINUX_ANDROID_AR=$ANDROID_NDK_TOOLCHAIN_BIN/llvm-ar
27 | export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang
28 | cargo build --target aarch64-linux-android
29 |
30 | mkdir -p ../uniffi/src/main/jniLibs/arm64-v8a
31 | cp target/aarch64-linux-android/debug/libuniffi_ruslin.so ../uniffi/src/main/jniLibs/arm64-v8a
32 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/release.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | GIT_COMMIT_HASH=$(git rev-parse --verify HEAD | tr -d '\n')
6 |
7 | export CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
8 |
9 | export RUSTFLAGS="--cfg uuid_unstable"
10 | export RUSTFLAGS="$RUSTFLAGS --remap-path-prefix=$HOME/.cargo/=/.cargo/"
11 | export RUSTFLAGS="$RUSTFLAGS --remap-path-prefix=$PWD/=/ruslin-data-uniffi/$GIT_COMMIT_HASH/"
12 |
13 | # This workaround should be removed
14 | # after the https://github.com/llvm/llvm-project/commit/95dcaef00379e893dabc61cf598fe51c9d03414e change is merged into the NDK
15 | OS_RELEASE_ID=$(grep -oP '(?<=^ID=).+' /etc/os-release | tr -d '"')
16 | if [ "$OS_RELEASE_ID" = "debian" ]; then
17 | export RUSTFLAGS="$RUSTFLAGS -C link-args=-Wl,--hash-style=gnu"
18 | fi
19 |
20 | echo "RUSTFLAGS: $RUSTFLAGS"
21 | # https://github.com/briansmith/ring/issues/715 ?
22 | # export CFLAGS="-fdebug-prefix-map=$(pwd)=."
23 |
24 | ANDROID_NDK_TOOLCHAIN_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
25 | export AR=$ANDROID_NDK_TOOLCHAIN_BIN/llvm-ar
26 |
27 | ANDROID_ABI=$1
28 |
29 | case "$ANDROID_ABI" in
30 | arm64-v8a)
31 | RUST_TARGET="aarch64-linux-android"
32 | export CC=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang
33 | export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang++
34 | export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/aarch64-linux-android28-clang
35 | ;;
36 | armeabi-v7a)
37 | RUST_TARGET="armv7-linux-androideabi"
38 | export CC=$ANDROID_NDK_TOOLCHAIN_BIN/armv7a-linux-androideabi28-clang
39 | export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/armv7a-linux-androideabi28-clang++
40 | export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/armv7a-linux-androideabi28-clang
41 | ;;
42 | x86_64)
43 | RUST_TARGET="x86_64-linux-android"
44 | export CC=$ANDROID_NDK_TOOLCHAIN_BIN/x86_64-linux-android28-clang
45 | export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/x86_64-linux-android28-clang++
46 | export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/x86_64-linux-android28-clang
47 | ;;
48 | x86)
49 | RUST_TARGET="i686-linux-android"
50 | export CC=$ANDROID_NDK_TOOLCHAIN_BIN/i686-linux-android28-clang
51 | export CXX=$ANDROID_NDK_TOOLCHAIN_BIN/i686-linux-android28-clang++
52 | export CARGO_TARGET_I686_LINUX_ANDROID_LINKER=$ANDROID_NDK_TOOLCHAIN_BIN/i686-linux-android28-clang
53 | ;;
54 | *)
55 | echo "Unsupported"
56 | exit 1
57 | esac
58 |
59 | echo "Rust target: $RUST_TARGET"
60 | cargo fetch
61 | cargo build --target $RUST_TARGET --verbose --release --frozen --locked
62 | mkdir -p ../uniffi/src/main/jniLibs/$ANDROID_ABI
63 | cp target/$RUST_TARGET/release/libuniffi_ruslin.so ../uniffi/src/main/jniLibs/$ANDROID_ABI
64 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/rust-toolchain.toml:
--------------------------------------------------------------------------------
1 | [toolchain]
2 | channel = "1.75.0"
3 | targets = [
4 | "aarch64-linux-android",
5 | "armv7-linux-androideabi",
6 | "i686-linux-android",
7 | "x86_64-linux-android"
8 | ]
9 | components = ["clippy", "rustfmt"]
10 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/src/ffi/folder.rs:
--------------------------------------------------------------------------------
1 | use ruslin_data::{DateTimeTimestamp, Folder};
2 |
3 | pub struct FFIFolder {
4 | pub id: String,
5 | pub title: String,
6 | pub created_time: i64,
7 | pub updated_time: i64,
8 | pub user_created_time: i64,
9 | pub user_updated_time: i64,
10 | pub encryption_cipher_text: String,
11 | pub encryption_applied: bool,
12 | pub parent_id: Option,
13 | pub is_shared: bool,
14 | pub share_id: String,
15 | pub master_key_id: String,
16 | pub icon: String,
17 | }
18 |
19 | impl From for FFIFolder {
20 | fn from(folder: Folder) -> Self {
21 | Self {
22 | id: folder.id,
23 | title: folder.title,
24 | created_time: folder.created_time.timestamp_millis(),
25 | updated_time: folder.updated_time.timestamp_millis(),
26 | user_created_time: folder.user_created_time.timestamp_millis(),
27 | user_updated_time: folder.user_updated_time.timestamp_millis(),
28 | encryption_cipher_text: folder.encryption_cipher_text,
29 | encryption_applied: folder.encryption_applied,
30 | parent_id: folder.parent_id,
31 | is_shared: folder.is_shared,
32 | share_id: folder.share_id,
33 | master_key_id: folder.master_key_id,
34 | icon: folder.icon,
35 | }
36 | }
37 | }
38 |
39 | impl From for Folder {
40 | fn from(folder: FFIFolder) -> Self {
41 | Self {
42 | id: folder.id,
43 | title: folder.title,
44 | created_time: DateTimeTimestamp::from_timestamp_millis(folder.created_time),
45 | updated_time: DateTimeTimestamp::from_timestamp_millis(folder.updated_time),
46 | user_created_time: DateTimeTimestamp::from_timestamp_millis(folder.user_created_time),
47 | user_updated_time: DateTimeTimestamp::from_timestamp_millis(folder.user_updated_time),
48 | encryption_cipher_text: folder.encryption_cipher_text,
49 | encryption_applied: folder.encryption_applied,
50 | parent_id: folder.parent_id,
51 | is_shared: folder.is_shared,
52 | share_id: folder.share_id,
53 | master_key_id: folder.master_key_id,
54 | icon: folder.icon,
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/src/ffi/mod.rs:
--------------------------------------------------------------------------------
1 | mod folder;
2 | mod note;
3 | mod resource;
4 | mod status;
5 | mod sync_info;
6 |
7 | pub use folder::FFIFolder;
8 | pub use note::{FFIAbbrNote, FFINote, FFISearchNote};
9 | pub use resource::FFIResource;
10 | pub use status::FFIStatus;
11 | pub use sync_info::FFISyncInfo;
12 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/src/ffi/resource.rs:
--------------------------------------------------------------------------------
1 | use ruslin_data::{DateTimeTimestamp, Resource};
2 |
3 | pub struct FFIResource {
4 | pub id: String,
5 | pub title: String,
6 | pub mime: String,
7 | pub filename: String,
8 | pub created_time: i64,
9 | pub updated_time: i64,
10 | pub user_created_time: i64,
11 | pub user_updated_time: i64,
12 | pub file_extension: String,
13 | pub encryption_cipher_text: String,
14 | pub encryption_applied: bool,
15 | pub encryption_blob_encrypted: bool,
16 | pub size: i32,
17 | pub is_shared: bool,
18 | pub share_id: String,
19 | pub master_key_id: String,
20 | }
21 |
22 | impl From for Resource {
23 | fn from(value: FFIResource) -> Self {
24 | Self {
25 | id: value.id,
26 | title: value.title,
27 | mime: value.mime,
28 | filename: value.filename,
29 | created_time: DateTimeTimestamp::from_timestamp_millis(value.created_time),
30 | updated_time: DateTimeTimestamp::from_timestamp_millis(value.updated_time),
31 | user_created_time: DateTimeTimestamp::from_timestamp_millis(value.user_created_time),
32 | user_updated_time: DateTimeTimestamp::from_timestamp_millis(value.user_updated_time),
33 | file_extension: value.file_extension,
34 | encryption_cipher_text: value.encryption_cipher_text,
35 | encryption_applied: value.encryption_applied,
36 | encryption_blob_encrypted: value.encryption_blob_encrypted,
37 | size: value.size,
38 | is_shared: value.is_shared,
39 | share_id: value.share_id,
40 | master_key_id: value.master_key_id,
41 | }
42 | }
43 | }
44 |
45 | impl From for FFIResource {
46 | fn from(value: Resource) -> Self {
47 | Self {
48 | id: value.id,
49 | title: value.title,
50 | mime: value.mime,
51 | filename: value.filename,
52 | created_time: value.created_time.timestamp_millis(),
53 | updated_time: value.updated_time.timestamp_millis(),
54 | user_created_time: value.user_created_time.timestamp_millis(),
55 | user_updated_time: value.user_updated_time.timestamp_millis(),
56 | file_extension: value.file_extension,
57 | encryption_cipher_text: value.encryption_cipher_text,
58 | encryption_applied: value.encryption_applied,
59 | encryption_blob_encrypted: value.encryption_blob_encrypted,
60 | size: value.size,
61 | is_shared: value.is_shared,
62 | share_id: value.share_id,
63 | master_key_id: value.master_key_id,
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/src/ffi/status.rs:
--------------------------------------------------------------------------------
1 | use ruslin_data::Status;
2 |
3 | pub type FFIStatus = Status;
4 |
--------------------------------------------------------------------------------
/ruslin-data-uniffi/src/ffi/sync_info.rs:
--------------------------------------------------------------------------------
1 | use ruslin_data::sync::SyncInfo;
2 |
3 | pub type FFISyncInfo = SyncInfo;
4 |
--------------------------------------------------------------------------------
/scripts/gcloud_benchmark.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | gcloud beta firebase test android run \
6 | --app dummy.apk \
7 | --test mdrenderbenchmark/build/outputs/apk/androidTest/release/mdrenderbenchmark-release-androidTest.apk \
8 | --test-runner-class androidx.benchmark.junit4.AndroidBenchmarkRunner \
9 | --device model=redfin,version=30,locale=en,orientation=portrait \
10 | --directories-to-pull /sdcard/Download \
11 | --environment-variables additionalTestOutputDir=/sdcard/Download,no-isolated-storage=true \
12 | --timeout 20m
13 |
--------------------------------------------------------------------------------
/scripts/local_device_benchmark.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | adb uninstall org.dianqk.mdrenderbenchmark.test
4 |
5 | set -e
6 |
7 | ./gradlew clean
8 | ./gradlew :mdrenderbenchmark:assembleReleaseAndroidTest
9 |
10 | adb install mdrenderbenchmark/build/outputs/apk/androidTest/release/mdrenderbenchmark-release-androidTest.apk
11 | adb shell am instrument -w org.dianqk.mdrenderbenchmark.test/androidx.benchmark.junit4.AndroidBenchmarkRunner
12 |
13 | adb pull /storage/emulated/0/Android/media/org.dianqk.mdrenderbenchmark.test/org.dianqk.mdrenderbenchmark.test-benchmarkData.json
14 |
--------------------------------------------------------------------------------
/scripts/prepare_artifacts.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | pushd app/build/outputs/mapping/release
6 | zip mapping.zip *.txt
7 | popd
8 |
9 | mv app/build/outputs/bundle/release/app-release.aab app/build/outputs/bundle/release/x-app-release.aab
10 | mv app/build/outputs/mapping/release/mapping.zip app/build/outputs/mapping/release/x-mapping.zip
11 | # FIXME: Unable to retrieve this in CI.
12 | # mv app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip app/build/outputs/native-debug-symbols/release/x-native-debug-symbols.zip
13 |
--------------------------------------------------------------------------------
/scripts/prepare_release.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const assert = require('assert');
3 |
4 | const releaseDir = './app/build/outputs/apk/release';
5 | const outputMetadata = JSON.parse(fs.readFileSync(`${releaseDir}/output-metadata.json`, 'utf8'));
6 |
7 | const elements = outputMetadata.elements;
8 |
9 | for (let element of elements) {
10 | let sourceApk = `${releaseDir}/${element.outputFile}`;
11 | let abiFilter = element.filters[0];
12 | assert.equal("ABI", abiFilter.filterType);
13 | let abi = abiFilter.value;
14 | let destinationApk = `${releaseDir}/ruslin-${abi}-${element.versionName}-${outputMetadata.variantName}.apk`;
15 | fs.renameSync(sourceApk, destinationApk);
16 | }
17 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | pluginManagement {
4 | repositories {
5 | gradlePluginPortal()
6 | google()
7 | mavenCentral()
8 | }
9 | }
10 |
11 | plugins {
12 | // https://docs.gradle.org/8.5/userguide/toolchains.html#sub:download_repositories
13 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.7.0")
14 | }
15 |
16 | dependencyResolutionManagement {
17 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
18 | repositories {
19 | google()
20 | mavenCentral()
21 | }
22 | }
23 | rootProject.name = "Ruslin"
24 | include(":app", ":mdrender", ":mdrenderbenchmark", ":uniffi")
25 |
--------------------------------------------------------------------------------
/uniffi/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/uniffi/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | id("org.jetbrains.kotlin.android")
4 | }
5 |
6 | android {
7 | namespace = "uniffi.ruslin"
8 | compileSdk = libs.versions.compileSdkVersion.get().toInt()
9 | ndkVersion = libs.versions.ndkVersion.get()
10 | buildToolsVersion = libs.versions.buildToolsVersion.get()
11 |
12 | defaultConfig {
13 | minSdk = libs.versions.minSdkVersion.get().toInt()
14 | // targetSdk = libs.versions.targetSdkVersion.get().toInt()
15 | consumerProguardFiles("consumer-rules.pro")
16 | }
17 |
18 | buildTypes {
19 | release {
20 | isMinifyEnabled = true
21 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
22 | }
23 | }
24 | compileOptions {
25 | sourceCompatibility = JavaVersion.VERSION_17
26 | targetCompatibility = JavaVersion.VERSION_17
27 | }
28 | kotlinOptions {
29 | jvmTarget = JavaVersion.VERSION_17.toString()
30 | }
31 | kotlin {
32 | jvmToolchain(17)
33 | }
34 | }
35 |
36 | dependencies {
37 | implementation(libs.androidx.core.ktx)
38 | implementation("net.java.dev.jna:jna:5.14.0@aar")
39 | implementation(libs.kotlinx.coroutines.core)
40 | }
--------------------------------------------------------------------------------
/uniffi/consumer-rules.pro:
--------------------------------------------------------------------------------
1 | -dontwarn java.awt.Component
2 | -dontwarn java.awt.GraphicsEnvironment
3 | -dontwarn java.awt.HeadlessException
4 | -dontwarn java.awt.Window
5 |
6 | -keep class com.sun.jna.** { *; }
7 | -keep class uniffi.ruslin.** { *; }
8 |
--------------------------------------------------------------------------------
/uniffi/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/uniffi/src/androidTest/java/uniffi/ruslin/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package uniffi.ruslin
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("uniffi.ruslin.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/uniffi/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/uniffi/src/main/java/uniffi/ruslin/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruslin-note/ruslin-android/564f1d4500cccd3e07c6bc5a0bbf15072047b631/uniffi/src/main/java/uniffi/ruslin/.gitkeep
--------------------------------------------------------------------------------
/uniffi/src/test/java/uniffi/ruslin/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package uniffi.ruslin
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------