├── .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 | notes 6 | folders 7 | editor 8 | search 9 | account 10 |
11 |
12 |
13 | 14 | 🚧 目前处于 Pre-alpha 阶段,不建议在生产环境使用,请注意做好备份。 🚧 15 | 16 | 已支持的功能: 17 | 18 | - ✅ 支持 Markdown 编辑和预览 19 | - ✅ 使用 jieba-rs 完成的全文搜索(支持中文和英文) 20 | - ✅ 使用自部署的 Joplin 服务器同步笔记 21 | - ✅ 手动和自动同步 22 | - 🚧 可能兼容 Joplin 的同步格式(不支持端到端加密) 23 | 24 | ## 下载 25 | 26 | [Get it on F-Droid](https://f-droid.org/packages/org.dianqk.ruslin/) 29 | [Get it on Google Play](https://play.google.com/store/apps/details?id=org.dianqk.ruslin) 32 | [Get it on GitHub](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 | } --------------------------------------------------------------------------------