├── .github ├── readme_image │ └── Screenshot_20201110-105619.png └── workflows │ ├── pre-release.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── fishhawk │ │ └── lisu │ │ └── ExampleInstrumentedTest.kt │ ├── debug │ └── res │ │ ├── values-zh-rCN │ │ └── strints.xml │ │ └── values │ │ └── strings.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── fishhawk │ │ │ └── lisu │ │ │ ├── LisuApplication.kt │ │ │ ├── data │ │ │ ├── LoremIpsum.kt │ │ │ ├── database │ │ │ │ ├── ApplicationDatebase.kt │ │ │ │ ├── MangaSettingRepository.kt │ │ │ │ ├── ReadingHistoryRepository.kt │ │ │ │ ├── SearchHistoryRepository.kt │ │ │ │ ├── ServerHistoryRepository.kt │ │ │ │ ├── dao │ │ │ │ │ ├── MangaSettingDao.kt │ │ │ │ │ ├── ReadingHistoryDao.kt │ │ │ │ │ ├── SearchHistoryDao.kt │ │ │ │ │ └── ServerHistoryDao.kt │ │ │ │ └── model │ │ │ │ │ ├── MangaSetting.kt │ │ │ │ │ ├── ReadingHistory.kt │ │ │ │ │ ├── SearchHistory.kt │ │ │ │ │ └── ServerHistory.kt │ │ │ ├── datastore │ │ │ │ ├── Preference.kt │ │ │ │ ├── PreferenceRepository.kt │ │ │ │ └── ProviderBrowseHistoryRepository.kt │ │ │ └── network │ │ │ │ ├── GitHubRepository.kt │ │ │ │ ├── LisuRepository.kt │ │ │ │ ├── base │ │ │ │ ├── Connectivity.kt │ │ │ │ ├── RemoteData.kt │ │ │ │ └── RemoteList.kt │ │ │ │ ├── dao │ │ │ │ ├── LisuDownloadDao.kt │ │ │ │ ├── LisuLibraryDao.kt │ │ │ │ └── LisuProviderDao.kt │ │ │ │ └── model │ │ │ │ ├── CommentDto.kt │ │ │ │ ├── DownloadTaskDto.kt │ │ │ │ ├── GitHubRelease.kt │ │ │ │ ├── MangaDto.kt │ │ │ │ ├── MetadataDto.kt │ │ │ │ └── ProviderDto.kt │ │ │ ├── notification │ │ │ ├── AppUpdateNotification.kt │ │ │ ├── NotificationReceiver.kt │ │ │ └── Notifications.kt │ │ │ ├── ui │ │ │ ├── base │ │ │ │ ├── BaseActivity.kt │ │ │ │ └── BaseViewModel.kt │ │ │ ├── download │ │ │ │ ├── DownloadScreen.kt │ │ │ │ └── DownloadViewModel.kt │ │ │ ├── explore │ │ │ │ ├── ExploreScreen.kt │ │ │ │ ├── ExploreViewModel.kt │ │ │ │ └── LoginScreen.kt │ │ │ ├── gallery │ │ │ │ ├── GalleryCommentScreen.kt │ │ │ │ ├── GalleryCoverSheet.kt │ │ │ │ ├── GalleryEditScreen.kt │ │ │ │ ├── GalleryScreen.kt │ │ │ │ ├── GalleryViewModel.kt │ │ │ │ ├── MangaContent.kt │ │ │ │ └── MangaHeader.kt │ │ │ ├── globalsearch │ │ │ │ ├── GlobalSearchScreen.kt │ │ │ │ └── GlobalSearchViewModel.kt │ │ │ ├── history │ │ │ │ ├── HistoryScreen.kt │ │ │ │ └── HistoryViewModel.kt │ │ │ ├── library │ │ │ │ ├── LibraryScreen.kt │ │ │ │ └── LibraryViewModel.kt │ │ │ ├── main │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ └── NavigateHelper.kt │ │ │ ├── more │ │ │ │ ├── MoreScreen.kt │ │ │ │ ├── MoreViewModel.kt │ │ │ │ ├── OpenSourceLicenseScreen.kt │ │ │ │ ├── Preference.kt │ │ │ │ └── SettingScreen.kt │ │ │ ├── provider │ │ │ │ ├── ProviderScreen.kt │ │ │ │ └── ProviderViewModel.kt │ │ │ ├── reader │ │ │ │ ├── ReaderActivity.kt │ │ │ │ ├── ReaderMenu.kt │ │ │ │ ├── ReaderOverlaySheet.kt │ │ │ │ ├── ReaderPageSheet.kt │ │ │ │ ├── ReaderScreen.kt │ │ │ │ ├── ReaderSettingsSheet.kt │ │ │ │ ├── ReaderViewModel.kt │ │ │ │ └── viewer │ │ │ │ │ ├── NestedScroll.kt │ │ │ │ │ ├── Page.kt │ │ │ │ │ ├── PagerViewer.kt │ │ │ │ │ ├── ViewerState.kt │ │ │ │ │ └── WebtoonViewer.kt │ │ │ └── theme │ │ │ │ ├── Colors.kt │ │ │ │ └── Theme.kt │ │ │ ├── util │ │ │ ├── ContextExtension.kt │ │ │ ├── DateExtension.kt │ │ │ ├── FileExtension.kt │ │ │ ├── FlowExtension.kt │ │ │ └── interceptor │ │ │ │ ├── ProgressInterceptor.kt │ │ │ │ └── ProgressResponseBody.kt │ │ │ └── widget │ │ │ ├── BottomSheetItem.kt │ │ │ ├── LisuDialog.kt │ │ │ ├── LisuListItem.kt │ │ │ ├── LisuScaffold.kt │ │ │ ├── LisuSearchToolBar.kt │ │ │ ├── LisuSlider.kt │ │ │ ├── LisuToolBar.kt │ │ │ ├── MangaList.kt │ │ │ ├── OverflowMenuButton.kt │ │ │ ├── StateView.kt │ │ │ ├── TooltipIconButton.kt │ │ │ ├── VerticalGrid.kt │ │ │ └── m3 │ │ │ └── Switch.kt │ └── res │ │ ├── drawable │ │ ├── ic_baseline_close_24.xml │ │ ├── ic_baseline_refresh_24.xml │ │ └── ic_baseline_system_update_alt_24.xml │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ └── ic_launcher_adaptive_fore.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ └── ic_launcher_adaptive_fore.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ └── ic_launcher_adaptive_fore.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ └── ic_launcher_adaptive_fore.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_adaptive_back.png │ │ └── ic_launcher_adaptive_fore.png │ │ ├── values-zh-rCN │ │ └── strints.xml │ │ ├── values │ │ └── strings.xml │ │ └── xml │ │ └── provider_path.xml │ └── test │ └── java │ └── com │ └── fishhawk │ └── lisu │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/readme_image/Screenshot_20201110-105619.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/.github/readme_image/Screenshot_20201110-105619.png -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: pre-release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | name: Build app 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Clone repo 18 | uses: actions/checkout@v3 19 | 20 | - name: Validate Gradle Wrapper 21 | uses: gradle/wrapper-validation-action@v1 22 | 23 | - name: Set up JDK 11 24 | uses: actions/setup-java@v3 25 | with: 26 | java-version: 11 27 | distribution: adopt 28 | 29 | - name: Build app and run unit tests 30 | uses: gradle/gradle-command-action@v2 31 | with: 32 | arguments: assembleRelease 33 | 34 | - name: Sign APK 35 | uses: r0adkll/sign-android-release@v1 36 | with: 37 | releaseDirectory: app/build/outputs/apk/release 38 | signingKeyBase64: ${{ secrets.SIGNING_KEY }} 39 | alias: ${{ secrets.ALIAS }} 40 | keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} 41 | keyPassword: ${{ secrets.KEY_PASSWORD }} 42 | 43 | - name: Retrieve version 44 | run: | 45 | set -x 46 | echo "VERSION=v$(grep "versionName" app/build.gradle.kts | awk '{print $3}' | tr -d '"')" >> $GITHUB_ENV 47 | 48 | - name: Clean up build artifacts 49 | run: | 50 | set -e 51 | 52 | mv app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk lisu-${{ env.VERSION }}.apk 53 | sha=`sha256sum lisu-${{ env.VERSION }}.apk | awk '{ print $1 }'` 54 | echo "APK_UNIVERSAL_SHA=$sha" >> $GITHUB_ENV 55 | 56 | cp app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk lisu-arm64-v8a-${{ env.VERSION }}.apk 57 | sha=`sha256sum lisu-arm64-v8a-${{ env.VERSION }}.apk | awk '{ print $1 }'` 58 | echo "APK_ARM64_V8A_SHA=$sha" >> $GITHUB_ENV 59 | 60 | cp app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk lisu-armeabi-v7a-${{ env.VERSION }}.apk 61 | sha=`sha256sum lisu-armeabi-v7a-${{ env.VERSION }}.apk | awk '{ print $1 }'` 62 | echo "APK_ARMEABI_V7A_SHA=$sha" >> $GITHUB_ENV 63 | 64 | cp app/build/outputs/apk/release/app-x86-release-unsigned-signed.apk lisu-x86-${{ env.VERSION }}.apk 65 | sha=`sha256sum lisu-x86-${{ env.VERSION }}.apk | awk '{ print $1 }'` 66 | echo "APK_X86_SHA=$sha" >> $GITHUB_ENV 67 | 68 | cp app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk lisu-x86_64-${{ env.VERSION }}.apk 69 | sha=`sha256sum lisu-x86_64-${{ env.VERSION }}.apk | awk '{ print $1 }'` 70 | echo "APK_X86_64_SHA=$sha" >> $GITHUB_ENV 71 | 72 | - name: Delete old pre-release 73 | uses: dev-drprasad/delete-tag-and-release@v0.2.0 74 | with: 75 | delete_release: true 76 | tag_name: latest 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | - name: Pre-release 81 | uses: softprops/action-gh-release@v1 82 | with: 83 | tag_name: latest 84 | name: Lisu-android ${{ env.VERSION }}-SNAPSHOT 85 | body: | 86 | ### Checksums 87 | | Variant | SHA-256 | 88 | | ------- | ------- | 89 | | Universal | ${{ env.APK_UNIVERSAL_SHA }} 90 | | arm64-v8a | ${{ env.APK_ARM64_V8A_SHA }} 91 | | armeabi-v7a | ${{ env.APK_ARMEABI_V7A_SHA }} 92 | | x86 | ${{ env.APK_X86_SHA }} | 93 | | x86_64 | ${{ env.APK_X86_64_SHA }} | 94 | files: | 95 | lisu-${{ env.VERSION }}.apk 96 | lisu-arm64-v8a-${{ env.VERSION }}.apk 97 | lisu-armeabi-v7a-${{ env.VERSION }}.apk 98 | lisu-x86-${{ env.VERSION }}.apk 99 | lisu-x86_64-${{ env.VERSION }}.apk 100 | draft: false 101 | prerelease: true 102 | env: 103 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - v* 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | name: Build app 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Clone repo 20 | uses: actions/checkout@v3 21 | 22 | - name: Validate Gradle Wrapper 23 | uses: gradle/wrapper-validation-action@v1 24 | 25 | - name: Set up JDK 11 26 | uses: actions/setup-java@v3 27 | with: 28 | java-version: 11 29 | distribution: adopt 30 | 31 | - name: Build app and run unit tests 32 | uses: gradle/gradle-command-action@v2 33 | with: 34 | arguments: assembleRelease 35 | 36 | - name: Sign APK 37 | if: startsWith(github.ref, 'refs/tags/') 38 | uses: r0adkll/sign-android-release@v1 39 | with: 40 | releaseDirectory: app/build/outputs/apk/release 41 | signingKeyBase64: ${{ secrets.SIGNING_KEY }} 42 | alias: ${{ secrets.ALIAS }} 43 | keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} 44 | keyPassword: ${{ secrets.KEY_PASSWORD }} 45 | 46 | - name: Retrieve version 47 | if: startsWith(github.ref, 'refs/tags/') 48 | run: | 49 | set -x 50 | echo "VERSION=v$(grep "versionName" app/build.gradle.kts | awk '{print $3}' | tr -d '"')" >> $GITHUB_ENV 51 | 52 | - name: Clean up build artifacts 53 | if: startsWith(github.ref, 'refs/tags/') 54 | run: | 55 | set -e 56 | 57 | mv app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk lisu-${{ env.VERSION }}.apk 58 | sha=`sha256sum lisu-${{ env.VERSION }}.apk | awk '{ print $1 }'` 59 | echo "APK_UNIVERSAL_SHA=$sha" >> $GITHUB_ENV 60 | 61 | cp app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk lisu-arm64-v8a-${{ env.VERSION }}.apk 62 | sha=`sha256sum lisu-arm64-v8a-${{ env.VERSION }}.apk | awk '{ print $1 }'` 63 | echo "APK_ARM64_V8A_SHA=$sha" >> $GITHUB_ENV 64 | 65 | cp app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk lisu-armeabi-v7a-${{ env.VERSION }}.apk 66 | sha=`sha256sum lisu-armeabi-v7a-${{ env.VERSION }}.apk | awk '{ print $1 }'` 67 | echo "APK_ARMEABI_V7A_SHA=$sha" >> $GITHUB_ENV 68 | 69 | cp app/build/outputs/apk/release/app-x86-release-unsigned-signed.apk lisu-x86-${{ env.VERSION }}.apk 70 | sha=`sha256sum lisu-x86-${{ env.VERSION }}.apk | awk '{ print $1 }'` 71 | echo "APK_X86_SHA=$sha" >> $GITHUB_ENV 72 | 73 | cp app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk lisu-x86_64-${{ env.VERSION }}.apk 74 | sha=`sha256sum lisu-x86_64-${{ env.VERSION }}.apk | awk '{ print $1 }'` 75 | echo "APK_X86_64_SHA=$sha" >> $GITHUB_ENV 76 | 77 | - name: Update latest tag 78 | if: startsWith(github.ref, 'refs/tags/') 79 | run: | 80 | git tag -d latest || true 81 | git push origin :refs/tags/latest || true 82 | git tag latest 83 | git push origin latest 84 | 85 | - name: Pre-release 86 | if: startsWith(github.ref, 'refs/tags/') 87 | uses: softprops/action-gh-release@v1 88 | with: 89 | tag_name: latest 90 | name: Lisu-android ${{ env.VERSION }} 91 | body: | 92 | ### Checksums 93 | | Variant | SHA-256 | 94 | | ------- | ------- | 95 | | Universal | ${{ env.APK_UNIVERSAL_SHA }} 96 | | arm64-v8a | ${{ env.APK_ARM64_V8A_SHA }} 97 | | armeabi-v7a | ${{ env.APK_ARMEABI_V7A_SHA }} 98 | | x86 | ${{ env.APK_X86_SHA }} | 99 | | x86_64 | ${{ env.APK_X86_64_SHA }} | 100 | files: | 101 | lisu-${{ env.VERSION }}.apk 102 | lisu-arm64-v8a-${{ env.VERSION }}.apk 103 | lisu-armeabi-v7a-${{ env.VERSION }}.apk 104 | lisu-x86-${{ env.VERSION }}.apk 105 | lisu-x86_64-${{ env.VERSION }}.apk 106 | draft: true 107 | prerelease: false 108 | env: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 栗鼠 Android 2 | 3 | >深挖洞,广积粮,做一只快乐的栗鼠。 4 | 5 | 栗鼠是一个免费的漫画库管理系统。你可以浏览各种来源里的漫画,将感兴趣的添加到库中。栗鼠会自动把漫画内容同步到你的磁盘上。 6 | 7 | 这个仓库包含栗鼠 Android 客户端的代码。 8 | 9 | - Server:https://github.com/FishHawk/lisu 10 | - Android:https://github.com/FishHawk/lisu-android(你在这) 11 | 12 | 13 | 14 | ![Screenshot_20201110-105619_c](.github/readme_image/Screenshot_20201110-105619.png) 15 | 16 | ## 特点 17 | 18 | - 利用[ComicImageView](https://github.com/FishHawk/ComicImageView)减轻了漫画网点缩放后产生的摩尔纹。 19 | - 支持在多个服务器之间切换。 20 | - 支持深色主题。 21 | 22 | ## 高级用法 23 | 24 | ### mDNS自动发现 25 | 26 | 对于linux系统,创建`/etc/avahi/services/lisu.service`文件。内容如下: 27 | 28 | ```xml 29 | 30 | 31 | 32 | lisu %h 33 | 34 | _lisu._tcp 35 | 8080 36 | 37 | 38 | ``` 39 | 40 | 创建后,服务端会启动mDNS服务,从而让客户端能够自动发现lisu服务。服务名称和端口号可以自行修改。 41 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | *.iml 3 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("plugin.serialization") version "1.8.10" 3 | id("com.android.application") version "8.0.0-beta04" 4 | id("com.google.devtools.ksp") version "1.8.10-1.0.9" 5 | id("org.jetbrains.kotlin.android") version "1.8.10" 6 | id("org.jetbrains.kotlin.plugin.parcelize") version "1.8.0" 7 | id("com.mikepenz.aboutlibraries.plugin") version "10.3.0" 8 | } 9 | 10 | android { 11 | namespace = "com.fishhawk.lisu" 12 | compileSdk = 33 13 | 14 | defaultConfig { 15 | applicationId = "com.fishhawk.lisu" 16 | minSdk = 21 17 | targetSdk = 33 18 | versionCode = 5 19 | versionName = "0.0.1" 20 | 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | multiDexEnabled = true 23 | 24 | // ndk { 25 | // abiFilters += listOf("arm64-v8a", "armeabi-v7a") 26 | // } 27 | } 28 | 29 | splits { 30 | abi { 31 | isEnable = true 32 | reset() 33 | include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") 34 | isUniversalApk = true 35 | } 36 | } 37 | 38 | buildTypes { 39 | debug { 40 | applicationIdSuffix = ".debug" 41 | } 42 | release { 43 | isMinifyEnabled = false 44 | proguardFiles( 45 | getDefaultProguardFile("proguard-android-optimize.txt"), 46 | "proguard-rules.pro" 47 | ) 48 | } 49 | } 50 | 51 | buildFeatures { 52 | compose = true 53 | } 54 | 55 | compileOptions { 56 | isCoreLibraryDesugaringEnabled = true 57 | sourceCompatibility = JavaVersion.VERSION_1_8 58 | targetCompatibility = JavaVersion.VERSION_1_8 59 | } 60 | 61 | kotlin { 62 | jvmToolchain(8) 63 | } 64 | 65 | kotlinOptions { 66 | jvmTarget = "1.8" 67 | } 68 | 69 | composeOptions { 70 | kotlinCompilerExtensionVersion = "1.4.3" 71 | } 72 | } 73 | 74 | dependencies { 75 | coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.2") 76 | 77 | implementation("androidx.activity:activity-compose:1.7.0") 78 | 79 | val composeVersion = "1.4.0-rc01" 80 | implementation("androidx.compose.ui:ui:$composeVersion") 81 | implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") 82 | implementation("androidx.compose.material:material-icons-extended:$composeVersion") 83 | implementation("androidx.compose.material3:material3:1.1.0-alpha08") 84 | debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion") 85 | 86 | implementation("androidx.datastore:datastore-preferences:1.0.0") 87 | implementation("androidx.lifecycle:lifecycle-view-model-compose:2.6.1") 88 | implementation("androidx.navigation:navigation-compose:2.5.2") 89 | 90 | val roomVersion = "2.5.0" 91 | implementation("androidx.room:room-runtime:$roomVersion") 92 | implementation("androidx.room:room-ktx:$roomVersion") 93 | ksp("androidx.room:room-compiler:$roomVersion") 94 | 95 | val koinVersion = "3.2.2" 96 | implementation("io.insert-koin:koin-core:$koinVersion") 97 | implementation("io.insert-koin:koin-android:$koinVersion") 98 | implementation("io.insert-koin:koin-androidx-compose:3.2.1") 99 | 100 | val accompanistVersion = "0.29.2-rc" 101 | implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion") 102 | implementation("com.google.accompanist:accompanist-placeholder-material:$accompanistVersion") 103 | implementation("com.google.accompanist:accompanist-swiperefresh:$accompanistVersion") 104 | implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion") 105 | implementation("com.google.accompanist:accompanist-webview:$accompanistVersion") 106 | 107 | val coilVersion = "2.2.2" 108 | implementation("io.coil-kt:coil:$coilVersion") 109 | implementation("io.coil-kt:coil-compose:$coilVersion") 110 | implementation("io.coil-kt:coil-gif:$coilVersion") 111 | 112 | val ktorVersion = "2.2.4" 113 | implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") 114 | implementation("io.ktor:ktor-client-core:$ktorVersion") 115 | implementation("io.ktor:ktor-client-okhttp:$ktorVersion") 116 | implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") 117 | implementation("io.ktor:ktor-client-resources:$ktorVersion") 118 | 119 | // Licenses 120 | implementation("com.mikepenz:aboutlibraries-compose:10.6.1") 121 | 122 | // UI Tests 123 | testImplementation("junit:junit:4.13.2") 124 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 125 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") 126 | androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.1") 127 | 128 | // implementation("com.quickbirdstudios:opencv:4.1.0") 129 | } -------------------------------------------------------------------------------- /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.kts. 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/com/fishhawk/lisu/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 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("com.fishhawk.driftinglibraryandroid", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/debug/res/values-zh-rCN/strints.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 栗鼠Debug 4 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lisu Debug 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 21 | 22 | 27 | 30 | 31 | 32 | 35 | 36 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/LisuApplication.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu 2 | 3 | import android.app.Application 4 | import android.os.Build.VERSION.SDK_INT 5 | import androidx.room.Room 6 | import coil.ImageLoader 7 | import coil.ImageLoaderFactory 8 | import coil.decode.GifDecoder 9 | import coil.decode.ImageDecoderDecoder 10 | import com.fishhawk.lisu.data.database.* 11 | import com.fishhawk.lisu.data.datastore.PreferenceRepository 12 | import com.fishhawk.lisu.data.datastore.ProviderBrowseHistoryRepository 13 | import com.fishhawk.lisu.data.network.GitHubRepository 14 | import com.fishhawk.lisu.data.network.LisuRepository 15 | import com.fishhawk.lisu.data.network.base.Connectivity 16 | import com.fishhawk.lisu.notification.Notifications 17 | import com.fishhawk.lisu.ui.download.DownloadViewModel 18 | import com.fishhawk.lisu.ui.explore.ExploreViewModel 19 | import com.fishhawk.lisu.ui.gallery.GalleryViewModel 20 | import com.fishhawk.lisu.ui.globalsearch.GlobalSearchViewModel 21 | import com.fishhawk.lisu.ui.history.HistoryViewModel 22 | import com.fishhawk.lisu.ui.library.LibraryViewModel 23 | import com.fishhawk.lisu.ui.main.MainViewModel 24 | import com.fishhawk.lisu.ui.more.MoreViewModel 25 | import com.fishhawk.lisu.ui.provider.ProviderViewModel 26 | import com.fishhawk.lisu.ui.reader.ReaderViewModel 27 | import com.fishhawk.lisu.util.interceptor.ProgressInterceptor 28 | import okhttp3.OkHttpClient 29 | import org.koin.android.ext.koin.androidApplication 30 | import org.koin.android.ext.koin.androidContext 31 | import org.koin.androidx.viewmodel.dsl.viewModel 32 | import org.koin.core.component.KoinComponent 33 | import org.koin.core.component.inject 34 | import org.koin.core.context.startKoin 35 | import org.koin.core.qualifier.named 36 | import org.koin.dsl.module 37 | 38 | lateinit var PR: PreferenceRepository 39 | 40 | @Suppress("unused") 41 | class LisuApplication : Application(), ImageLoaderFactory { 42 | override fun onCreate() { 43 | super.onCreate() 44 | startKoin { 45 | androidContext(this@LisuApplication) 46 | modules(appModule) 47 | } 48 | 49 | PR = object : KoinComponent { 50 | val pr by inject() 51 | }.pr 52 | 53 | Notifications.createChannels(this) 54 | } 55 | 56 | override fun newImageLoader(): ImageLoader { 57 | return ImageLoader 58 | .Builder(applicationContext) 59 | .respectCacheHeaders(false) 60 | .components { 61 | if (SDK_INT >= 28) { 62 | add(ImageDecoderDecoder.Factory()) 63 | } else { 64 | add(GifDecoder.Factory()) 65 | } 66 | } 67 | .okHttpClient { 68 | OkHttpClient.Builder() 69 | .addInterceptor(ProgressInterceptor()) 70 | .build() 71 | } 72 | .build() 73 | } 74 | } 75 | 76 | val appModule = module { 77 | single { Connectivity(get()) } 78 | 79 | single { PreferenceRepository(androidApplication()) } 80 | single { ProviderBrowseHistoryRepository(androidApplication()) } 81 | 82 | single(named("address")) { 83 | get().serverAddress.flow 84 | } 85 | 86 | single { LisuRepository(get(), get(named("address"))) } 87 | single { GitHubRepository() } 88 | 89 | single { 90 | Room.databaseBuilder( 91 | androidApplication(), 92 | ApplicationDatabase::class.java, 93 | "Test.db" 94 | ).build() 95 | } 96 | 97 | single { MangaSettingRepository(get().mangaSettingDao()) } 98 | single { ReadingHistoryRepository(get().readingHistoryDao()) } 99 | single { SearchHistoryRepository(get().searchHistoryDao()) } 100 | single { ServerHistoryRepository(get().serverHistoryDao()) } 101 | 102 | single { MainViewModel(get()) } 103 | 104 | viewModel { LibraryViewModel(get(), get()) } 105 | viewModel { HistoryViewModel(get()) } 106 | viewModel { ExploreViewModel(get()) } 107 | viewModel { MoreViewModel(get()) } 108 | 109 | viewModel { DownloadViewModel(get()) } 110 | viewModel { GlobalSearchViewModel(get(), get(), get()) } 111 | viewModel { ProviderViewModel(get(), get(), get(), get()) } 112 | viewModel { GalleryViewModel(get(), get(), get()) } 113 | 114 | viewModel { ReaderViewModel(get(), get(), get(), get()) } 115 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/LoremIpsum.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data 2 | 3 | import androidx.compose.ui.tooling.preview.datasource.LoremIpsum 4 | import com.fishhawk.lisu.data.network.model.* 5 | import java.time.Instant 6 | import java.time.LocalDate 7 | import kotlin.random.Random 8 | 9 | private val LOREM_IPSUM_SOURCE = """ 10 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sodales 11 | laoreet commodo. Phasellus a purus eu risus elementum consequat. Aenean eu 12 | elit ut nunc convallis laoreet non ut libero. Suspendisse interdum placerat 13 | risus vel ornare. Donec vehicula, turpis sed consectetur ullamcorper, ante 14 | nunc egestas quam, ultricies adipiscing velit enim at nunc. Aenean id diam 15 | neque. Praesent ut lacus sed justo viverra fermentum et ut sem. Fusce 16 | convallis gravida lacinia. Integer semper dolor ut elit sagittis lacinia. 17 | Praesent sodales scelerisque eros at rhoncus. Duis posuere sapien vel ipsum 18 | ornare interdum at eu quam. Vestibulum vel massa erat. Aenean quis sagittis 19 | purus. Phasellus arcu purus, rutrum id consectetur non, bibendum at nibh. 20 | 21 | Duis nec erat dolor. Nulla vitae consectetur ligula. Quisque nec mi est. Ut 22 | quam ante, rutrum at pellentesque gravida, pretium in dui. Cras eget sapien 23 | velit. Suspendisse ut sem nec tellus vehicula eleifend sit amet quis velit. 24 | Phasellus quis suscipit nisi. Nam elementum malesuada tincidunt. Curabitur 25 | iaculis pretium eros, malesuada faucibus leo eleifend a. Curabitur congue 26 | orci in neque euismod a blandit libero vehicula. 27 | """.trim().split(" ") 28 | 29 | object LoremIpsum { 30 | fun words(size: Int): String { 31 | var wordsUsed = 0 32 | val loremIpsumMaxSize = LOREM_IPSUM_SOURCE.size 33 | return generateSequence { 34 | LOREM_IPSUM_SOURCE[wordsUsed++ % loremIpsumMaxSize] 35 | }.take(size).joinToString(" ") 36 | } 37 | 38 | fun username() = words(2) 39 | fun cover() = "https://api.lorem.space/image/book" 40 | fun date() = Instant.now().epochSecond 41 | fun id() = Random.nextInt(10000, 99999).toString() 42 | 43 | fun mangaDetail() = MangaDetailDto( 44 | state = MangaState.Remote, 45 | providerId = words(1), 46 | id = words(1), 47 | cover = cover(), 48 | updateTime = date(), 49 | title = words(3), 50 | authors = words(2).split(" "), 51 | isFinished = false, 52 | description = words(20), 53 | tags = mapOf("" to words(4).split(" ")), 54 | collections = mapOf(), 55 | chapterPreviews = emptyList(), 56 | ) 57 | 58 | fun comment() = CommentDto( 59 | username = username(), 60 | content = words(20), 61 | createTime = date(), 62 | vote = (-100..200).random(), 63 | ) 64 | 65 | fun provider() = ProviderDto( 66 | id = "漫画人" + id(), 67 | lang = listOf("zh", "en").random(), 68 | icon = cover(), 69 | boardModels = emptyMap(), 70 | isLogged = listOf(true, false, null).random(), 71 | ) 72 | 73 | fun mangaDownloadTask() = MangaDownloadTask( 74 | providerId = "漫画人", 75 | mangaId = id(), 76 | cover = cover(), 77 | title = "龙珠", 78 | chapterTasks = emptyList(), 79 | ) 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/ApplicationDatebase.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverter 6 | import androidx.room.TypeConverters 7 | import com.fishhawk.lisu.data.database.dao.MangaSettingDao 8 | import com.fishhawk.lisu.data.database.dao.ReadingHistoryDao 9 | import com.fishhawk.lisu.data.database.dao.SearchHistoryDao 10 | import com.fishhawk.lisu.data.database.dao.ServerHistoryDao 11 | import com.fishhawk.lisu.data.database.model.MangaSetting 12 | import com.fishhawk.lisu.data.database.model.ReadingHistory 13 | import com.fishhawk.lisu.data.database.model.SearchHistory 14 | import com.fishhawk.lisu.data.database.model.ServerHistory 15 | import java.time.Instant 16 | import java.time.LocalDateTime 17 | import java.time.ZoneId 18 | 19 | class LocalDateTimeConverters { 20 | @TypeConverter 21 | fun toLocalDateTime(milli: Long): LocalDateTime = 22 | Instant.ofEpochMilli(milli).atZone(ZoneId.systemDefault()).toLocalDateTime() 23 | 24 | @TypeConverter 25 | fun fromLocalDateTime(dateTime: LocalDateTime): Long = 26 | dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() 27 | } 28 | 29 | @Database( 30 | entities = [ 31 | MangaSetting::class, 32 | ReadingHistory::class, 33 | SearchHistory::class, 34 | ServerHistory::class 35 | ], 36 | version = 1, 37 | exportSchema = false 38 | ) 39 | @TypeConverters(LocalDateTimeConverters::class) 40 | abstract class ApplicationDatabase : RoomDatabase() { 41 | abstract fun mangaSettingDao(): MangaSettingDao 42 | abstract fun readingHistoryDao(): ReadingHistoryDao 43 | abstract fun searchHistoryDao(): SearchHistoryDao 44 | abstract fun serverHistoryDao(): ServerHistoryDao 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/MangaSettingRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database 2 | 3 | import com.fishhawk.lisu.data.database.dao.MangaSettingDao 4 | import com.fishhawk.lisu.data.database.model.MangaSetting 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.emitAll 7 | import kotlinx.coroutines.flow.transformLatest 8 | 9 | class MangaSettingRepository(private val dao: MangaSettingDao) { 10 | fun select(providerId: String, mangaId: String, title: String?): Flow = 11 | dao.select(providerId, mangaId) 12 | .transformLatest { 13 | if (it != null || title.isNullOrBlank()) emit(it) 14 | else emitAll(dao.selectMostSimilar(title)) 15 | } 16 | 17 | suspend fun update(history: MangaSetting) = dao.insertOrUpdate(history) 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/ReadingHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database 2 | 3 | import com.fishhawk.lisu.data.database.dao.ReadingHistoryDao 4 | import com.fishhawk.lisu.data.database.model.ReadingHistory 5 | 6 | class ReadingHistoryRepository(private val dao: ReadingHistoryDao) { 7 | 8 | fun list() = dao.list() 9 | 10 | fun select(mangaId: String) = dao.select(mangaId) 11 | 12 | suspend fun update(history: ReadingHistory) = dao.updateOrInsert(history) 13 | 14 | suspend fun delete(history: ReadingHistory) = dao.delete(history) 15 | 16 | suspend fun clear() = dao.clear() 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/SearchHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database 2 | 3 | import com.fishhawk.lisu.data.database.dao.SearchHistoryDao 4 | import com.fishhawk.lisu.data.database.model.SearchHistory 5 | 6 | class SearchHistoryRepository(private val dao: SearchHistoryDao) { 7 | 8 | fun list() = dao.list() 9 | 10 | fun listByProvider(providerId: String) = dao.listByProvider(providerId) 11 | 12 | suspend fun update(history: SearchHistory) = dao.insert(history) 13 | 14 | suspend fun deleteByKeywords(providerId: String, keywords: String) = 15 | dao.deleteByKeywords(providerId, keywords) 16 | 17 | suspend fun clear() = dao.clear() 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/ServerHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database 2 | 3 | import com.fishhawk.lisu.data.database.dao.ServerHistoryDao 4 | import com.fishhawk.lisu.data.database.model.ServerHistory 5 | 6 | class ServerHistoryRepository(private val dao: ServerHistoryDao) { 7 | 8 | fun list() = dao.list() 9 | 10 | suspend fun update(history: ServerHistory) = dao.insert(history) 11 | 12 | suspend fun deleteByAddress(address: String) = dao.deleteByAddress(address) 13 | 14 | suspend fun clear() = dao.clear() 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/dao/MangaSettingDao.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.dao 2 | 3 | import androidx.room.* 4 | import com.fishhawk.lisu.data.database.model.MangaSetting 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | @Dao 8 | interface MangaSettingDao { 9 | @Query("SELECT * FROM MangaSetting WHERE providerId = :providerId AND mangaId = :mangaId") 10 | fun select(providerId: String, mangaId: String): Flow 11 | 12 | @Query("SELECT * FROM MangaSetting WHERE title = :title") 13 | fun selectMostSimilar(title: String): Flow 14 | 15 | @Insert(onConflict = OnConflictStrategy.REPLACE) 16 | suspend fun insertOrUpdate(setting: MangaSetting) 17 | } 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/dao/ReadingHistoryDao.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.dao 2 | 3 | import androidx.room.* 4 | import com.fishhawk.lisu.data.database.model.ReadingHistory 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | @Dao 8 | interface ReadingHistoryDao { 9 | @Query("SELECT * FROM ReadingHistory ORDER BY date DESC") 10 | fun list(): Flow> 11 | 12 | @Query("SELECT * FROM ReadingHistory WHERE mangaId = :mangaId") 13 | fun select(mangaId: String): Flow 14 | 15 | @Insert(onConflict = OnConflictStrategy.REPLACE) 16 | suspend fun updateOrInsert(history: ReadingHistory) 17 | 18 | @Delete 19 | suspend fun delete(history: ReadingHistory) 20 | 21 | @Query("DELETE FROM ReadingHistory") 22 | suspend fun clear() 23 | } 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/dao/SearchHistoryDao.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.fishhawk.lisu.data.database.model.SearchHistory 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | @Dao 11 | interface SearchHistoryDao { 12 | @Query("SELECT * FROM SearchHistory ORDER BY date DESC") 13 | fun list(): Flow> 14 | 15 | @Query("SELECT * FROM SearchHistory WHERE providerId = :providerId ORDER BY date DESC") 16 | fun listByProvider(providerId: String): Flow> 17 | 18 | @Insert(onConflict = OnConflictStrategy.REPLACE) 19 | suspend fun insert(history: SearchHistory) 20 | 21 | @Query("DELETE FROM SearchHistory WHERE providerId = :providerId AND keywords = :keywords") 22 | suspend fun deleteByKeywords(providerId: String, keywords: String) 23 | 24 | @Query("DELETE FROM SearchHistory") 25 | suspend fun clear() 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/dao/ServerHistoryDao.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.fishhawk.lisu.data.database.model.ServerHistory 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | @Dao 11 | interface ServerHistoryDao { 12 | @Query("SELECT * FROM ServerHistory ORDER BY date DESC") 13 | fun list(): Flow> 14 | 15 | @Insert(onConflict = OnConflictStrategy.REPLACE) 16 | suspend fun insert(history: ServerHistory) 17 | 18 | @Query("DELETE FROM ServerHistory WHERE address = :address") 19 | suspend fun deleteByAddress(address: String) 20 | 21 | @Query("DELETE FROM ServerHistory") 22 | suspend fun clear() 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/model/MangaSetting.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.model 2 | 3 | import androidx.room.Entity 4 | import com.fishhawk.lisu.data.datastore.ReaderMode 5 | import com.fishhawk.lisu.data.datastore.ReaderOrientation 6 | 7 | @Entity(primaryKeys = ["providerId", "mangaId"]) 8 | data class MangaSetting( 9 | val providerId: String, 10 | val mangaId: String, 11 | val title: String?, 12 | val readerMode: ReaderMode? = null, 13 | val readerOrientation: ReaderOrientation? = null, 14 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/model/ReadingHistory.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.model 2 | 3 | import androidx.room.Entity 4 | import com.fishhawk.lisu.data.network.model.MangaState 5 | import java.time.LocalDateTime 6 | 7 | @Entity(primaryKeys = ["providerId", "mangaId"]) 8 | data class ReadingHistory( 9 | val state: MangaState, 10 | val providerId: String, 11 | 12 | val mangaId: String, 13 | 14 | val cover: String?, 15 | val title: String?, 16 | val authors: String?, 17 | 18 | var date: LocalDateTime = LocalDateTime.now(), 19 | 20 | var collectionId: String, 21 | var chapterId: String, 22 | var chapterName: String, 23 | var page: Int, 24 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/model/SearchHistory.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.model 2 | 3 | import androidx.room.Entity 4 | import java.time.LocalDateTime 5 | 6 | @Entity(primaryKeys = ["providerId", "keywords"]) 7 | data class SearchHistory( 8 | val providerId: String, 9 | val keywords: String, 10 | val date: LocalDateTime = LocalDateTime.now() 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/database/model/ServerHistory.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.database.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import java.time.LocalDateTime 6 | 7 | @Entity 8 | data class ServerHistory( 9 | @PrimaryKey 10 | val address: String, 11 | val date: LocalDateTime = LocalDateTime.now() 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/datastore/Preference.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.datastore 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.datastore.core.DataStore 7 | import androidx.datastore.preferences.core.* 8 | import kotlinx.coroutines.flow.* 9 | import kotlinx.coroutines.runBlocking 10 | import kotlin.coroutines.CoroutineContext 11 | import kotlin.coroutines.EmptyCoroutineContext 12 | 13 | interface Serializer { 14 | fun deserialize(serialized: String): T 15 | fun serialize(value: T): String 16 | } 17 | 18 | interface Preference { 19 | val defaultValue: T 20 | val flow: Flow 21 | suspend fun set(value: T) 22 | } 23 | 24 | class BasePreference( 25 | private val store: DataStore, 26 | private val key: Preferences.Key, 27 | override val defaultValue: T 28 | ) : Preference { 29 | override val flow = store.data.map { 30 | it[key] ?: defaultValue 31 | }.distinctUntilChanged().conflate() 32 | 33 | override suspend fun set(value: T) { 34 | store.edit { it[key] = value } 35 | } 36 | } 37 | 38 | class ObjectPreference( 39 | private val store: DataStore, 40 | private val key: Preferences.Key, 41 | private val serializer: Serializer, 42 | override val defaultValue: T 43 | ) : Preference { 44 | override val flow: Flow = store.data.map { preferences -> 45 | preferences[key]?.let { serializer.deserialize(it) } ?: defaultValue 46 | }.distinctUntilChanged().conflate() 47 | 48 | override suspend fun set(value: T) { 49 | store.edit { it[key] = serializer.serialize(value) } 50 | } 51 | } 52 | 53 | fun DataStore.get( 54 | name: String, 55 | defaultValue: Int 56 | ): Lazy> = 57 | lazy { BasePreference(this, intPreferencesKey(name), defaultValue) } 58 | 59 | fun DataStore.get( 60 | name: String, 61 | defaultValue: Double 62 | ): Lazy> = 63 | lazy { BasePreference(this, doublePreferencesKey(name), defaultValue) } 64 | 65 | fun DataStore.get( 66 | name: String, 67 | defaultValue: String 68 | ): Lazy> = 69 | lazy { BasePreference(this, stringPreferencesKey(name), defaultValue) } 70 | 71 | fun DataStore.get( 72 | name: String, 73 | defaultValue: Boolean 74 | ): Lazy> = 75 | lazy { BasePreference(this, booleanPreferencesKey(name), defaultValue) } 76 | 77 | fun DataStore.get( 78 | name: String, 79 | defaultValue: Float 80 | ): Lazy> = 81 | lazy { BasePreference(this, floatPreferencesKey(name), defaultValue) } 82 | 83 | fun DataStore.get( 84 | name: String, 85 | defaultValue: Long 86 | ): Lazy> = 87 | lazy { BasePreference(this, longPreferencesKey(name), defaultValue) } 88 | 89 | fun DataStore.get( 90 | name: String, 91 | defaultValue: Set 92 | ): Lazy>> = 93 | lazy { BasePreference(this, stringSetPreferencesKey(name), defaultValue) } 94 | 95 | inline fun > DataStore.get( 96 | name: String, 97 | defaultValue: T 98 | ): Lazy> = lazy { 99 | val serializer = object : Serializer { 100 | override fun deserialize(serialized: String) = enumValueOf(serialized) 101 | override fun serialize(value: T) = value.name 102 | } 103 | ObjectPreference(this, stringPreferencesKey(name), serializer, defaultValue) 104 | } 105 | 106 | @Composable 107 | fun Preference.collectAsState( 108 | context: CoroutineContext = EmptyCoroutineContext 109 | ): State = flow.collectAsState(getBlocking(), context) 110 | 111 | suspend fun Preference.get(): T = flow.first() 112 | 113 | fun Preference.getBlocking(): T = runBlocking { get() } 114 | 115 | fun Preference.setBlocking(value: T) = runBlocking { set(value) } 116 | 117 | inline fun > T.next(): T { 118 | val values = enumValues() 119 | val nextOrdinal = (ordinal + 1) % values.size 120 | return values[nextOrdinal] 121 | } 122 | 123 | suspend inline fun > Preference.setNext() = set(flow.first().next()) 124 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/datastore/PreferenceRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.datastore 2 | 3 | import android.content.Context 4 | import androidx.datastore.preferences.preferencesDataStore 5 | 6 | enum class StartScreen { Library, History, Explore } 7 | enum class Theme { Light, Dark } 8 | 9 | enum class ChapterDisplayMode { Grid, Linear } 10 | enum class ChapterDisplayOrder { Ascend, Descend } 11 | 12 | enum class ReaderMode { Ltr, Rtl, Continuous } 13 | enum class ReaderOrientation { Portrait, Landscape } 14 | 15 | enum class ColorFilterMode { Default, Multiply, Screen, Overlay, Lighten, Darken } 16 | enum class ScaleType { FitScreen, FitWidth, FitHeight, OriginalSize } 17 | 18 | class PreferenceRepository(context: Context) { 19 | private val Context.store by preferencesDataStore(name = "preference") 20 | private val store = context.store 21 | 22 | val serverAddress by store.get("serverAddress", "192.168.1.100:8080") 23 | val lastAppCheckTime by store.get("lastAppCheckTime", 0L) 24 | 25 | val lastUsedProvider by store.get("last_used_provider", "") 26 | 27 | val enableAutoUpdates by store.get("enable_auto_updates", true) 28 | 29 | val theme by store.get("theme", Theme.Light) 30 | val secureMode by store.get("secure_mode", false) 31 | 32 | 33 | val startScreen by store.get("start_screen", StartScreen.Library) 34 | val chapterDisplayMode by store.get("chapter_display_mode", ChapterDisplayMode.Grid) 35 | val chapterDisplayOrder by store.get("chapter_display_order", ChapterDisplayOrder.Ascend) 36 | val isConfirmExitEnabled by store.get("is_confirm_exit_enabled", false) 37 | val isRandomButtonEnabled by store.get("is_random_button_enabled", false) 38 | 39 | 40 | val readerMode by store.get("reader_mode", ReaderMode.Ltr) 41 | val readerOrientation by store.get("reader_orientation", ReaderOrientation.Portrait) 42 | 43 | val scaleType by store.get("scale_type", ScaleType.FitScreen) 44 | 45 | val isPageIntervalEnabled by store.get("is_page_interval_enabled", false) 46 | val showInfoBar by store.get("show_info_bar", true) 47 | val isLongTapDialogEnabled by store.get("is_long_tap_dialog_enabled", true) 48 | val isAreaInterpolationEnabled by store.get("is_area_interpolation_enabled", false) 49 | 50 | val keepScreenOn by store.get("keep_screen_on", false) 51 | val useVolumeKey by store.get("use_volume_key", false) 52 | val invertVolumeKey by store.get("invert_volume_key", false) 53 | 54 | 55 | val enabledColorFilter by store.get("enableColorFilter", false) 56 | val colorFilterMode by store.get("colorFilterMode", ColorFilterMode.Default) 57 | val colorFilterH by store.get("colorFilterH", 0f) 58 | val colorFilterS by store.get("colorFilterS", 0.5f) 59 | val colorFilterL by store.get("colorFilterL", 0.5f) 60 | val colorFilterA by store.get("colorFilterA", 0.5f) 61 | 62 | val enableCustomBrightness by store.get("enableCustomBrightness", false) 63 | val customBrightness by store.get("customBrightness", 0.1f) 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/datastore/ProviderBrowseHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.datastore 2 | 3 | import android.content.Context 4 | import androidx.datastore.preferences.core.* 5 | import androidx.datastore.preferences.preferencesDataStore 6 | import com.fishhawk.lisu.data.network.model.* 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.conflate 9 | import kotlinx.coroutines.flow.distinctUntilChanged 10 | import kotlinx.coroutines.flow.map 11 | 12 | class ProviderBrowseHistoryRepository(context: Context) { 13 | private val Context.store by preferencesDataStore(name = "provider_history") 14 | private val store = context.store 15 | 16 | suspend fun setFilterValue( 17 | providerId: String, 18 | boardId: BoardId, 19 | name: String, 20 | value: Any, 21 | ) { 22 | val fullName = "$providerId:${boardId.name}:$name" 23 | when (value) { 24 | is String -> store.get(fullName, value).value.set(value) 25 | is Boolean -> store.get(fullName, value).value.set(value) 26 | is Int -> store.get(fullName, value).value.set(value) 27 | else -> { 28 | val newValue = (value as Set<*>).map { it.toString() }.toSet() 29 | store.get(fullName, newValue).value.set(newValue) 30 | } 31 | } 32 | } 33 | 34 | fun getBoardFilterValue( 35 | providerId: String, 36 | boardId: BoardId, 37 | model: BoardModel, 38 | ): Flow { 39 | return store.data 40 | .map { 41 | BoardFilterValue( 42 | base = getFilterValues(providerId, boardId, model.base, it), 43 | advance = getFilterValues(providerId, boardId, model.advance, it), 44 | ) 45 | } 46 | .distinctUntilChanged() 47 | .conflate() 48 | } 49 | 50 | private fun getFilterValues( 51 | providerId: String, 52 | boardId: BoardId, 53 | boardModel: Map, 54 | preferences: Preferences, 55 | ): Map { 56 | return boardModel.mapValues { (name, filterModel) -> 57 | val fullName = "$providerId:${boardId.name}:$name" 58 | val value: Any = when (filterModel) { 59 | is FilterModel.Text -> 60 | preferences[stringPreferencesKey(fullName)] 61 | ?: "" 62 | is FilterModel.Switch -> 63 | preferences[booleanPreferencesKey(fullName)] 64 | ?: filterModel.default 65 | is FilterModel.Select -> 66 | preferences[intPreferencesKey(fullName)] 67 | ?: 0 68 | is FilterModel.MultipleSelect -> 69 | preferences[stringSetPreferencesKey(fullName)] 70 | ?.mapNotNull { it.toIntOrNull() }?.toSet() ?: emptySet() 71 | } 72 | FilterValue(filterModel, value) 73 | } 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/GitHubRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network 2 | 3 | import com.fishhawk.lisu.data.network.model.GitHubRelease 4 | import io.ktor.client.* 5 | import io.ktor.client.call.* 6 | import io.ktor.client.content.* 7 | import io.ktor.client.engine.okhttp.* 8 | import io.ktor.client.plugins.* 9 | import io.ktor.client.plugins.contentnegotiation.* 10 | import io.ktor.client.plugins.resources.* 11 | import io.ktor.client.plugins.resources.Resources 12 | import io.ktor.client.request.* 13 | import io.ktor.http.* 14 | import io.ktor.resources.* 15 | import io.ktor.serialization.kotlinx.json.* 16 | import kotlinx.serialization.Serializable 17 | import kotlinx.serialization.json.Json 18 | import kotlinx.serialization.json.JsonObject 19 | import java.io.InputStream 20 | 21 | @Serializable 22 | @Resource("/repos/{owner}/{repo}/releases/latest") 23 | private data class GitHubLatestRelease( 24 | val owner: String, 25 | val repo: String, 26 | ) 27 | 28 | class GitHubRepository { 29 | private val client: HttpClient = HttpClient(OkHttp) { 30 | expectSuccess = true 31 | install(Resources) 32 | install(ContentNegotiation) { json(Json) } 33 | } 34 | 35 | suspend fun getLatestRelease( 36 | owner: String, 37 | repo: String, 38 | ): Result = runCatching { 39 | val builder = URLBuilder("https://api.github.com/") 40 | client.href(GitHubLatestRelease(owner = owner, repo = repo), builder) 41 | val url = builder.build() 42 | client.get(url) { 43 | headers.append("Accept", "application/vnd.github.v3.full+json") 44 | }.body() 45 | } 46 | 47 | suspend fun downloadReleaseFile(url: String, listener: ProgressListener?): Result = 48 | runCatching { 49 | client.get(url) { 50 | onDownload(listener) 51 | }.body() 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/base/Connectivity.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.base 2 | 3 | import android.content.Context 4 | import android.net.* 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.distinctUntilChanged 8 | import kotlinx.coroutines.flow.filterNotNull 9 | import kotlinx.coroutines.flow.onEach 10 | import kotlinx.coroutines.flow.receiveAsFlow 11 | 12 | class Connectivity(context: Context) { 13 | private val connectivityManager = 14 | context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 15 | 16 | private val _interfaceName = Channel() 17 | val interfaceName = _interfaceName.receiveAsFlow() 18 | .filterNotNull() 19 | .distinctUntilChanged() 20 | .onEach { 21 | // hack 22 | delay(500) 23 | } 24 | 25 | init { 26 | connectivityManager.registerNetworkCallback( 27 | NetworkRequest.Builder() 28 | .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 29 | .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) 30 | .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) 31 | .build(), 32 | object : ConnectivityManager.NetworkCallback() { 33 | override fun onLinkPropertiesChanged( 34 | network: Network, 35 | linkProperties: LinkProperties 36 | ) { 37 | _interfaceName.trySend(linkProperties.interfaceName) 38 | } 39 | }) 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/base/RemoteData.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.base 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.channels.Channel 5 | import kotlinx.coroutines.channels.awaitClose 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.* 8 | import kotlinx.coroutines.launch 9 | 10 | sealed interface RemoteDataAction { 11 | data class Mutate(val transformer: (T) -> T) : RemoteDataAction 12 | object Reload : RemoteDataAction 13 | } 14 | 15 | typealias RemoteDataActionChannel = Channel> 16 | 17 | suspend fun RemoteDataActionChannel.mutate(transformer: (T) -> T) { 18 | send(RemoteDataAction.Mutate(transformer)) 19 | } 20 | 21 | suspend fun RemoteDataActionChannel.reload() { 22 | send(RemoteDataAction.Reload) 23 | } 24 | 25 | class RemoteData( 26 | private val actionChannel: RemoteDataActionChannel, 27 | val value: Result?, 28 | ) { 29 | suspend fun mutate(transformer: (T) -> T) = actionChannel.mutate(transformer) 30 | suspend fun reload() = actionChannel.reload() 31 | } 32 | 33 | fun remoteData( 34 | connectivity: Connectivity, 35 | loader: suspend () -> Result, 36 | onStart: ((actionChannel: RemoteDataActionChannel) -> Unit)? = null, 37 | onClose: ((actionChannel: RemoteDataActionChannel) -> Unit)? = null, 38 | ): Flow> = 39 | remoteData( 40 | connectivity = connectivity, 41 | block = { emit(loader()) }, 42 | onStart = onStart, 43 | onClose = onClose, 44 | ) 45 | 46 | fun remoteDataFromFlow( 47 | connectivity: Connectivity, 48 | loader: suspend () -> Result>, 49 | onStart: ((actionChannel: RemoteDataActionChannel) -> Unit)? = null, 50 | onClose: ((actionChannel: RemoteDataActionChannel) -> Unit)? = null, 51 | ): Flow> = 52 | remoteData( 53 | connectivity = connectivity, 54 | block = { 55 | loader() 56 | .onSuccess { 57 | it.catch { emit(Result.failure(it)) } 58 | .collect { emit(Result.success(it)) } 59 | } 60 | .onFailure { 61 | emit(Result.failure(it)) 62 | } 63 | }, 64 | onStart = onStart, 65 | onClose = onClose, 66 | ) 67 | 68 | private fun remoteData( 69 | connectivity: Connectivity, 70 | block: suspend RemoteDataScope.() -> Unit, 71 | onStart: ((actionChannel: RemoteDataActionChannel) -> Unit)? = null, 72 | onClose: ((actionChannel: RemoteDataActionChannel) -> Unit)? = null, 73 | ): Flow> = callbackFlow { 74 | val dispatcher = Dispatchers.IO.limitedParallelism(1) 75 | 76 | val actionChannel = Channel>() 77 | var value: Result? = null 78 | 79 | onStart?.invoke(actionChannel) 80 | 81 | val remoteDataScope = RemoteDataScope { newValue -> 82 | value = newValue 83 | send(RemoteData(actionChannel, newValue)) 84 | } 85 | 86 | var job = launch(dispatcher) { remoteDataScope.block() } 87 | 88 | launch(dispatcher) { 89 | actionChannel.receiveAsFlow().flowOn(dispatcher).collect { action -> 90 | when (action) { 91 | is RemoteDataAction.Mutate -> { 92 | value?.onSuccess { remoteDataScope.emit(Result.success(action.transformer(it))) } 93 | } 94 | is RemoteDataAction.Reload -> { 95 | job.cancel() 96 | remoteDataScope.emit(null) 97 | job = launch(dispatcher) { remoteDataScope.block() } 98 | } 99 | } 100 | } 101 | } 102 | launch(dispatcher) { 103 | connectivity.interfaceName.collect { 104 | delay(250) 105 | if (value?.isSuccess != true) { 106 | actionChannel.reload() 107 | } 108 | } 109 | } 110 | awaitClose { 111 | onClose?.invoke(actionChannel) 112 | } 113 | } 114 | 115 | private fun interface RemoteDataScope { 116 | suspend fun emit(value: Result?) 117 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/base/RemoteList.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.base 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.channels.Channel 5 | import kotlinx.coroutines.channels.awaitClose 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.callbackFlow 8 | import kotlinx.coroutines.flow.flowOn 9 | import kotlinx.coroutines.flow.receiveAsFlow 10 | 11 | sealed interface RemoteListAction { 12 | data class Mutate(val transformer: (MutableList) -> MutableList) : RemoteListAction 13 | object Reload : RemoteListAction 14 | object RequestNextPage : RemoteListAction 15 | } 16 | 17 | typealias RemoteListActionChannel = Channel> 18 | 19 | suspend fun RemoteListActionChannel.mutate(transformer: (MutableList) -> MutableList) { 20 | send(RemoteListAction.Mutate(transformer)) 21 | } 22 | 23 | suspend fun RemoteListActionChannel.reload() { 24 | send(RemoteListAction.Reload) 25 | } 26 | 27 | suspend fun RemoteListActionChannel.requestNextPage() { 28 | send(RemoteListAction.RequestNextPage) 29 | } 30 | 31 | class RemoteList( 32 | private val actionChannel: RemoteListActionChannel, 33 | val value: Result>?, 34 | private val refresh: suspend () -> Result, 35 | ) { 36 | suspend fun mutate(transformer: (MutableList) -> MutableList) = 37 | actionChannel.mutate(transformer) 38 | 39 | suspend fun reload() = actionChannel.reload() 40 | suspend fun requestNextPage() = actionChannel.requestNextPage() 41 | suspend fun refresh() = refresh.invoke() 42 | } 43 | 44 | data class PagedList( 45 | val list: List, 46 | val appendState: Result?, 47 | ) 48 | 49 | data class Page( 50 | val data: List, 51 | val nextKey: Key?, 52 | ) 53 | 54 | fun remotePagingList( 55 | connectivity: Connectivity, 56 | loader: suspend (Int) -> Result>, 57 | onStart: ((actionChannel: RemoteListActionChannel) -> Unit)? = null, 58 | onClose: ((actionChannel: RemoteListActionChannel) -> Unit)? = null, 59 | ): Flow> = remotePagingList( 60 | connectivity = connectivity, 61 | startKey = 0, 62 | loader = { page -> loader(page).map { Page(it, if (it.isEmpty()) null else page + 1) } }, 63 | onStart = onStart, 64 | onClose = onClose, 65 | ) 66 | 67 | fun remotePagingList( 68 | connectivity: Connectivity, 69 | startKey: Key, 70 | loader: suspend (Key) -> Result>, 71 | onStart: ((actionChannel: RemoteListActionChannel) -> Unit)? = null, 72 | onClose: ((actionChannel: RemoteListActionChannel) -> Unit)? = null, 73 | ): Flow> = callbackFlow { 74 | val dispatcher = Dispatchers.IO.limitedParallelism(1) 75 | 76 | val actionChannel = Channel>() 77 | 78 | var listState: Result? = null 79 | var appendState: Result? = null 80 | var value: MutableList = mutableListOf() 81 | var nextKey: Key? = startKey 82 | 83 | onStart?.invoke(actionChannel) 84 | 85 | lateinit var job: Job 86 | 87 | suspend fun mySend() { 88 | send( 89 | RemoteList( 90 | actionChannel = actionChannel, 91 | value = listState?.map { 92 | PagedList( 93 | appendState = appendState, 94 | list = value, 95 | ) 96 | }, 97 | refresh = { 98 | withContext(dispatcher) { 99 | loader(startKey) 100 | .onSuccess { 101 | if (job.isActive) { 102 | job.cancel() 103 | } 104 | value.clear() 105 | value.addAll(it.data) 106 | nextKey = it.nextKey 107 | listState = Result.success(Unit) 108 | appendState = Result.success(Unit) 109 | mySend() 110 | } 111 | .map { } 112 | } 113 | } 114 | ) 115 | ) 116 | } 117 | 118 | fun requestNextPage() = launch(dispatcher) { 119 | nextKey?.let { key -> 120 | appendState = null 121 | mySend() 122 | loader(key) 123 | .onSuccess { 124 | value.addAll(it.data) 125 | nextKey = it.nextKey 126 | listState = Result.success(Unit) 127 | appendState = Result.success(Unit) 128 | mySend() 129 | } 130 | .onFailure { 131 | if (listState?.isSuccess != true) 132 | listState = Result.failure(it) 133 | appendState = Result.failure(it) 134 | mySend() 135 | } 136 | } 137 | } 138 | 139 | job = requestNextPage() 140 | 141 | launch(dispatcher) { 142 | actionChannel.receiveAsFlow().flowOn(dispatcher).collect { action -> 143 | when (action) { 144 | is RemoteListAction.Mutate -> { 145 | value = action.transformer(value) 146 | mySend() 147 | } 148 | is RemoteListAction.Reload -> { 149 | job.cancel() 150 | listState = null 151 | appendState = null 152 | value.clear() 153 | nextKey = startKey 154 | mySend() 155 | job = requestNextPage() 156 | } 157 | is RemoteListAction.RequestNextPage -> { 158 | if (!job.isActive) job = requestNextPage() 159 | } 160 | } 161 | } 162 | } 163 | launch(dispatcher) { 164 | connectivity.interfaceName.collect { 165 | delay(250) 166 | if (listState?.isSuccess != true) { 167 | actionChannel.reload() 168 | } else if (appendState?.isSuccess != true) { 169 | actionChannel.requestNextPage() 170 | } 171 | } 172 | } 173 | awaitClose { 174 | onClose?.invoke(actionChannel) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/dao/LisuDownloadDao.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.dao 2 | 3 | import com.fishhawk.lisu.data.network.model.MangaDownloadTask 4 | import io.ktor.client.* 5 | import io.ktor.client.call.* 6 | import io.ktor.client.plugins.resources.* 7 | import io.ktor.client.plugins.websocket.* 8 | import io.ktor.resources.* 9 | import io.ktor.websocket.* 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.filterIsInstance 12 | import kotlinx.coroutines.flow.flow 13 | import kotlinx.coroutines.flow.receiveAsFlow 14 | import kotlinx.serialization.Serializable 15 | import kotlinx.serialization.builtins.ListSerializer 16 | import kotlinx.serialization.json.Json 17 | 18 | @Serializable 19 | @Resource("/download") 20 | private class Download { 21 | @Serializable 22 | @Resource("/start-all") 23 | data class StartAll( 24 | val parent: Download = Download(), 25 | ) 26 | 27 | @Serializable 28 | @Resource("/start-manga/{providerId}/{mangaId}") 29 | data class StartManga( 30 | val parent: Download = Download(), 31 | val providerId: String, 32 | val mangaId: String, 33 | ) 34 | 35 | @Serializable 36 | @Resource("/start-chapter/{providerId}/{mangaId}/{collectionId}/{chapterId}") 37 | data class StartChapter( 38 | val parent: Download = Download(), 39 | val providerId: String, 40 | val mangaId: String, 41 | val collectionId: String, 42 | val chapterId: String, 43 | ) 44 | 45 | @Serializable 46 | @Resource("/cancel-all") 47 | data class CancelAll( 48 | val parent: Download = Download(), 49 | ) 50 | 51 | @Serializable 52 | @Resource("/cancel-manga/{providerId}/{mangaId}") 53 | data class CancelManga( 54 | val parent: Download = Download(), 55 | val providerId: String, 56 | val mangaId: String, 57 | ) 58 | 59 | @Serializable 60 | @Resource("/cancel-chapter/{providerId}/{mangaId}/{collectionId}/{chapterId}") 61 | data class CancelChapter( 62 | val parent: Download = Download(), 63 | val providerId: String, 64 | val mangaId: String, 65 | val collectionId: String, 66 | val chapterId: String, 67 | ) 68 | } 69 | 70 | class LisuDownloadDao(private val client: HttpClient) { 71 | 72 | suspend fun listMangaDownloadTask( 73 | ): Flow> = flow { 74 | client.webSocket("/download/list") { 75 | val url = call.request.url 76 | incoming 77 | .receiveAsFlow() 78 | .filterIsInstance() 79 | .collect { frame -> 80 | val list = Json.decodeFromString( 81 | ListSerializer(MangaDownloadTask.serializer()), 82 | frame.readText() 83 | ).map { 84 | it.copy( 85 | cover = client.processCover( 86 | url = url, 87 | providerId = it.providerId, 88 | mangaId = it.mangaId, 89 | imageId = it.cover, 90 | ) 91 | ) 92 | } 93 | emit(list) 94 | } 95 | } 96 | } 97 | 98 | suspend fun startAllTasks( 99 | ): String = client.post( 100 | Download.StartAll() 101 | ).body() 102 | 103 | suspend fun startMangaTask( 104 | providerId: String, 105 | mangaId: String, 106 | ): String = client.post( 107 | Download.StartManga( 108 | providerId = providerId, 109 | mangaId = mangaId, 110 | ) 111 | ).body() 112 | 113 | suspend fun startChapterTask( 114 | providerId: String, 115 | mangaId: String, 116 | collectionId: String, 117 | chapterId: String, 118 | ): String = client.post( 119 | Download.StartChapter( 120 | providerId = providerId, 121 | mangaId = mangaId, 122 | collectionId = collectionId, 123 | chapterId = chapterId, 124 | ) 125 | ).body() 126 | 127 | suspend fun cancelAllTasks( 128 | ): String = client.post( 129 | Download.CancelAll() 130 | ).body() 131 | 132 | suspend fun cancelMangaTask( 133 | providerId: String, 134 | mangaId: String, 135 | ): String = client.post( 136 | Download.CancelManga( 137 | providerId = providerId, 138 | mangaId = mangaId, 139 | ) 140 | ).body() 141 | 142 | suspend fun cancelChapterTask( 143 | providerId: String, 144 | mangaId: String, 145 | collectionId: String, 146 | chapterId: String, 147 | ): String = client.post( 148 | Download.CancelChapter( 149 | providerId = providerId, 150 | mangaId = mangaId, 151 | collectionId = collectionId, 152 | chapterId = chapterId, 153 | ) 154 | ).body() 155 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/dao/LisuLibraryDao.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.dao 2 | 3 | import com.fishhawk.lisu.data.network.model.MangaDto 4 | import com.fishhawk.lisu.data.network.model.MangaKeyDto 5 | import com.fishhawk.lisu.data.network.model.MangaMetadata 6 | import com.fishhawk.lisu.data.network.model.MangaPageDto 7 | import io.ktor.client.* 8 | import io.ktor.client.call.* 9 | import io.ktor.client.plugins.resources.* 10 | import io.ktor.client.request.* 11 | import io.ktor.client.request.forms.* 12 | import io.ktor.client.statement.* 13 | import io.ktor.http.* 14 | import io.ktor.resources.* 15 | import kotlinx.serialization.Serializable 16 | 17 | @Serializable 18 | @Resource("/library") 19 | private class Library { 20 | @Serializable 21 | @Resource("/search") 22 | data class Search( 23 | val parent: Library = Library(), 24 | val key: String?, 25 | val keywords: String, 26 | ) 27 | 28 | @Serializable 29 | @Resource("/manga-delete") 30 | data class MangaDelete( 31 | val parent: Library = Library(), 32 | ) 33 | 34 | @Serializable 35 | @Resource("/random-manga") 36 | data class RandomManga( 37 | val parent: Library = Library(), 38 | ) 39 | 40 | @Serializable 41 | @Resource("/manga/{providerId}/{mangaId}") 42 | data class Manga( 43 | val parent: Library = Library(), 44 | val providerId: String, 45 | val mangaId: String, 46 | ) 47 | 48 | @Serializable 49 | @Resource("/manga/{providerId}/{mangaId}/cover") 50 | data class Cover( 51 | val parent: Library = Library(), 52 | val providerId: String, 53 | val mangaId: String, 54 | ) 55 | 56 | @Serializable 57 | @Resource("/manga/{providerId}/{mangaId}/metadata") 58 | data class Metadata( 59 | val parent: Library = Library(), 60 | val providerId: String, 61 | val mangaId: String, 62 | ) 63 | } 64 | 65 | class LisuLibraryDao(private val client: HttpClient) { 66 | 67 | suspend fun search( 68 | key: String?, 69 | keywords: String = "", 70 | ): MangaPageDto = client.get( 71 | Library.Search( 72 | key = key, 73 | keywords = keywords, 74 | ) 75 | ).run { 76 | val mangaPage = body() 77 | mangaPage.copy( 78 | list = mangaPage.list.map { 79 | it.copy( 80 | cover = client.processCover( 81 | url = request.url, 82 | providerId = it.providerId, 83 | mangaId = it.id, 84 | imageId = it.cover, 85 | ) 86 | ) 87 | } 88 | ) 89 | } 90 | 91 | suspend fun removeMultipleMangas( 92 | mangas: List, 93 | ): String = client.post( 94 | Library.MangaDelete() 95 | ) { 96 | contentType(ContentType.Application.Json) 97 | setBody(mangas) 98 | }.body() 99 | 100 | suspend fun getRandomManga(): MangaDto = client.get( 101 | Library.RandomManga() 102 | ).body() 103 | 104 | suspend fun addManga( 105 | providerId: String, 106 | mangaId: String, 107 | ): String = client.post( 108 | Library.Manga( 109 | providerId = providerId, 110 | mangaId = mangaId, 111 | ) 112 | ).body() 113 | 114 | suspend fun removeManga( 115 | providerId: String, 116 | mangaId: String, 117 | ): String = client.delete( 118 | Library.Manga( 119 | providerId = providerId, 120 | mangaId = mangaId, 121 | ) 122 | ).body() 123 | 124 | suspend fun updateMangaCover( 125 | providerId: String, 126 | mangaId: String, 127 | cover: ByteArray, 128 | coverType: String, 129 | ): String = client.put( 130 | Library.Cover( 131 | providerId = providerId, 132 | mangaId = mangaId, 133 | ) 134 | ) { 135 | setBody( 136 | MultiPartFormDataContent( 137 | formData { 138 | append("cover", cover, Headers.build { 139 | append(HttpHeaders.ContentType, coverType) 140 | }) 141 | } 142 | ) 143 | ) 144 | }.body() 145 | 146 | suspend fun updateMangaMetadata( 147 | providerId: String, 148 | mangaId: String, 149 | metadata: MangaMetadata, 150 | ): MangaMetadata = client.put( 151 | Library.Metadata( 152 | providerId = providerId, 153 | mangaId = mangaId, 154 | ) 155 | ) { 156 | contentType(ContentType.Application.Json) 157 | setBody(metadata) 158 | }.body() 159 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/model/CommentDto.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class CommentDto( 7 | val username: String, 8 | val content: String, 9 | val createTime: Long? = null, 10 | val vote: Int? = null, 11 | val subComments: List? = null, 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/model/DownloadTaskDto.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class MangaDownloadTask( 8 | val providerId: String, 9 | val mangaId: String, 10 | val cover: String?, 11 | val title: String?, 12 | val chapterTasks: List, 13 | ) 14 | 15 | @Serializable 16 | data class ChapterDownloadTask( 17 | val collectionId: String, 18 | val chapterId: String, 19 | val name: String? = null, 20 | val title: String? = null, 21 | var state: State = State.Waiting, 22 | ) { 23 | @Serializable 24 | sealed interface State { 25 | @Serializable 26 | @SerialName("waiting") 27 | object Waiting : State 28 | 29 | @Serializable 30 | @SerialName("downloading") 31 | data class Downloading( 32 | val downloadedPageNumber: Int?, 33 | val totalPageNumber: Int?, 34 | ) : State 35 | 36 | @Serializable 37 | @SerialName("failed") 38 | data class Failed( 39 | val downloadedPageNumber: Int?, 40 | val totalPageNumber: Int?, 41 | val errorMessage: String, 42 | ) : State 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/model/GitHubRelease.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.model 2 | 3 | import android.os.Build 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class GitHubRelease( 9 | @SerialName("name") val name: String, 10 | @SerialName("body") val body: String, 11 | @SerialName("tag_name") val version: String, 12 | @SerialName("html_url") val releaseLink: String, 13 | @SerialName("assets") private val assets: List, 14 | ) { 15 | fun getDownloadLink(): String { 16 | val apkVariant = when (Build.SUPPORTED_ABIS[0]) { 17 | "arm64-v8a" -> "-arm64-v8a" 18 | "armeabi-v7a" -> "-armeabi-v7a" 19 | "x86", "x86_64" -> "-x86" 20 | else -> "" 21 | } 22 | val asset = assets.find { 23 | it.downloadLink.contains("lisu$apkVariant-") 24 | } ?: assets.first() 25 | return asset.downloadLink 26 | } 27 | 28 | @Serializable 29 | data class Assets(@SerialName("browser_download_url") val downloadLink: String) 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/model/MangaDto.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaKeyDto( 7 | val providerId: String, 8 | val id: String, 9 | ) 10 | 11 | @Serializable 12 | data class MangaPageDto( 13 | val list: List, 14 | val nextKey: String? = null, 15 | ) 16 | 17 | enum class MangaState { 18 | Local, Remote, RemoteInLibrary 19 | } 20 | 21 | @Serializable 22 | data class MangaDto( 23 | val state: MangaState = MangaState.Local, 24 | val providerId: String, 25 | 26 | val id: String, 27 | 28 | var cover: String? = null, 29 | val updateTime: Long? = null, 30 | val title: String? = null, 31 | val authors: List = emptyList(), 32 | val isFinished: Boolean? = null, 33 | ) { 34 | val key 35 | get() = MangaKeyDto(providerId, id) 36 | 37 | val titleOrId 38 | get() = title ?: id 39 | } 40 | 41 | @Serializable 42 | data class MangaDetailDto( 43 | val state: MangaState = MangaState.Local, 44 | val providerId: String, 45 | 46 | val id: String, 47 | 48 | var cover: String? = null, 49 | val updateTime: Long? = null, 50 | val title: String? = null, 51 | val authors: List = emptyList(), 52 | val isFinished: Boolean? = null, 53 | 54 | val description: String? = null, 55 | val tags: Map> = emptyMap(), 56 | 57 | val collections: Map> = emptyMap(), 58 | val chapterPreviews: List = emptyList(), 59 | ) 60 | 61 | @Serializable 62 | data class Chapter( 63 | val id: String, 64 | val name: String? = null, 65 | val title: String? = null, 66 | val updateTime: Long? = null, 67 | val isLocked: Boolean = false, 68 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/model/MetadataDto.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaMetadata( 7 | val title: String? = null, 8 | val authors: List = emptyList(), 9 | val isFinished: Boolean? = null, 10 | val description: String? = null, 11 | val tags: Map> = emptyMap(), 12 | ) 13 | 14 | fun MangaDetailDto.toMetadata(): MangaMetadata { 15 | return MangaMetadata( 16 | title = title, 17 | authors = authors, 18 | isFinished = isFinished, 19 | description = description, 20 | tags = tags, 21 | ) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/data/network/model/ProviderDto.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.data.network.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | 7 | enum class BoardId { Main, Rank, Search } 8 | 9 | @Serializable 10 | sealed interface FilterModel { 11 | @Serializable 12 | @SerialName("Text") 13 | data class Text(val showSearchBar: Boolean = false) : FilterModel 14 | 15 | @Serializable 16 | @SerialName("Switch") 17 | data class Switch(val default: Boolean = false) : FilterModel 18 | 19 | @Serializable 20 | @SerialName("Select") 21 | data class Select(val options: List) : FilterModel 22 | 23 | @Serializable 24 | @SerialName("MultipleSelect") 25 | data class MultipleSelect(val options: List) : FilterModel 26 | } 27 | 28 | @Serializable 29 | data class BoardModel( 30 | val hasSearchBar: Boolean = false, 31 | val base: Map = emptyMap(), 32 | val advance: Map = emptyMap(), 33 | ) 34 | 35 | data class FilterValue( 36 | val model: FilterModel, 37 | val value: Any, 38 | ) 39 | 40 | data class BoardFilterValue( 41 | val base: Map, 42 | val advance: Map, 43 | ) { 44 | companion object { 45 | val Empty = BoardFilterValue(emptyMap(), emptyMap()) 46 | } 47 | } 48 | 49 | @Serializable 50 | data class ProviderDto( 51 | val id: String, 52 | val lang: String, 53 | var icon: String? = null, 54 | val boardModels: Map, 55 | val isLogged: Boolean? = null, 56 | val cookiesLogin: CookiesLoginDto? = null, 57 | val passwordLogin: Boolean = false, 58 | ) { 59 | val searchBoardId: BoardId? 60 | get() = 61 | if (boardModels.containsKey(BoardId.Search)) BoardId.Search 62 | else boardModels.entries.find { it.value.hasSearchBar }?.key 63 | } 64 | 65 | 66 | @Serializable 67 | data class CookiesLoginDto( 68 | val loginSite: String, 69 | val cookieNames: List, 70 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/notification/AppUpdateNotification.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.notification 2 | 3 | import android.app.PendingIntent 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import androidx.core.app.NotificationCompat 8 | import androidx.core.app.NotificationManagerCompat 9 | import com.fishhawk.lisu.R 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.flow.receiveAsFlow 12 | 13 | object AppUpdateNotification { 14 | internal const val channel = "app_apk_update_channel" 15 | private const val id = 1 16 | 17 | private fun Context.notificationBuilder() = 18 | NotificationCompat.Builder(this, channel) 19 | 20 | private fun NotificationCompat.Builder.show(context: Context) = 21 | with(NotificationManagerCompat.from(context)) { notify(id, build()) } 22 | 23 | fun onDownloadStart(context: Context) = with(context) { 24 | notificationBuilder() 25 | .setSmallIcon(R.mipmap.ic_launcher) 26 | .setContentTitle(context.getString(R.string.app_name)) 27 | .setContentText(context.getString(R.string.notification_app_update_in_progress)) 28 | .setOngoing(true) 29 | .show(this) 30 | } 31 | 32 | fun onProgressChange(context: Context, progress: Float) = with(context) { 33 | notificationBuilder() 34 | .setSmallIcon(R.mipmap.ic_launcher) 35 | .setProgress(100, (progress * 100).toInt(), false) 36 | .setOnlyAlertOnce(true) 37 | .show(this) 38 | } 39 | 40 | fun onDownloadFinished(context: Context, uri: Uri) = with(context) { 41 | val intent = Intent(Intent.ACTION_VIEW).apply { 42 | setDataAndType(uri, "application/vnd.android.package-archive") 43 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION 44 | } 45 | val installIntent = PendingIntent.getActivity( 46 | this, 0, intent, 47 | NotificationReceiver.FLAG_IMMUTABLE 48 | ) 49 | 50 | notificationBuilder() 51 | .setContentText(context.getString(R.string.notification_app_update_complete)) 52 | .setSmallIcon(android.R.drawable.stat_sys_download_done) 53 | .setOnlyAlertOnce(false) 54 | .setProgress(0, 0, false) 55 | .setContentIntent(installIntent) 56 | .clearActions() 57 | .addAction( 58 | R.drawable.ic_baseline_system_update_alt_24, 59 | context.getString(R.string.action_install), 60 | installIntent 61 | ) 62 | .addAction( 63 | R.drawable.ic_baseline_close_24, 64 | context.getString(R.string.action_cancel), 65 | NotificationReceiver.dismissNotificationBroadcast(context, id) 66 | ) 67 | .show(this) 68 | } 69 | 70 | fun onDownloadError(context: Context, url: String) = with(context) { 71 | notificationBuilder() 72 | .setContentText(context.getString(R.string.notification_app_update_error)) 73 | .setSmallIcon(R.mipmap.ic_launcher) 74 | .setOnlyAlertOnce(false) 75 | .setProgress(0, 0, false) 76 | .clearActions() 77 | .addAction( 78 | R.drawable.ic_baseline_refresh_24, 79 | context.getString(R.string.action_retry), 80 | NotificationReceiver.retryAppUpdateBroadcast(context, url) 81 | ) 82 | .addAction( 83 | R.drawable.ic_baseline_close_24, 84 | context.getString(R.string.action_cancel), 85 | NotificationReceiver.dismissNotificationBroadcast(context, id) 86 | ) 87 | .show(this) 88 | } 89 | 90 | internal val retryChannel = Channel() 91 | val retryFlow = retryChannel.receiveAsFlow() 92 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/notification/NotificationReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.notification 2 | 3 | import android.app.PendingIntent 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.os.Build 8 | import androidx.core.app.NotificationManagerCompat 9 | 10 | class NotificationReceiver : BroadcastReceiver() { 11 | override fun onReceive(context: Context, intent: Intent) { 12 | when (intent.action) { 13 | ACTION_DISMISS_NOTIFICATION -> 14 | with(NotificationManagerCompat.from(context)) { 15 | cancel(intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) 16 | } 17 | ACTION_RETRY_APP_UPDATE -> 18 | AppUpdateNotification.retryChannel.trySend( 19 | intent.getStringExtra(EXTRA_DOWNLOAD_URL)!! 20 | ) 21 | } 22 | } 23 | 24 | companion object { 25 | internal val FLAG_IMMUTABLE = 26 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) 27 | PendingIntent.FLAG_IMMUTABLE 28 | else 0 29 | 30 | private val FLAG_UPDATE_CURRENT = 31 | FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT 32 | 33 | private const val NAME = "NotificationReceiver" 34 | 35 | private const val ACTION_DISMISS_NOTIFICATION = "$NAME.ACTION_DISMISS_NOTIFICATION" 36 | private const val EXTRA_NOTIFICATION_ID = "$NAME.NOTIFICATION_ID" 37 | internal fun dismissNotificationBroadcast( 38 | context: Context, 39 | notificationId: Int 40 | ): PendingIntent { 41 | val intent = Intent(context, NotificationReceiver::class.java).apply { 42 | action = ACTION_DISMISS_NOTIFICATION 43 | putExtra(EXTRA_NOTIFICATION_ID, notificationId) 44 | } 45 | return PendingIntent.getBroadcast(context, 0, intent, FLAG_UPDATE_CURRENT) 46 | } 47 | 48 | private const val ACTION_RETRY_APP_UPDATE = "$NAME.ACTION_RETRY_APP_UPDATE" 49 | private const val EXTRA_DOWNLOAD_URL = "$NAME.URL" 50 | internal fun retryAppUpdateBroadcast( 51 | context: Context, 52 | url: String 53 | ): PendingIntent { 54 | val intent = Intent(context, NotificationReceiver::class.java).apply { 55 | action = ACTION_RETRY_APP_UPDATE 56 | putExtra(EXTRA_DOWNLOAD_URL, url) 57 | } 58 | return PendingIntent.getBroadcast(context, 0, intent, FLAG_UPDATE_CURRENT) 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/notification/Notifications.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.notification 2 | 3 | import android.content.Context 4 | import androidx.core.app.NotificationChannelCompat 5 | import androidx.core.app.NotificationChannelGroupCompat 6 | import androidx.core.app.NotificationManagerCompat 7 | import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT 8 | import com.fishhawk.lisu.R 9 | 10 | object Notifications { 11 | fun createChannels(context: Context) = with(NotificationManagerCompat.from(context)) { 12 | createNotificationChannelsCompat( 13 | listOf( 14 | buildNotificationChannel(AppUpdateNotification.channel, IMPORTANCE_DEFAULT) { 15 | setName("App updates") 16 | } 17 | ) 18 | ) 19 | } 20 | } 21 | 22 | private inline fun buildNotificationChannelGroup( 23 | channelId: String, 24 | block: (NotificationChannelGroupCompat.Builder.() -> Unit), 25 | ): NotificationChannelGroupCompat { 26 | val builder = NotificationChannelGroupCompat.Builder(channelId) 27 | builder.block() 28 | return builder.build() 29 | } 30 | 31 | private inline fun buildNotificationChannel( 32 | channelId: String, 33 | channelImportance: Int, 34 | block: (NotificationChannelCompat.Builder.() -> Unit), 35 | ): NotificationChannelCompat { 36 | val builder = NotificationChannelCompat.Builder(channelId, channelImportance) 37 | builder.block() 38 | return builder.build() 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.base 2 | 3 | import android.os.Bundle 4 | import android.view.WindowManager 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.core.view.WindowCompat 8 | import androidx.lifecycle.lifecycleScope 9 | import com.fishhawk.lisu.PR 10 | import kotlinx.coroutines.flow.launchIn 11 | import kotlinx.coroutines.flow.onEach 12 | 13 | open class BaseActivity : ComponentActivity() { 14 | private val requestPermissionLauncher = registerForActivityResult( 15 | ActivityResultContracts.RequestPermission() 16 | ) {} 17 | 18 | fun requestPermission(permission: String) { 19 | requestPermissionLauncher.launch(permission) 20 | } 21 | 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | WindowCompat.setDecorFitsSystemWindows(window, false) 25 | 26 | PR.secureMode.flow 27 | .onEach { setFlag(WindowManager.LayoutParams.FLAG_SECURE, it) } 28 | .launchIn(lifecycleScope) 29 | } 30 | 31 | protected fun setFlag(flag: Int, isEnabled: Boolean) = 32 | if (isEnabled) window.addFlags(flag) 33 | else window.clearFlags(flag) 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/base/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.base 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.SharingStarted 10 | import kotlinx.coroutines.flow.receiveAsFlow 11 | import kotlinx.coroutines.flow.shareIn 12 | import kotlinx.coroutines.launch 13 | 14 | interface Event 15 | 16 | abstract class BaseViewModel : ViewModel() { 17 | private val _event = Channel() 18 | val event = _event.receiveAsFlow().shareIn(viewModelScope, SharingStarted.Lazily) 19 | protected suspend fun sendEvent(event: E) = _event.send(event) 20 | protected fun sendEventSync(event: E) = viewModelScope.launch { _event.send(event) } 21 | } 22 | 23 | @Composable 24 | fun OnEvent(event: Flow, onEvent: (E) -> Unit) { 25 | LaunchedEffect(Unit) { 26 | event.collect(onEvent) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/download/DownloadViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.download 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.fishhawk.lisu.data.network.LisuRepository 5 | import com.fishhawk.lisu.data.network.model.ChapterDownloadTask 6 | import com.fishhawk.lisu.data.network.model.MangaDownloadTask 7 | import com.fishhawk.lisu.ui.base.BaseViewModel 8 | import com.fishhawk.lisu.ui.base.Event 9 | import kotlinx.coroutines.flow.SharingStarted 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.stateIn 12 | import kotlinx.coroutines.launch 13 | 14 | sealed interface DownloadEvent : Event { 15 | object Reload : DownloadEvent 16 | } 17 | 18 | class DownloadViewModel( 19 | private val lisuRepository: LisuRepository, 20 | ) : BaseViewModel() { 21 | private val tasksRemoteData = lisuRepository.listMangaDownloadTask() 22 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 23 | 24 | val tasksResult = tasksRemoteData 25 | .map { it?.value } 26 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 27 | 28 | fun reload() { 29 | viewModelScope.launch { 30 | tasksRemoteData.value?.reload() 31 | } 32 | } 33 | 34 | fun startAllTasks() { 35 | viewModelScope.launch { 36 | lisuRepository.startAllTasks() 37 | } 38 | } 39 | 40 | fun cancelAllTasks() { 41 | viewModelScope.launch { 42 | lisuRepository.cancelAllTasks() 43 | } 44 | } 45 | 46 | fun startMangaTask(mangaTask: MangaDownloadTask) { 47 | viewModelScope.launch { 48 | lisuRepository.startMangaTask( 49 | providerId = mangaTask.providerId, 50 | mangaId = mangaTask.mangaId, 51 | ) 52 | } 53 | } 54 | 55 | fun cancelMangaTask(mangaTask: MangaDownloadTask) { 56 | viewModelScope.launch { 57 | lisuRepository.cancelMangaTask( 58 | providerId = mangaTask.providerId, 59 | mangaId = mangaTask.mangaId, 60 | ) 61 | } 62 | } 63 | 64 | fun startChapterTask(mangaTask: MangaDownloadTask, chapterTask: ChapterDownloadTask) { 65 | viewModelScope.launch { 66 | lisuRepository.startChapterTask( 67 | providerId = mangaTask.providerId, 68 | mangaId = mangaTask.mangaId, 69 | collectionId = chapterTask.collectionId, 70 | chapterId = chapterTask.chapterId, 71 | ) 72 | } 73 | } 74 | 75 | fun cancelChapterTask(mangaTask: MangaDownloadTask, chapterTask: ChapterDownloadTask) { 76 | viewModelScope.launch { 77 | lisuRepository.cancelChapterTask( 78 | providerId = mangaTask.providerId, 79 | mangaId = mangaTask.mangaId, 80 | collectionId = chapterTask.collectionId, 81 | chapterId = chapterTask.chapterId, 82 | ) 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/explore/ExploreViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.explore 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.fishhawk.lisu.PR 5 | import com.fishhawk.lisu.data.network.LisuRepository 6 | import com.fishhawk.lisu.ui.base.BaseViewModel 7 | import com.fishhawk.lisu.ui.base.Event 8 | import kotlinx.coroutines.flow.* 9 | import kotlinx.coroutines.launch 10 | 11 | sealed interface ExploreEvent : Event { 12 | object LoginSuccess : ExploreEvent 13 | data class LoginFailure(val exception: Throwable) : ExploreEvent 14 | } 15 | 16 | class ExploreViewModel( 17 | private val lisuRepository: LisuRepository, 18 | ) : BaseViewModel() { 19 | val providersLoadState = lisuRepository.providers 20 | .filterNotNull() 21 | .map { it.value?.map { list -> list.groupBy { provider -> provider.lang } } } 22 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 23 | 24 | val lastUsedProvider = 25 | combine( 26 | lisuRepository.providers 27 | .filterNotNull() 28 | .mapNotNull { it.value?.getOrNull() }, 29 | PR.lastUsedProvider.flow 30 | ) { list, name -> list.find { it.id == name } } 31 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 32 | 33 | fun reload() { 34 | viewModelScope.launch { 35 | lisuRepository.providers.value?.reload() 36 | } 37 | } 38 | 39 | fun loginByCookies(providerId: String, cookies: Map) { 40 | viewModelScope.launch { 41 | lisuRepository.loginByCookies(providerId, cookies) 42 | .onSuccess { sendEvent(ExploreEvent.LoginSuccess) } 43 | .onFailure { sendEvent(ExploreEvent.LoginFailure(it)) } 44 | } 45 | } 46 | 47 | fun loginByPassword(providerId: String, username: String, password: String) { 48 | viewModelScope.launch { 49 | lisuRepository.loginByPassword(providerId, username, password) 50 | .onSuccess { sendEvent(ExploreEvent.LoginSuccess) } 51 | .onFailure { sendEvent(ExploreEvent.LoginFailure(it)) } 52 | } 53 | } 54 | 55 | fun logout(providerId: String) { 56 | viewModelScope.launch { 57 | lisuRepository.logout(providerId) 58 | .onSuccess { } 59 | .onFailure { } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/gallery/GalleryCoverSheet.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.gallery 2 | 3 | import android.graphics.drawable.Drawable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material.icons.outlined.Refresh 7 | import androidx.compose.material.icons.outlined.SaveAlt 8 | import androidx.compose.material.icons.outlined.Share 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.res.stringResource 13 | import com.fishhawk.lisu.R 14 | import com.fishhawk.lisu.ui.theme.LisuIcons 15 | import com.fishhawk.lisu.util.toast 16 | import com.fishhawk.lisu.widget.BottomSheetListItem 17 | 18 | @Composable 19 | internal fun GalleryCoverSheetContent( 20 | cover: Drawable?, 21 | onAction: (GalleryAction) -> Unit, 22 | ) { 23 | val context = LocalContext.current 24 | Column(modifier = Modifier.fillMaxWidth()) { 25 | BottomSheetListItem( 26 | icon = LisuIcons.Refresh, 27 | title = stringResource(R.string.action_edit_cover), 28 | ) { onAction(GalleryAction.EditCover) } 29 | BottomSheetListItem( 30 | icon = LisuIcons.SaveAlt, 31 | title = stringResource(R.string.action_save_cover), 32 | ) { 33 | if (cover == null) context.toast("There is no cover.") 34 | else onAction(GalleryAction.SaveCover(cover)) 35 | } 36 | BottomSheetListItem( 37 | icon = LisuIcons.Share, 38 | title = stringResource(R.string.action_share_cover), 39 | ) { 40 | if (cover == null) context.toast("There is no cover.") 41 | else onAction(GalleryAction.ShareCover(cover)) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/gallery/GalleryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.gallery 2 | 3 | import android.os.Bundle 4 | import androidx.lifecycle.viewModelScope 5 | import com.fishhawk.lisu.data.database.ReadingHistoryRepository 6 | import com.fishhawk.lisu.data.network.LisuRepository 7 | import com.fishhawk.lisu.data.network.model.MangaDto 8 | import com.fishhawk.lisu.data.network.model.MangaMetadata 9 | import com.fishhawk.lisu.data.network.model.MangaState 10 | import com.fishhawk.lisu.ui.base.BaseViewModel 11 | import com.fishhawk.lisu.ui.base.Event 12 | import kotlinx.coroutines.flow.SharingStarted 13 | import kotlinx.coroutines.flow.filterNotNull 14 | import kotlinx.coroutines.flow.map 15 | import kotlinx.coroutines.flow.stateIn 16 | import kotlinx.coroutines.launch 17 | import kotlinx.serialization.json.Json 18 | 19 | sealed interface GalleryEffect : Event { 20 | data class AddToLibraryFailure(val exception: Throwable) : GalleryEffect 21 | data class RemoveFromLibraryFailure(val exception: Throwable) : GalleryEffect 22 | 23 | object UpdateMetadataSuccess : GalleryEffect 24 | data class UpdateMetadataFailure(val exception: Throwable) : GalleryEffect 25 | 26 | object UpdateCoverSuccess : GalleryEffect 27 | data class UpdateCoverFailure(val exception: Throwable) : GalleryEffect 28 | } 29 | 30 | class GalleryViewModel( 31 | args: Bundle, 32 | private val lisuRepository: LisuRepository, 33 | readingHistoryRepository: ReadingHistoryRepository, 34 | ) : BaseViewModel() { 35 | val manga = Json.decodeFromString(MangaDto.serializer(), args.getString("manga")!!) 36 | 37 | val providerId = manga.providerId 38 | val mangaId = manga.id 39 | 40 | val searchBoardId = lisuRepository 41 | .providers.value?.value?.getOrNull() 42 | ?.find { it.id == providerId } 43 | ?.searchBoardId 44 | 45 | private val _detail = lisuRepository.getManga(manga.providerId, manga.id) 46 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 47 | 48 | val detail = _detail 49 | .filterNotNull() 50 | .map { it.value } 51 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 52 | 53 | val history = readingHistoryRepository.select(manga.id) 54 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 55 | 56 | private val _comments = lisuRepository 57 | .getComment(manga.providerId, manga.id) 58 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) 59 | 60 | val comments = _comments 61 | .map { it?.value } 62 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) 63 | 64 | fun reloadManga() { 65 | viewModelScope.launch { 66 | _detail.value?.reload() 67 | } 68 | } 69 | 70 | fun reloadComments() { 71 | viewModelScope.launch { 72 | _comments.value?.reload() 73 | } 74 | } 75 | 76 | fun requestCommentsNextPage() { 77 | viewModelScope.launch { 78 | _comments.value?.requestNextPage() 79 | } 80 | } 81 | 82 | fun addToLibrary() { 83 | if (manga.state != MangaState.Remote) return 84 | viewModelScope.launch { 85 | lisuRepository.addMangaToLibrary( 86 | manga.providerId, manga.id 87 | ).onFailure { 88 | sendEvent(GalleryEffect.AddToLibraryFailure(it)) 89 | } 90 | } 91 | } 92 | 93 | fun removeFromLibrary() { 94 | if (manga.state != MangaState.RemoteInLibrary) return 95 | viewModelScope.launch { 96 | lisuRepository.removeMangaFromLibrary( 97 | manga.providerId, manga.id 98 | ).onFailure { 99 | sendEvent(GalleryEffect.RemoveFromLibraryFailure(it)) 100 | } 101 | } 102 | } 103 | 104 | fun updateCover(cover: ByteArray, coverType: String) { 105 | viewModelScope.launch { 106 | lisuRepository.updateMangaCover( 107 | manga.providerId, manga.id, cover, coverType 108 | ).onSuccess { 109 | sendEvent(GalleryEffect.UpdateCoverSuccess) 110 | }.onFailure { 111 | sendEvent(GalleryEffect.UpdateCoverFailure(it)) 112 | } 113 | } 114 | } 115 | 116 | fun updateMetadata(metadata: MangaMetadata) { 117 | if (manga.state != MangaState.Local) return 118 | viewModelScope.launch { 119 | lisuRepository.updateMangaMetadata( 120 | manga.providerId, manga.id, metadata 121 | ).onSuccess { 122 | sendEvent(GalleryEffect.UpdateMetadataSuccess) 123 | }.onFailure { 124 | sendEvent(GalleryEffect.UpdateMetadataFailure(it)) 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/globalsearch/GlobalSearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.globalsearch 2 | 3 | import android.os.Bundle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.fishhawk.lisu.data.database.SearchHistoryRepository 7 | import com.fishhawk.lisu.data.database.model.SearchHistory 8 | import com.fishhawk.lisu.data.network.LisuRepository 9 | import com.fishhawk.lisu.data.network.base.RemoteList 10 | import com.fishhawk.lisu.data.network.model.BoardFilterValue 11 | import com.fishhawk.lisu.data.network.model.BoardId 12 | import com.fishhawk.lisu.data.network.model.MangaDto 13 | import com.fishhawk.lisu.data.network.model.ProviderDto 14 | import com.fishhawk.lisu.util.flatCombine 15 | import com.fishhawk.lisu.util.flatten 16 | import kotlinx.coroutines.flow.* 17 | import kotlinx.coroutines.launch 18 | 19 | data class SearchRecord( 20 | val searchBoardId: BoardId, 21 | val provider: ProviderDto, 22 | val remoteList: RemoteList, 23 | ) 24 | 25 | class GlobalSearchViewModel( 26 | args: Bundle, 27 | private val lisuRepository: LisuRepository, 28 | private val searchHistoryRepository: SearchHistoryRepository, 29 | ) : ViewModel() { 30 | 31 | private val _keywords = MutableStateFlow(args.getString("keywords") ?: "") 32 | val keywords = _keywords.asStateFlow() 33 | 34 | private val providers = 35 | lisuRepository.providers 36 | .map { remoteProvider -> 37 | remoteProvider?.value?.map { providers -> 38 | providers.mapNotNull { provider -> 39 | provider.searchBoardId?.let { it to provider } 40 | } 41 | } 42 | } 43 | 44 | val suggestions = searchHistoryRepository.list() 45 | .map { list -> list.map { it.keywords }.distinct() } 46 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 47 | 48 | val searchRecordsResult = 49 | flatCombine( 50 | keywords 51 | .filter { it.isNotBlank() } 52 | .onEach { searchHistoryRepository.update(SearchHistory("", it)) }, 53 | providers, 54 | ) { keywords, providersResult -> 55 | flatten( 56 | providersResult?.map { providers -> 57 | flatten( 58 | providers.map { (boardId, provider) -> 59 | lisuRepository.getBoard( 60 | providerId = provider.id, 61 | boardId = boardId, 62 | filterValues = BoardFilterValue.Empty, 63 | keywords = keywords, 64 | ).map { 65 | SearchRecord( 66 | searchBoardId = boardId, 67 | provider = provider, 68 | remoteList = it, 69 | ) 70 | } 71 | } 72 | ) 73 | } 74 | ) 75 | }.stateIn(viewModelScope, SharingStarted.Lazily, null) 76 | 77 | fun search(keywords: String) { 78 | _keywords.value = keywords 79 | } 80 | 81 | fun reload(providerId: String) { 82 | viewModelScope.launch { 83 | searchRecordsResult.value?.getOrNull() 84 | ?.find { it.provider.id == providerId } 85 | ?.remoteList?.reload() 86 | } 87 | } 88 | 89 | fun reloadProviders() { 90 | viewModelScope.launch { 91 | lisuRepository.providers.value?.reload() 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/history/HistoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.history 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.fishhawk.lisu.data.database.ReadingHistoryRepository 6 | import com.fishhawk.lisu.data.database.model.ReadingHistory 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.coroutines.flow.stateIn 10 | import kotlinx.coroutines.launch 11 | 12 | class HistoryViewModel ( 13 | private val repository: ReadingHistoryRepository 14 | ) : ViewModel() { 15 | 16 | val histories = repository.list() 17 | .map { list -> list.groupBy { it.date.toLocalDate() } } 18 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyMap()) 19 | 20 | fun deleteHistory(history: ReadingHistory) = viewModelScope.launch { 21 | repository.delete(history) 22 | } 23 | 24 | fun clearHistory() = viewModelScope.launch { 25 | repository.clear() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/library/LibraryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.library 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.fishhawk.lisu.data.database.SearchHistoryRepository 5 | import com.fishhawk.lisu.data.network.LisuRepository 6 | import com.fishhawk.lisu.data.network.model.MangaDto 7 | import com.fishhawk.lisu.data.network.model.MangaKeyDto 8 | import com.fishhawk.lisu.ui.base.BaseViewModel 9 | import com.fishhawk.lisu.ui.base.Event 10 | import kotlinx.coroutines.flow.* 11 | import kotlinx.coroutines.launch 12 | 13 | sealed interface LibraryEvent : Event { 14 | data class GetRandomSuccess(val manga: MangaDto) : LibraryEvent 15 | data class GetRandomFailure(val exception: Throwable) : LibraryEvent 16 | data class DeleteMultipleFailure(val exception: Throwable) : LibraryEvent 17 | data class RefreshFailure(val exception: Throwable) : LibraryEvent 18 | } 19 | 20 | class LibraryViewModel( 21 | private val lisuRepository: LisuRepository, 22 | searchHistoryRepo: SearchHistoryRepository, 23 | ) : BaseViewModel() { 24 | 25 | private val _keywords = MutableStateFlow("") 26 | val keywords = _keywords.asStateFlow() 27 | 28 | val suggestions = searchHistoryRepo.list() 29 | .map { list -> list.map { it.keywords }.distinct() } 30 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) 31 | 32 | private val _mangas = 33 | keywords 34 | .flatMapLatest { lisuRepository.searchFromLibrary(it) } 35 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 36 | 37 | val mangas = 38 | _mangas 39 | .filterNotNull() 40 | .map { it.value } 41 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 42 | 43 | private val _isRefreshing = MutableStateFlow(false) 44 | val isRefreshing = _isRefreshing.asStateFlow() 45 | 46 | fun getRandomManga() { 47 | viewModelScope.launch { 48 | lisuRepository.getRandomMangaFromLibrary() 49 | .onSuccess { sendEvent(LibraryEvent.GetRandomSuccess(it)) } 50 | .onFailure { sendEvent(LibraryEvent.GetRandomFailure(it)) } 51 | } 52 | } 53 | 54 | fun deleteMultipleManga(mangas: List) { 55 | viewModelScope.launch { 56 | lisuRepository.removeMultipleMangasFromLibrary(mangas) 57 | .onSuccess { _mangas.value?.reload() } 58 | .onFailure { sendEvent(LibraryEvent.DeleteMultipleFailure(it)) } 59 | } 60 | } 61 | 62 | fun search(keywords: String) { 63 | _keywords.value = keywords 64 | } 65 | 66 | fun reload() { 67 | viewModelScope.launch { 68 | _mangas.value?.reload() 69 | } 70 | } 71 | 72 | fun refresh() { 73 | viewModelScope.launch { 74 | if (_isRefreshing.value) return@launch 75 | _isRefreshing.value = true 76 | _mangas.value?.refresh() 77 | ?.onFailure { sendEvent(LibraryEvent.RefreshFailure(it)) } 78 | _isRefreshing.value = false 79 | } 80 | } 81 | 82 | fun requestNextPage() { 83 | viewModelScope.launch { 84 | _mangas.value?.requestNextPage() 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.main 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.fishhawk.lisu.BuildConfig 5 | import com.fishhawk.lisu.PR 6 | import com.fishhawk.lisu.data.datastore.get 7 | import com.fishhawk.lisu.data.network.GitHubRepository 8 | import com.fishhawk.lisu.data.network.model.GitHubRelease 9 | import com.fishhawk.lisu.ui.base.BaseViewModel 10 | import com.fishhawk.lisu.ui.base.Event 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | import java.io.File 15 | import java.time.Duration 16 | import java.time.Instant 17 | 18 | sealed interface MainEvent : Event { 19 | object NoNewUpdates : MainEvent 20 | data class CheckUpdateFailure(val exception: Throwable) : MainEvent 21 | data class ShowUpdateDialog(val release: GitHubRelease) : MainEvent 22 | object AlreadyDownloading : MainEvent 23 | object NotifyDownloadStart : MainEvent 24 | data class NotifyDownloadProgress(val progress: Float) : MainEvent 25 | data class NotifyDownloadFinish(val file: File) : MainEvent 26 | data class NotifyDownloadError(val url: String) : MainEvent 27 | } 28 | 29 | class MainViewModel( 30 | private val repo: GitHubRepository, 31 | ) : BaseViewModel() { 32 | private var isDownloading = false 33 | 34 | fun checkForUpdate(isUserPrompt: Boolean = false) { 35 | viewModelScope.launch { 36 | if (isUserPrompt.not() && 37 | Duration.between( 38 | Instant.ofEpochSecond(PR.lastAppCheckTime.get()), 39 | Instant.now() 40 | ) < Duration.ofDays(1) 41 | ) { 42 | return@launch 43 | } 44 | 45 | if (isDownloading) { 46 | sendEvent(MainEvent.AlreadyDownloading) 47 | return@launch 48 | } 49 | 50 | repo.getLatestRelease(lisuOwner, lisuRepo) 51 | .onSuccess { 52 | if (isNewVersion(it.version)) { 53 | sendEvent(MainEvent.ShowUpdateDialog(it)) 54 | } else if (isUserPrompt) { 55 | sendEvent(MainEvent.NoNewUpdates) 56 | } 57 | } 58 | .onFailure { 59 | if (isUserPrompt) { 60 | sendEvent(MainEvent.CheckUpdateFailure(it)) 61 | } 62 | } 63 | PR.lastAppCheckTime.set(Instant.now().epochSecond) 64 | } 65 | } 66 | 67 | private fun isNewVersion(versionTag: String): Boolean { 68 | val newVersion = versionTag.replace("[^\\d.]".toRegex(), "") 69 | return newVersion != BuildConfig.VERSION_NAME 70 | } 71 | 72 | fun downloadApk(dir: File?, downloadUrl: String) { 73 | if (isDownloading) return 74 | viewModelScope.launch { 75 | isDownloading = true 76 | val apkFile = File(dir, "update.apk") 77 | sendEvent(MainEvent.NotifyDownloadStart) 78 | 79 | var lastInstant = Instant.now() 80 | 81 | repo.downloadReleaseFile( 82 | url = downloadUrl, 83 | listener = { bytesSentTotal, contentLength -> 84 | val progress = bytesSentTotal.toFloat() / contentLength 85 | val currentInstant = Instant.now() 86 | if (Duration.between(lastInstant, currentInstant) > Duration.ofMillis(200)) { 87 | lastInstant = currentInstant 88 | sendEventSync(MainEvent.NotifyDownloadProgress(progress)) 89 | } 90 | } 91 | ) 92 | .mapCatching { 93 | withContext(Dispatchers.IO) { 94 | it.copyTo(apkFile.outputStream()) 95 | } 96 | } 97 | .onSuccess { sendEvent(MainEvent.NotifyDownloadFinish(apkFile)) } 98 | .onFailure { sendEvent(MainEvent.NotifyDownloadError(downloadUrl)) } 99 | isDownloading = false 100 | } 101 | } 102 | 103 | companion object { 104 | private const val lisuOwner = "FishHawk" 105 | private const val lisuRepo = "lisu-android" 106 | } 107 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/main/NavigateHelper.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.main 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Bundle 7 | import androidx.core.os.bundleOf 8 | import androidx.navigation.NavHostController 9 | import androidx.navigation.NavType 10 | import com.fishhawk.lisu.data.network.model.BoardId 11 | import com.fishhawk.lisu.data.network.model.MangaDetailDto 12 | import com.fishhawk.lisu.data.network.model.MangaDto 13 | import com.fishhawk.lisu.ui.reader.ReaderActivity 14 | import kotlinx.serialization.json.Json 15 | 16 | object MangaNavType : NavType(isNullableAllowed = true) { 17 | override fun get(bundle: Bundle, key: String): MangaDto? { 18 | return bundle.getString(key)?.let { parseValue(it) } 19 | } 20 | 21 | override fun parseValue(value: String): MangaDto { 22 | return Json.decodeFromString(MangaDto.serializer(), value) 23 | } 24 | 25 | override fun put(bundle: Bundle, key: String, value: MangaDto) { 26 | bundle.putString(key, Json.encodeToString(MangaDto.serializer(), value)) 27 | } 28 | } 29 | 30 | fun NavHostController.navToLoginWebsite(providerId: String) { 31 | navigate("provider/${providerId}/login-website") 32 | } 33 | 34 | fun NavHostController.navToLoginCookies(providerId: String) { 35 | navigate("provider/${providerId}/login-cookies") 36 | } 37 | 38 | fun NavHostController.navToLoginPassword(providerId: String) { 39 | navigate("provider/${providerId}/login-password") 40 | } 41 | 42 | fun NavHostController.navToProvider( 43 | providerId: String, 44 | boardId: BoardId, 45 | keywords: String? = null, 46 | ) { 47 | val query = keywords?.let { "?keywords=${Uri.encode(keywords)}" } ?: "" 48 | navigate("provider/${providerId}/board/${boardId.name}$query") 49 | } 50 | 51 | fun NavHostController.navToGlobalSearch(keywords: String? = null) { 52 | val query = keywords?.let { "?keywords=${Uri.encode(keywords)}" } ?: "" 53 | navigate("global-search$query") 54 | } 55 | 56 | fun NavHostController.navToGallery(manga: MangaDto) { 57 | val json = Uri.encode(Json.encodeToString(MangaDto.serializer(), manga)) 58 | navigate("gallery/${manga.id}/detail?manga=${json}") 59 | } 60 | 61 | fun NavHostController.navToGalleryEdit() = navigate("edit") 62 | 63 | fun NavHostController.navToGalleryComment() = navigate("comment") 64 | 65 | fun NavHostController.navToDownload() = navigate("download") 66 | fun NavHostController.navToSettingGeneral() = navigate("setting-general") 67 | fun NavHostController.navToSettingReader() = navigate("setting-reader") 68 | fun NavHostController.navToSettingAdvanced() = navigate("setting-advanced") 69 | fun NavHostController.navToAbout() = navigate("about") 70 | fun NavHostController.navToOpenSourceLicense() = navigate("open-source-license") 71 | 72 | fun Context.navToReader( 73 | providerId: String, 74 | mangaId: String, 75 | collectionId: String, 76 | chapterId: String, 77 | page: Int = 0, 78 | ) { 79 | val bundle = bundleOf( 80 | "mangaId" to mangaId, 81 | "providerId" to providerId, 82 | "collectionId" to collectionId, 83 | "chapterId" to chapterId, 84 | "page" to page 85 | ) 86 | val intent = Intent(this, ReaderActivity::class.java) 87 | intent.putExtras(bundle) 88 | startActivity(intent) 89 | } 90 | 91 | fun Context.navToReader( 92 | detail: MangaDetailDto, 93 | collectionId: String, 94 | chapterId: String, 95 | page: Int, 96 | ) { 97 | val bundle = bundleOf( 98 | "detail" to Json.encodeToString(MangaDetailDto.serializer(), detail), 99 | "collectionId" to collectionId, 100 | "chapterId" to chapterId, 101 | "page" to page 102 | ) 103 | val intent = Intent(this, ReaderActivity::class.java) 104 | intent.putExtras(bundle) 105 | startActivity(intent) 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/more/MoreViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.more 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.fishhawk.lisu.PR 6 | import com.fishhawk.lisu.data.database.ServerHistoryRepository 7 | import com.fishhawk.lisu.data.database.model.ServerHistory 8 | import com.fishhawk.lisu.data.datastore.getBlocking 9 | import kotlinx.coroutines.flow.SharingStarted 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.stateIn 12 | import kotlinx.coroutines.launch 13 | 14 | class MoreViewModel ( 15 | private val repository: ServerHistoryRepository 16 | ) : ViewModel() { 17 | val address = PR.serverAddress.let { 18 | it.flow.stateIn(viewModelScope, SharingStarted.Eagerly, it.getBlocking()) 19 | } 20 | 21 | val suggestions = repository.list() 22 | .map { list -> list.map { it.address } } 23 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 24 | 25 | fun updateAddress(address: String) = viewModelScope.launch { 26 | PR.serverAddress.set(address) 27 | repository.update(ServerHistory(address = address)) 28 | } 29 | 30 | fun deleteSuggestion(address: String) = viewModelScope.launch { 31 | repository.deleteByAddress(address) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/more/OpenSourceLicenseScreen.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.more 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material3.ExperimentalMaterial3Api 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.navigation.NavHostController 10 | import com.fishhawk.lisu.R 11 | import com.fishhawk.lisu.widget.LisuScaffold 12 | import com.fishhawk.lisu.widget.LisuToolBar 13 | import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer 14 | 15 | @OptIn(ExperimentalMaterial3Api::class) 16 | @Composable 17 | fun OpenSourceLicenseScreen(navController: NavHostController) { 18 | LisuScaffold( 19 | topBar = { 20 | LisuToolBar( 21 | title = stringResource(R.string.label_open_source_license), 22 | onNavUp = { navController.navigateUp() } 23 | ) 24 | }, 25 | content = { paddingValues -> 26 | LibrariesContainer( 27 | modifier = Modifier 28 | .padding(paddingValues) 29 | .fillMaxSize() 30 | ) 31 | } 32 | ) 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/more/Preference.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.more 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.material3.* 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.vector.ImageVector 8 | import androidx.compose.ui.res.stringResource 9 | import com.fishhawk.lisu.data.datastore.Preference 10 | import com.fishhawk.lisu.data.datastore.collectAsState 11 | import com.fishhawk.lisu.data.datastore.get 12 | import com.fishhawk.lisu.widget.LisuSelectDialog 13 | import com.fishhawk.lisu.widget.m3.LisuSwitch 14 | import kotlinx.coroutines.launch 15 | 16 | @OptIn(ExperimentalMaterial3Api::class) 17 | @Composable 18 | fun BasePreference( 19 | icon: ImageVector?, 20 | title: String, 21 | summary: String?, 22 | onClick: () -> Unit, 23 | trailing: @Composable (() -> Unit)? = null, 24 | ) { 25 | ListItem( 26 | headlineContent = { Text(text = title) }, 27 | modifier = Modifier.clickable(onClick = { onClick() }), 28 | leadingContent = icon?.let { 29 | { Icon(it, contentDescription = "", tint = MaterialTheme.colorScheme.primary) } 30 | }, 31 | supportingContent = summary?.let { { Text(text = it) } }, 32 | trailingContent = trailing 33 | ) 34 | } 35 | 36 | @Composable 37 | fun TextPreference( 38 | icon: ImageVector? = null, 39 | title: String, 40 | summary: String? = null, 41 | onClick: () -> Unit = {}, 42 | ) = BasePreference(icon, title, summary, onClick) 43 | 44 | @Composable 45 | fun SwitchPreference( 46 | icon: ImageVector? = null, 47 | title: String, 48 | summary: String? = null, 49 | preference: Preference, 50 | ) { 51 | val scope = rememberCoroutineScope() 52 | BasePreference( 53 | icon = icon, 54 | title = title, 55 | summary = summary, 56 | onClick = { scope.launch { preference.set(!preference.get()) } } 57 | ) { 58 | val checked by preference.collectAsState() 59 | LisuSwitch(checked = checked, onCheckedChange = null) 60 | } 61 | } 62 | 63 | @Composable 64 | inline fun > ListPreference( 65 | icon: ImageVector? = null, 66 | title: String, 67 | summary: String? = "%s", 68 | preference: Preference, 69 | crossinline translate: (T) -> Int, 70 | ) { 71 | val isOpen = remember { mutableStateOf(false) } 72 | val selected by preference.collectAsState() 73 | 74 | BasePreference( 75 | icon = icon, 76 | title = title, 77 | summary = summary?.format(selected.name), 78 | onClick = { isOpen.value = true } 79 | ) { 80 | if (isOpen.value) { 81 | val scope = rememberCoroutineScope() 82 | LisuSelectDialog( 83 | title = title, 84 | options = enumValues().map { stringResource(translate(it)) }, 85 | selected = selected.ordinal, 86 | onSelectedChanged = { scope.launch { preference.set(enumValues()[it]) } }, 87 | onDismiss = { isOpen.value = false }, 88 | ) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/provider/ProviderViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.provider 2 | 3 | import android.os.Bundle 4 | import androidx.lifecycle.viewModelScope 5 | import com.fishhawk.lisu.data.database.SearchHistoryRepository 6 | import com.fishhawk.lisu.data.database.model.SearchHistory 7 | import com.fishhawk.lisu.data.datastore.ProviderBrowseHistoryRepository 8 | import com.fishhawk.lisu.data.network.LisuRepository 9 | import com.fishhawk.lisu.data.network.base.PagedList 10 | import com.fishhawk.lisu.data.network.model.BoardFilterValue 11 | import com.fishhawk.lisu.data.network.model.BoardId 12 | import com.fishhawk.lisu.data.network.model.MangaDto 13 | import com.fishhawk.lisu.ui.base.BaseViewModel 14 | import com.fishhawk.lisu.ui.base.Event 15 | import com.fishhawk.lisu.util.flatCombine 16 | import kotlinx.coroutines.flow.* 17 | import kotlinx.coroutines.launch 18 | 19 | sealed interface ProviderEvent : Event { 20 | data class RemoveFromLibraryFailure(val exception: Throwable) : ProviderEvent 21 | data class AddToLibraryFailure(val exception: Throwable) : ProviderEvent 22 | data class RefreshFailure(val exception: Throwable) : ProviderEvent 23 | } 24 | 25 | data class Board( 26 | val filterValues: BoardFilterValue, 27 | val mangaResult: Result>?, 28 | ) 29 | 30 | class ProviderViewModel( 31 | args: Bundle, 32 | private val lisuRepository: LisuRepository, 33 | private val providerBrowseHistoryRepository: ProviderBrowseHistoryRepository, 34 | private val searchHistoryRepository: SearchHistoryRepository, 35 | ) : BaseViewModel() { 36 | val providerId = args.getString("providerId")!! 37 | val boardId = BoardId.valueOf(args.getString("boardId")!!) 38 | 39 | private val boardModel = lisuRepository 40 | .providers.value!!.value!!.getOrThrow() 41 | .find { provider -> provider.id == providerId }!! 42 | .boardModels[boardId]!! 43 | 44 | val hasAdvanceFilters = boardModel.advance.isNotEmpty() 45 | val hasSearchBar = boardModel.hasSearchBar 46 | 47 | private val _keywords = MutableStateFlow(args.getString("keywords") ?: "") 48 | val keywords = _keywords.asStateFlow() 49 | 50 | val suggestions = searchHistoryRepository.listByProvider(providerId) 51 | .map { list -> list.map { it.keywords }.distinct() } 52 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 53 | 54 | private val filterValues = 55 | providerBrowseHistoryRepository 56 | .getBoardFilterValue(providerId, boardId, boardModel) 57 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 58 | 59 | private val remoteMangaList = flatCombine( 60 | keywords.onEach { 61 | if (it.isNotBlank()) { 62 | searchHistoryRepository.update(SearchHistory(providerId, it)) 63 | } 64 | }, 65 | filterValues.filterNotNull(), 66 | ) { keywords, filterValues -> 67 | lisuRepository.getBoard(providerId, boardId, filterValues, keywords) 68 | }.stateIn(viewModelScope, SharingStarted.Eagerly, null) 69 | 70 | val board = combine( 71 | filterValues.filterNotNull(), 72 | remoteMangaList, 73 | ) { filterValues, remoteMangaList -> 74 | Board(filterValues, remoteMangaList?.value) 75 | }.stateIn(viewModelScope, SharingStarted.Eagerly, null) 76 | 77 | private val _isRefreshing = MutableStateFlow(false) 78 | val isRefreshing = _isRefreshing.asStateFlow() 79 | 80 | fun search(keywords: String) { 81 | _keywords.value = keywords 82 | } 83 | 84 | fun deleteSuggestion(keywords: String) = viewModelScope.launch { 85 | searchHistoryRepository.deleteByKeywords(providerId, keywords) 86 | } 87 | 88 | fun addToLibrary(manga: MangaDto) { 89 | viewModelScope.launch { 90 | lisuRepository.addMangaToLibrary(manga.providerId, manga.id) 91 | .onFailure { sendEvent(ProviderEvent.AddToLibraryFailure(it)) } 92 | } 93 | } 94 | 95 | fun removeFromLibrary(manga: MangaDto) { 96 | viewModelScope.launch { 97 | lisuRepository.removeMangaFromLibrary(manga.providerId, manga.id) 98 | .onFailure { sendEvent(ProviderEvent.RemoveFromLibraryFailure(it)) } 99 | } 100 | } 101 | 102 | fun updateFilterHistory(name: String, value: Any) { 103 | viewModelScope.launch { 104 | providerBrowseHistoryRepository.setFilterValue(providerId, boardId, name, value) 105 | } 106 | } 107 | 108 | fun updateFilterHistory(values: Map) { 109 | viewModelScope.launch { 110 | values.map { (name, value) -> 111 | providerBrowseHistoryRepository.setFilterValue(providerId, boardId, name, value) 112 | } 113 | } 114 | } 115 | 116 | fun reload() { 117 | viewModelScope.launch { 118 | remoteMangaList.value?.reload() 119 | } 120 | } 121 | 122 | fun refresh() { 123 | viewModelScope.launch { 124 | if (_isRefreshing.value) return@launch 125 | _isRefreshing.value = true 126 | remoteMangaList.value?.refresh() 127 | ?.onFailure { sendEvent(ProviderEvent.RefreshFailure(it)) } 128 | _isRefreshing.value = false 129 | } 130 | } 131 | 132 | fun requestNextPage() { 133 | viewModelScope.launch { 134 | remoteMangaList.value?.requestNextPage() 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/ReaderActivity.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader 2 | 3 | import android.os.Bundle 4 | import android.view.WindowManager 5 | import androidx.activity.compose.setContent 6 | import androidx.lifecycle.lifecycleScope 7 | import com.fishhawk.lisu.PR 8 | import com.fishhawk.lisu.ui.base.BaseActivity 9 | import com.fishhawk.lisu.ui.theme.LisuTheme 10 | import kotlinx.coroutines.flow.combine 11 | import kotlinx.coroutines.flow.launchIn 12 | import kotlinx.coroutines.flow.onEach 13 | 14 | class ReaderActivity : BaseActivity() { 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | 18 | PR.keepScreenOn.flow 19 | .onEach { setFlag(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, it) } 20 | .launchIn(lifecycleScope) 21 | 22 | combine( 23 | PR.enableCustomBrightness.flow, 24 | PR.customBrightness.flow 25 | ) { isEnabled, brightness -> 26 | val attrBrightness = 27 | if (isEnabled) brightness.coerceIn(0f, 1f) 28 | else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE 29 | window.attributes = window.attributes.apply { screenBrightness = attrBrightness } 30 | }.launchIn(lifecycleScope) 31 | 32 | setContent { 33 | LisuTheme { 34 | ReaderScreen() 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/ReaderOverlaySheet.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.BrightnessHigh 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.BlendMode 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.graphics.vector.ImageVector 15 | import androidx.compose.ui.unit.dp 16 | import com.fishhawk.lisu.PR 17 | import com.fishhawk.lisu.R 18 | import com.fishhawk.lisu.data.datastore.ColorFilterMode 19 | import com.fishhawk.lisu.data.datastore.Preference 20 | import com.fishhawk.lisu.data.datastore.collectAsState 21 | import com.fishhawk.lisu.data.datastore.getBlocking 22 | import com.fishhawk.lisu.ui.more.ListPreference 23 | import com.fishhawk.lisu.ui.more.SwitchPreference 24 | import com.fishhawk.lisu.widget.LisuSlider 25 | import kotlinx.coroutines.launch 26 | 27 | @Composable 28 | fun ReaderColorFilterOverlay() { 29 | val isEnabled by PR.enabledColorFilter.collectAsState() 30 | 31 | if (isEnabled) { 32 | val mode by PR.colorFilterMode.collectAsState() 33 | val blendMode = when (mode) { 34 | ColorFilterMode.Default -> BlendMode.SrcOver 35 | ColorFilterMode.Multiply -> BlendMode.Multiply 36 | ColorFilterMode.Screen -> BlendMode.Screen 37 | ColorFilterMode.Overlay -> BlendMode.Overlay 38 | ColorFilterMode.Lighten -> BlendMode.Lighten 39 | ColorFilterMode.Darken -> BlendMode.Darken 40 | } 41 | 42 | val h by PR.colorFilterH.collectAsState() 43 | val s by PR.colorFilterS.collectAsState() 44 | val l by PR.colorFilterL.collectAsState() 45 | val a by PR.colorFilterA.collectAsState() 46 | val color = Color.hsl( 47 | h.coerceIn(0f, 1f) * 360, 48 | s.coerceIn(0f, 1f), 49 | l.coerceIn(0f, 1f), 50 | a.coerceIn(0f, 1f) 51 | ) 52 | 53 | Canvas(modifier = Modifier.fillMaxSize()) { 54 | drawRect(color = color, size = size, blendMode = blendMode) 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | internal fun ReaderOverlaySheetContent() { 61 | Column(modifier = Modifier.padding(8.dp)) { 62 | val colorFilterEnabled by PR.enabledColorFilter.collectAsState() 63 | SwitchPreference(title = "Custom color filter", preference = PR.enabledColorFilter) 64 | SliderPreference(colorFilterEnabled, label = "H", preference = PR.colorFilterH) 65 | SliderPreference(colorFilterEnabled, label = "S", preference = PR.colorFilterS) 66 | SliderPreference(colorFilterEnabled, label = "L", preference = PR.colorFilterL) 67 | SliderPreference(colorFilterEnabled, label = "A", preference = PR.colorFilterA) 68 | ListPreference(title = "Blend mode", preference = PR.colorFilterMode) { 69 | when (it) { 70 | ColorFilterMode.Default -> R.string.settings_filter_mode_default 71 | ColorFilterMode.Multiply -> R.string.settings_filter_mode_multiply 72 | ColorFilterMode.Screen -> R.string.settings_filter_mode_screen 73 | ColorFilterMode.Overlay -> R.string.settings_filter_mode_overlay 74 | ColorFilterMode.Lighten -> R.string.settings_filter_mode_lighten 75 | ColorFilterMode.Darken -> R.string.settings_filter_mode_darken 76 | } 77 | } 78 | 79 | val enableCustomBrightness by PR.enableCustomBrightness.collectAsState() 80 | SwitchPreference( 81 | title = "Custom color filter", 82 | preference = PR.enableCustomBrightness 83 | ) 84 | SliderPreference( 85 | enableCustomBrightness, 86 | icon = Icons.Filled.BrightnessHigh, 87 | preference = PR.customBrightness 88 | ) 89 | } 90 | } 91 | 92 | @Composable 93 | private fun SliderPreference( 94 | enabled: Boolean, 95 | label: String, 96 | preference: Preference, 97 | ) { 98 | Row( 99 | modifier = Modifier.padding(horizontal = 16.dp), 100 | verticalAlignment = Alignment.CenterVertically 101 | ) { 102 | val scope = rememberCoroutineScope() 103 | var p by remember { mutableStateOf(preference.getBlocking()) } 104 | Text(text = label) 105 | LisuSlider( 106 | modifier = Modifier.height(36.dp), 107 | enabled = enabled, 108 | value = p, 109 | onValueChange = { 110 | p = it 111 | scope.launch { preference.set(it) } 112 | }) 113 | } 114 | } 115 | 116 | @Composable 117 | private fun SliderPreference( 118 | enabled: Boolean, 119 | icon: ImageVector, 120 | preference: Preference, 121 | ) { 122 | Row( 123 | modifier = Modifier.padding(horizontal = 16.dp), 124 | verticalAlignment = Alignment.CenterVertically 125 | ) { 126 | val scope = rememberCoroutineScope() 127 | var p by remember { mutableStateOf(preference.getBlocking()) } 128 | Icon(icon, contentDescription = null) 129 | LisuSlider( 130 | modifier = Modifier.height(36.dp), 131 | enabled = enabled, 132 | value = p, 133 | onValueChange = { 134 | p = it 135 | scope.launch { preference.set(it) } 136 | }) 137 | } 138 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/ReaderPageSheet.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader 2 | 3 | import android.graphics.Bitmap 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material.icons.outlined.Image 7 | import androidx.compose.material.icons.outlined.SaveAlt 8 | import androidx.compose.material.icons.outlined.Share 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import com.fishhawk.lisu.R 13 | import com.fishhawk.lisu.ui.theme.LisuIcons 14 | import com.fishhawk.lisu.widget.BottomSheetListItem 15 | import java.io.File 16 | 17 | @Composable 18 | internal fun ReaderPageSheetContent( 19 | bitmap: Bitmap, 20 | position: Int, 21 | onAction: (ReaderAction) -> Unit, 22 | ) { 23 | Column(modifier = Modifier.fillMaxWidth()) { 24 | BottomSheetListItem( 25 | icon = LisuIcons.Image, 26 | title = stringResource(R.string.action_set_as_cover) 27 | ) { onAction(ReaderAction.SetAsCover(bitmap)) } 28 | BottomSheetListItem( 29 | icon = LisuIcons.SaveAlt, 30 | title = stringResource(R.string.action_save_image) 31 | ) { onAction(ReaderAction.SavePage(bitmap, position)) } 32 | BottomSheetListItem( 33 | icon = LisuIcons.Share, 34 | title = stringResource(R.string.action_share_image) 35 | ) { onAction(ReaderAction.SharePage(bitmap, position)) } 36 | } 37 | } 38 | 39 | @Composable 40 | internal fun ReaderPageGifSheetContent( 41 | file: File, 42 | position: Int, 43 | onAction: (ReaderAction) -> Unit, 44 | ) { 45 | Column(modifier = Modifier.fillMaxWidth()) { 46 | BottomSheetListItem( 47 | icon = LisuIcons.SaveAlt, 48 | title = stringResource(R.string.action_save_image) 49 | ) { onAction(ReaderAction.SaveGifPage(file, position)) } 50 | BottomSheetListItem( 51 | icon = LisuIcons.Share, 52 | title = stringResource(R.string.action_share_image) 53 | ) { onAction(ReaderAction.ShareGifPage(file, position)) } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/ReaderSettingsSheet.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.res.stringResource 8 | import androidx.compose.ui.unit.dp 9 | import com.fishhawk.lisu.PR 10 | import com.fishhawk.lisu.R 11 | import com.fishhawk.lisu.data.datastore.ScaleType 12 | import com.fishhawk.lisu.ui.more.ListPreference 13 | import com.fishhawk.lisu.ui.more.SwitchPreference 14 | 15 | @Composable 16 | internal fun ReaderSettingsSheetContent() { 17 | Column(modifier = Modifier.padding(8.dp)) { 18 | ListPreference( 19 | title = stringResource(R.string.settings_scale_type), 20 | preference = PR.scaleType 21 | ) { 22 | when (it) { 23 | ScaleType.FitScreen -> R.string.settings_scale_type_fit_screen 24 | ScaleType.FitWidth -> R.string.settings_scale_type_fit_width 25 | ScaleType.FitHeight -> R.string.settings_scale_type_fit_height 26 | ScaleType.OriginalSize -> R.string.settings_scale_type_original_size 27 | } 28 | } 29 | 30 | SwitchPreference( 31 | title = stringResource(R.string.settings_is_page_interval_enabled), 32 | preference = PR.isPageIntervalEnabled 33 | ) 34 | SwitchPreference( 35 | title = stringResource(R.string.settings_show_info_bar), 36 | preference = PR.showInfoBar 37 | ) 38 | SwitchPreference( 39 | title = stringResource(R.string.settings_is_long_tap_dialog_enabled), 40 | preference = PR.isLongTapDialogEnabled 41 | ) 42 | SwitchPreference( 43 | title = stringResource(R.string.settings_is_area_interpolation_enabled), 44 | preference = PR.isAreaInterpolationEnabled 45 | ) 46 | 47 | SwitchPreference( 48 | title = stringResource(R.string.settings_keep_screen_on), 49 | preference = PR.keepScreenOn 50 | ) 51 | SwitchPreference( 52 | title = stringResource(R.string.settings_use_volume_key), 53 | preference = PR.useVolumeKey 54 | ) 55 | SwitchPreference( 56 | title = stringResource(R.string.settings_invert_volume_key), 57 | preference = PR.invertVolumeKey 58 | ) 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/viewer/NestedScroll.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader.viewer 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 5 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource 6 | import androidx.compose.ui.unit.Velocity 7 | 8 | fun nestedScrollConnection( 9 | requestMoveToPrevChapter: () -> Unit, 10 | requestMoveToNextChapter: () -> Unit, 11 | isPrepareToPrev: (offset: Offset) -> Boolean, 12 | isPrepareToNext: (offset: Offset) -> Boolean 13 | ) = object : NestedScrollConnection { 14 | var prepareToNext = false 15 | var prepareToPrev = false 16 | 17 | override fun onPostScroll( 18 | consumed: Offset, 19 | available: Offset, 20 | source: NestedScrollSource 21 | ): Offset { 22 | if (source == NestedScrollSource.Drag) { 23 | prepareToPrev = isPrepareToPrev(available) 24 | prepareToNext = isPrepareToNext(available) 25 | } 26 | return Offset.Zero 27 | } 28 | 29 | override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { 30 | if (prepareToNext) { 31 | prepareToNext = false 32 | requestMoveToNextChapter() 33 | } else if (prepareToPrev) { 34 | prepareToPrev = false 35 | requestMoveToPrevChapter() 36 | } 37 | return Velocity.Zero 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/viewer/Page.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader.viewer 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.CircularProgressIndicator 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.material3.TextButton 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.layout.ContentScale 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.platform.LocalLayoutDirection 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.LayoutDirection 19 | import androidx.compose.ui.unit.dp 20 | import coil.compose.AsyncImagePainter 21 | import coil.compose.rememberAsyncImagePainter 22 | import coil.request.ImageRequest 23 | import coil.size.Size 24 | import com.fishhawk.lisu.R 25 | import com.fishhawk.lisu.ui.reader.ReaderPage 26 | import com.fishhawk.lisu.util.interceptor.ProgressInterceptor 27 | import com.fishhawk.lisu.widget.StateView 28 | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull 29 | 30 | @Composable 31 | internal fun EmptyPage( 32 | modifier: Modifier = Modifier, 33 | ) { 34 | Box( 35 | modifier = modifier, 36 | contentAlignment = Alignment.Center 37 | ) { 38 | Text( 39 | modifier = Modifier.padding(48.dp), 40 | text = "Chapter is empty", 41 | style = MaterialTheme.typography.titleMedium, 42 | textAlign = TextAlign.Center 43 | ) 44 | } 45 | } 46 | 47 | @Composable 48 | internal fun NextChapterStatePage( 49 | page: ReaderPage.NextChapterState, 50 | modifier: Modifier = Modifier, 51 | ) { 52 | CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { 53 | Box( 54 | modifier = modifier, 55 | contentAlignment = Alignment.Center 56 | ) { 57 | Column(modifier = Modifier.padding(48.dp)) { 58 | val style1 = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) 59 | val style2 = MaterialTheme.typography.titleMedium 60 | val currChapterText = "${page.currentChapterName} ${page.currentChapterTitle}" 61 | val nextChapterText = "${page.nextChapterName} ${page.nextChapterTitle}" 62 | 63 | Text(text = "Current:", style = style1) 64 | Text(text = currChapterText, style = style2) 65 | Spacer(modifier = Modifier.height(16.dp)) 66 | Text(text = "Next:", style = style1) 67 | Text(text = nextChapterText, style = style2) 68 | 69 | StateView( 70 | result = page.nextChapterState, 71 | onRetry = { }, 72 | ) { _ -> 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | @Composable 80 | internal fun PrevChapterStatePage( 81 | page: ReaderPage.PrevChapterState, 82 | modifier: Modifier = Modifier, 83 | ) { 84 | CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { 85 | Box( 86 | modifier = modifier, 87 | contentAlignment = Alignment.Center 88 | ) { 89 | Column(modifier = Modifier.padding(48.dp)) { 90 | val style1 = 91 | MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium) 92 | val style2 = MaterialTheme.typography.titleMedium 93 | val prevChapterText = "${page.prevChapterName} ${page.prevChapterTitle}" 94 | val currChapterText = "${page.currentChapterName} ${page.currentChapterTitle}" 95 | 96 | Text(text = "Previous:", style = style1) 97 | Text(text = prevChapterText, style = style2) 98 | Spacer(modifier = Modifier.height(16.dp)) 99 | Text(text = "Current:", style = style1) 100 | Text(text = currChapterText, style = style2) 101 | } 102 | } 103 | } 104 | } 105 | 106 | @Composable 107 | internal fun ImagePage( 108 | page: ReaderPage.Image, 109 | contentScale: ContentScale, 110 | modifier: Modifier = Modifier, 111 | stateModifier: Modifier = Modifier, 112 | ) { 113 | var retryHash by remember { mutableStateOf(0) } 114 | val painter = rememberAsyncImagePainter( 115 | ImageRequest.Builder(LocalContext.current) 116 | .data(page.url) 117 | .size(Size.ORIGINAL) 118 | .setParameter("retry_hash", retryHash, memoryCacheKey = null) 119 | .build() 120 | ) 121 | when (val state = painter.state) { 122 | is AsyncImagePainter.State.Success -> 123 | Image( 124 | painter = painter, 125 | contentDescription = null, 126 | modifier = modifier, 127 | contentScale = contentScale, 128 | ) 129 | is AsyncImagePainter.State.Loading -> 130 | LoadingState( 131 | modifier = stateModifier, 132 | position = page.index + 1, 133 | url = page.url 134 | ) 135 | is AsyncImagePainter.State.Error -> 136 | ErrorState( 137 | modifier = stateModifier, 138 | position = page.index + 1, 139 | throwable = state.result.throwable, 140 | onRetry = { retryHash++ } 141 | ) 142 | AsyncImagePainter.State.Empty -> Unit 143 | } 144 | } 145 | 146 | @Composable 147 | private fun LoadingState(modifier: Modifier, position: Int, url: String) { 148 | Box(modifier = modifier) { 149 | Column( 150 | modifier = Modifier.align(Alignment.Center), 151 | horizontalAlignment = Alignment.CenterHorizontally, 152 | verticalArrangement = Arrangement.spacedBy(16.dp) 153 | ) { 154 | Text( 155 | text = position.toString(), 156 | style = MaterialTheme.typography.headlineLarge 157 | ) 158 | var progress by remember { mutableStateOf(0f) } 159 | if (progress > 0f) CircularProgressIndicator(progress = progress) 160 | else CircularProgressIndicator() 161 | 162 | DisposableEffect(Unit) { 163 | val key = url.toHttpUrlOrNull().toString() 164 | ProgressInterceptor.addListener(key) { progress = it } 165 | onDispose { 166 | ProgressInterceptor.removeListener(key) 167 | } 168 | } 169 | } 170 | } 171 | } 172 | 173 | @Composable 174 | private fun ErrorState( 175 | modifier: Modifier, 176 | position: Int, 177 | throwable: Throwable, 178 | onRetry: () -> Unit, 179 | ) { 180 | Box(modifier = modifier) { 181 | Column( 182 | modifier = Modifier.align(Alignment.Center), 183 | horizontalAlignment = Alignment.CenterHorizontally, 184 | verticalArrangement = Arrangement.spacedBy(16.dp) 185 | ) { 186 | Text( 187 | text = position.toString(), 188 | style = MaterialTheme.typography.headlineLarge 189 | ) 190 | Text( 191 | text = throwable.message ?: "", 192 | style = MaterialTheme.typography.bodyLarge, 193 | ) 194 | TextButton(onClick = { onRetry() }) { 195 | Text(stringResource(R.string.action_retry)) 196 | } 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/viewer/ViewerState.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader.viewer 2 | 3 | import androidx.annotation.IntRange 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.lazy.LazyListState 6 | import androidx.compose.foundation.pager.PagerState 7 | import com.fishhawk.lisu.ui.reader.ReaderPage 8 | 9 | sealed class ViewerState( 10 | val pages: List, 11 | val requestMoveToPrevChapter: () -> Unit, 12 | val requestMoveToNextChapter: () -> Unit, 13 | ) { 14 | @get:IntRange(from = 0) 15 | abstract val position: Int 16 | 17 | abstract val isRtl: Boolean 18 | 19 | abstract suspend fun scrollToPage(@IntRange(from = 0) page: Int) 20 | 21 | suspend fun scrollToImagePage(@IntRange(from = 0) index: Int) { 22 | scrollToPage( 23 | pages.indexOfFirst { 24 | it is ReaderPage.Image && it.index == index 25 | } 26 | ) 27 | } 28 | 29 | suspend fun toPrev() { 30 | if (position > 0) scrollToPage(position - 1) 31 | else requestMoveToPrevChapter() 32 | } 33 | 34 | suspend fun toNext() { 35 | if (position < pages.size - 1) scrollToPage(position + 1) 36 | else requestMoveToNextChapter() 37 | } 38 | 39 | suspend fun toLeft() = if (isRtl) toNext() else toPrev() 40 | suspend fun toRight() = if (isRtl) toPrev() else toNext() 41 | 42 | @OptIn(ExperimentalFoundationApi::class) 43 | class Pager( 44 | val state: PagerState, 45 | override val isRtl: Boolean, 46 | pages: List, 47 | requestMoveToPrevChapter: () -> Unit, 48 | requestMoveToNextChapter: () -> Unit, 49 | ) : ViewerState( 50 | pages = pages, 51 | requestMoveToPrevChapter = requestMoveToPrevChapter, 52 | requestMoveToNextChapter = requestMoveToNextChapter, 53 | ) { 54 | override val position: Int 55 | get() = state.currentPage 56 | 57 | override suspend fun scrollToPage(page: Int) { 58 | state.scrollToPage(page) 59 | } 60 | } 61 | 62 | class Webtoon( 63 | val state: LazyListState, 64 | pages: List, 65 | requestMoveToPrevChapter: () -> Unit, 66 | requestMoveToNextChapter: () -> Unit, 67 | ) : ViewerState( 68 | pages = pages, 69 | requestMoveToPrevChapter = requestMoveToPrevChapter, 70 | requestMoveToNextChapter = requestMoveToNextChapter, 71 | ) { 72 | override val isRtl = false 73 | 74 | override val position: Int 75 | get() = state.firstVisibleItemIndex 76 | 77 | override suspend fun scrollToPage(page: Int) { 78 | state.scrollToItem(page) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/reader/viewer/WebtoonViewer.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.reader.viewer 2 | 3 | import androidx.compose.foundation.focusable 4 | import androidx.compose.foundation.gestures.detectTapGestures 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.items 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.focus.FocusRequester 11 | import androidx.compose.ui.focus.focusRequester 12 | import androidx.compose.ui.input.nestedscroll.nestedScroll 13 | import androidx.compose.ui.input.pointer.pointerInput 14 | import androidx.compose.ui.layout.ContentScale 15 | import androidx.compose.ui.unit.dp 16 | import com.fishhawk.lisu.PR 17 | import com.fishhawk.lisu.data.datastore.collectAsState 18 | import com.fishhawk.lisu.ui.reader.ReaderPage 19 | 20 | @Composable 21 | internal fun WebtoonViewer( 22 | modifier: Modifier = Modifier, 23 | isMenuOpened: MutableState, 24 | state: ViewerState.Webtoon, 25 | onLongPress: (page: ReaderPage.Image) -> Unit, 26 | ) { 27 | val focusRequester = remember { FocusRequester() } 28 | LaunchedEffect(Unit) { 29 | focusRequester.requestFocus() 30 | } 31 | 32 | val nestedScrollConnection = remember { 33 | nestedScrollConnection( 34 | requestMoveToPrevChapter = state.requestMoveToPrevChapter, 35 | requestMoveToNextChapter = state.requestMoveToNextChapter, 36 | isPrepareToPrev = { it.y > 10 }, 37 | isPrepareToNext = { it.y < -10 }, 38 | ) 39 | } 40 | 41 | Box( 42 | modifier = modifier 43 | .focusRequester(focusRequester) 44 | .focusable() 45 | .nestedScroll(nestedScrollConnection) 46 | .pointerInput(Unit) { 47 | detectTapGestures( 48 | onLongPress = { offset -> 49 | state.state.layoutInfo.visibleItemsInfo.forEach { 50 | if (it.offset <= offset.y && it.offset + it.size >= offset.y) { 51 | val page = state.pages[it.index] 52 | if (page is ReaderPage.Image) onLongPress(page) 53 | } 54 | } 55 | }, 56 | onTap = { 57 | isMenuOpened.value = !isMenuOpened.value 58 | }, 59 | ) 60 | } 61 | ) { 62 | val isPageIntervalEnabled by PR.isPageIntervalEnabled.collectAsState() 63 | val itemSpacing = if (isPageIntervalEnabled) 16.dp else 0.dp 64 | 65 | LazyColumn( 66 | modifier = Modifier.fillMaxSize(), 67 | state = state.state, 68 | verticalArrangement = Arrangement.spacedBy(itemSpacing) 69 | ) { 70 | items(state.pages) { page -> 71 | val pageModifier = Modifier 72 | .fillMaxWidth() 73 | .height(240.dp) 74 | when (page) { 75 | is ReaderPage.Image -> { 76 | if (page.url.isBlank()) { 77 | EmptyPage(modifier = pageModifier) 78 | } else { 79 | ImagePage( 80 | page = page, 81 | contentScale = ContentScale.FillWidth, 82 | modifier = Modifier 83 | .fillMaxWidth() 84 | .wrapContentHeight(), 85 | stateModifier = pageModifier, 86 | ) 87 | } 88 | } 89 | is ReaderPage.NextChapterState -> 90 | NextChapterStatePage(page = page, modifier = pageModifier) 91 | is ReaderPage.PrevChapterState -> 92 | PrevChapterStatePage(page = page, modifier = pageModifier) 93 | } 94 | } 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.ui.theme 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.animation.core.MutableTransitionState 5 | import androidx.compose.animation.core.Spring 6 | import androidx.compose.animation.core.spring 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material3.ColorScheme 9 | import androidx.compose.material3.LocalContentColor 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.graphics.Color 13 | import com.fishhawk.lisu.PR 14 | import com.fishhawk.lisu.data.datastore.Theme 15 | import com.fishhawk.lisu.data.datastore.collectAsState 16 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 17 | 18 | val LisuIcons = Icons.Outlined 19 | 20 | @Composable 21 | fun MediumEmphasis(content: @Composable () -> Unit) { 22 | CompositionLocalProvider( 23 | LocalContentColor provides mediumEmphasisColor(), 24 | content = content, 25 | ) 26 | } 27 | 28 | @Composable 29 | fun mediumEmphasisColor(): Color { 30 | return LocalContentColor.current.copy(alpha = 0.70f) 31 | } 32 | 33 | @Composable 34 | fun LisuTheme(content: @Composable () -> Unit) { 35 | val theme by PR.theme.collectAsState() 36 | val colorScheme = when (theme) { 37 | Theme.Light -> ColorsLight 38 | Theme.Dark -> ColorsDark 39 | } 40 | 41 | MaterialTheme(colorScheme = animateColors(colorScheme)) { 42 | content() 43 | 44 | val controller = rememberSystemUiController() 45 | val isLight = theme == Theme.Light 46 | SideEffect { 47 | // hack, see https://github.com/google/accompanist/issues/683 48 | controller.setSystemBarsColor(color = Color.Transparent, darkIcons = true) 49 | controller.setSystemBarsColor(color = Color.Transparent, darkIcons = isLight) 50 | } 51 | } 52 | } 53 | 54 | @Composable 55 | fun LisuTransition( 56 | content: @Composable AnimatedVisibilityScope.() -> Unit, 57 | ) = AnimatedVisibility( 58 | visibleState = remember { 59 | MutableTransitionState(false) 60 | }.apply { targetState = true }, 61 | enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessLow)), 62 | exit = fadeOut(), 63 | content = content 64 | ) 65 | 66 | @Composable 67 | private fun animateColors(colors: ColorScheme): ColorScheme { 68 | val animationSpec = remember { 69 | spring(stiffness = 500f) 70 | } 71 | 72 | @Composable 73 | fun animateColor(color: Color): Color = animateColorAsState( 74 | targetValue = color, 75 | animationSpec = animationSpec 76 | ).value 77 | 78 | return colors.copy( 79 | primary = animateColor(colors.primary), 80 | primaryContainer = animateColor(colors.primaryContainer), 81 | secondary = animateColor(colors.secondary), 82 | secondaryContainer = animateColor(colors.secondaryContainer), 83 | background = animateColor(colors.background), 84 | surface = animateColor(colors.surface), 85 | surfaceVariant = animateColor(colors.surfaceVariant), 86 | surfaceTint = animateColor(colors.surfaceTint), 87 | ) 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/util/ContextExtension.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.util 2 | 3 | import android.app.Activity 4 | import android.content.* 5 | import android.content.pm.PackageManager 6 | import android.graphics.Bitmap 7 | import android.graphics.Bitmap.CompressFormat 8 | import android.graphics.drawable.Drawable 9 | import android.net.Uri 10 | import android.net.nsd.NsdManager 11 | import android.os.Build 12 | import android.os.Environment 13 | import android.provider.MediaStore 14 | import android.widget.Toast 15 | import androidx.core.content.ContextCompat 16 | import androidx.core.graphics.drawable.toBitmap 17 | import com.fishhawk.lisu.R 18 | import com.fishhawk.lisu.ui.base.BaseActivity 19 | import java.io.File 20 | import java.io.FileOutputStream 21 | import java.io.IOException 22 | import java.io.OutputStream 23 | 24 | fun Context.findActivity(): Activity { 25 | var context = this 26 | while (context is ContextWrapper) { 27 | if (context is Activity) return context 28 | context = context.baseContext 29 | } 30 | throw IllegalStateException("Permissions should be called in the context of an Activity") 31 | } 32 | 33 | fun Context.checkPermission(permission: String): Boolean { 34 | return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED 35 | } 36 | 37 | fun Context.ensurePermission(permission: String): Boolean { 38 | return checkPermission(permission).also { isGrant -> 39 | if (!isGrant) (findActivity() as BaseActivity).requestPermission(permission) 40 | } 41 | } 42 | 43 | fun Context.openWebPage(url: String) { 44 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) 45 | if (intent.resolveActivity(packageManager) != null) { 46 | startActivity(intent) 47 | } 48 | } 49 | 50 | private fun Context.saveImage( 51 | filename: String, 52 | type: String, 53 | onSave: (OutputStream) -> Unit, 54 | ) { 55 | try { 56 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 57 | val contentValues = ContentValues().apply { 58 | put(MediaStore.MediaColumns.DISPLAY_NAME, filename) 59 | put(MediaStore.MediaColumns.MIME_TYPE, type) 60 | put( 61 | MediaStore.MediaColumns.RELATIVE_PATH, 62 | Environment.DIRECTORY_PICTURES + File.separator + "Lisu" 63 | ) 64 | } 65 | val uri = contentResolver.insert( 66 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 67 | contentValues 68 | ) ?: throw IOException("Failed to create new MediaStore record.") 69 | contentResolver.openOutputStream(uri) 70 | } else { 71 | if (!ensurePermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)) 72 | throw IOException("Do not have write permission.") 73 | val imagesDir = File( 74 | Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), 75 | "Lisu" 76 | ) 77 | if (!imagesDir.exists()) imagesDir.mkdirs() 78 | val imageFile = File(imagesDir, "$filename.gif") 79 | FileOutputStream(imageFile) 80 | }?.use { 81 | onSave(it) 82 | toast(R.string.image_saved) 83 | } ?: throw IOException("Failed to open output stream.") 84 | } catch (e: Throwable) { 85 | toast(e) 86 | } 87 | } 88 | 89 | fun Context.saveGifFile(file: File, filename: String) { 90 | saveImage(filename, "image/gif") { 91 | file.inputStream().copyTo(it) 92 | } 93 | } 94 | 95 | fun Context.saveDrawable(image: Bitmap, filename: String) { 96 | saveImage(filename, "image/gif") { 97 | if (!image.compress(CompressFormat.PNG, 100, it)) 98 | throw IOException("Failed to save bitmap.") 99 | } 100 | } 101 | 102 | fun Context.saveDrawable(image: Drawable, filename: String) { 103 | saveDrawable(image.toBitmap(), filename) 104 | } 105 | 106 | fun Context.shareText(title: String, text: String) { 107 | val shareIntent = Intent().apply { 108 | action = Intent.ACTION_SEND 109 | type = "text/plain" 110 | putExtra(Intent.EXTRA_TEXT, text) 111 | } 112 | startActivity(Intent.createChooser(shareIntent, title)) 113 | } 114 | 115 | fun Context.shareGifFile(title: String, file: File, filename: String) { 116 | val uri = file.toUriCompat(this) 117 | val shareIntent = Intent().apply { 118 | action = Intent.ACTION_SEND 119 | type = "image/gif" 120 | putExtra(Intent.EXTRA_STREAM, uri) 121 | } 122 | startActivity(Intent.createChooser(shareIntent, title)) 123 | } 124 | 125 | fun Context.shareBitmap(title: String, image: Bitmap, filename: String) { 126 | val file = try { 127 | val outputFile = File(cacheDir, "$filename.png") 128 | val outPutStream = FileOutputStream(outputFile) 129 | image.compress(CompressFormat.PNG, 100, outPutStream) 130 | outPutStream.flush() 131 | outPutStream.close() 132 | outputFile 133 | } catch (e: Throwable) { 134 | return toast(e) 135 | } 136 | val uri = file.toUriCompat(this) 137 | val shareIntent = Intent().apply { 138 | action = Intent.ACTION_SEND 139 | type = "image/png" 140 | putExtra(Intent.EXTRA_STREAM, uri) 141 | } 142 | startActivity(Intent.createChooser(shareIntent, title)) 143 | } 144 | 145 | fun Context.shareDrawable(title: String, image: Drawable, filename: String) { 146 | shareBitmap(title, image.toBitmap(), filename) 147 | } 148 | 149 | fun Context.toast(message: String) { 150 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 151 | } 152 | 153 | fun Context.toast(resId: Int) = toast(getString(resId)) 154 | 155 | fun Context.toast(throwable: Throwable) = 156 | throwable.message?.let { toast(it) } 157 | ?: toast(R.string.unknown_error) 158 | 159 | fun Context.copyToClipboard(text: String, hintResId: Int? = null) { 160 | val clip = ClipData.newPlainText("simple text", text) 161 | clipboardManager.setPrimaryClip(clip) 162 | hintResId?.let { toast(it) } 163 | } 164 | 165 | val Context.nsdManager: NsdManager 166 | get() = getSystemService(Context.NSD_SERVICE) as NsdManager 167 | 168 | val Context.clipboardManager: ClipboardManager 169 | get() = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/util/DateExtension.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import com.fishhawk.lisu.R 6 | import java.time.Instant 7 | import java.time.LocalDate 8 | import java.time.LocalDateTime 9 | import java.time.ZoneId 10 | import java.time.format.DateTimeFormatter 11 | import java.time.temporal.ChronoUnit 12 | 13 | fun Long.toLocalDateTime(): LocalDateTime = 14 | Instant.ofEpochSecond(this).atZone(ZoneId.systemDefault()).toLocalDateTime() 15 | 16 | fun Long.toLocalDate(): LocalDate = 17 | Instant.ofEpochSecond(this).atZone(ZoneId.systemDefault()).toLocalDate() 18 | 19 | @Composable 20 | fun LocalDateTime.readableString(): String { 21 | val now = LocalDateTime.now() 22 | val days = ChronoUnit.DAYS.between(this, now) 23 | return when { 24 | days == 0L -> { 25 | when (val hours = ChronoUnit.HOURS.between(this, now)) { 26 | 0L -> { 27 | when (val minutes = ChronoUnit.MINUTES.between(this, now)) { 28 | 0L -> "just now" 29 | else -> "$minutes minutes age" 30 | } 31 | } 32 | else -> "$hours hours age" 33 | } 34 | } 35 | days == 1L -> stringResource(R.string.history_yesterday) 36 | days <= 5L -> stringResource(R.string.history_n_days_ago).format(days) 37 | else -> this.format(DateTimeFormatter.ofPattern(stringResource(R.string.history_date_format))) 38 | } 39 | } 40 | 41 | @Composable 42 | fun LocalDate.readableString(): String { 43 | val now = LocalDate.now() 44 | val days = ChronoUnit.DAYS.between(this, now) 45 | return when { 46 | days == 0L -> stringResource(R.string.history_today) 47 | days == 1L -> stringResource(R.string.history_yesterday) 48 | days <= 5L -> stringResource(R.string.history_n_days_ago).format(days) 49 | else -> this.format(DateTimeFormatter.ofPattern(stringResource(R.string.history_date_format))) 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/util/FileExtension.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.util 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.core.content.FileProvider 6 | import java.io.File 7 | 8 | fun File.toUriCompat(context: Context): Uri { 9 | return FileProvider.getUriForFile(context, context.packageName + ".provider", this) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/util/FlowExtension.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.util 2 | 3 | import kotlinx.coroutines.flow.* 4 | 5 | inline fun flatten(flows: Map>): Flow> = 6 | combine(flows.mapValues { (key, value) -> value.map { key to it } }.values) { it.toMap() } 7 | 8 | inline fun flatten(flows: Iterable>): Flow> = 9 | combine(flows) { it.toList() } 10 | 11 | inline fun flatten(vararg flows: Flow): Flow> = 12 | combine(*flows) { it.toList() } 13 | 14 | inline fun flatten(flow: Result>?): Flow?> = 15 | flow { 16 | flow 17 | ?.onSuccess { it.collect { emit(Result.success(it)) } } 18 | ?.onFailure { emit(Result.failure(it)) } 19 | ?: emit(null) 20 | } 21 | 22 | inline fun flatCombine( 23 | flow1: Flow, 24 | flow2: Flow, 25 | crossinline transform: suspend (a: T1, b: T2) -> Flow 26 | ): Flow = 27 | combine(flow1, flow2) { v1, v2 -> v1 to v2 } 28 | .flatMapLatest { (v1, v2) -> transform(v1, v2) } 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/util/interceptor/ProgressInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.util.interceptor 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | 6 | typealias OnProgressChangeListener = (Float) -> Unit 7 | 8 | class ProgressInterceptor : Interceptor { 9 | 10 | override fun intercept(chain: Interceptor.Chain): Response { 11 | val request = chain.request() 12 | val response = chain.proceed(request) 13 | val responseBody = response.body ?: return response 14 | 15 | val url = request.url.toString() 16 | val listener = getListener(url) ?: return response 17 | 18 | val progressResponseBody = ProgressResponseBody(responseBody, listener) 19 | return response.newBuilder().body(progressResponseBody).build() 20 | } 21 | 22 | companion object { 23 | private val LISTENERS = hashMapOf() 24 | 25 | fun addListener(url: String, listener: OnProgressChangeListener) { 26 | LISTENERS[url] = listener 27 | } 28 | 29 | fun removeListener(url: String) { 30 | LISTENERS.remove(url) 31 | } 32 | 33 | fun getListener(url: String) = LISTENERS[url] 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/util/interceptor/ProgressResponseBody.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.util.interceptor 2 | 3 | import okhttp3.ResponseBody 4 | import okio.Buffer 5 | import okio.BufferedSource 6 | import okio.ForwardingSource 7 | import okio.buffer 8 | 9 | class ProgressResponseBody( 10 | private val responseBody: ResponseBody, 11 | private val listener: OnProgressChangeListener 12 | ) : ResponseBody() { 13 | 14 | private val bufferedSource = object : ForwardingSource(responseBody.source()) { 15 | private var totalBytesRead = 0L 16 | override fun read(sink: Buffer, byteCount: Long): Long { 17 | return super.read(sink, byteCount).also { 18 | totalBytesRead += if (it != -1L) it else 0 19 | if (contentLength() > 0) { 20 | val progress = (totalBytesRead.toFloat() / contentLength()).coerceIn(0f, 1f) 21 | listener.invoke(progress) 22 | } 23 | } 24 | } 25 | }.buffer() 26 | 27 | override fun contentLength() = responseBody.contentLength() 28 | override fun contentType() = responseBody.contentType() 29 | override fun source(): BufferedSource = bufferedSource 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/BottomSheetItem.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.material3.Icon 5 | import androidx.compose.material3.ListItem 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | 11 | @Composable 12 | fun BottomSheetListItem( 13 | icon: ImageVector, 14 | title: String, 15 | onClick: () -> Unit, 16 | ) { 17 | ListItem( 18 | modifier = Modifier.clickable(onClick = { onClick() }), 19 | leadingContent = { Icon(imageVector = icon, contentDescription = title) }, 20 | headlineContent = { Text(text = title) } 21 | ) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/LisuDialog.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | 10 | @Composable 11 | fun LisuDialog( 12 | title: String, 13 | confirmText: String, 14 | dismissText: String, 15 | onConfirm: () -> Unit, 16 | onDismiss: () -> Unit, 17 | text: String? = null, 18 | ) { 19 | AlertDialog( 20 | onDismissRequest = onDismiss, 21 | title = { Text(text = title) }, 22 | text = text?.let { { Text(text = it, modifier = Modifier.heightIn(max = 400.dp)) } }, 23 | confirmButton = { 24 | TextButton(onClick = { 25 | onConfirm() 26 | onDismiss() 27 | }) { 28 | Text(text = confirmText) 29 | } 30 | }, 31 | dismissButton = { 32 | TextButton(onClick = onDismiss) { 33 | Text(text = dismissText) 34 | } 35 | } 36 | ) 37 | } 38 | 39 | @Composable 40 | fun LisuSelectDialog( 41 | title: String, 42 | options: List, 43 | selected: Int, 44 | onSelectedChanged: (Int) -> Unit, 45 | onDismiss: () -> Unit, 46 | ) { 47 | AlertDialog( 48 | onDismissRequest = onDismiss, 49 | confirmButton = { }, 50 | title = { Text(text = title) }, 51 | text = { 52 | Column { 53 | options.forEachIndexed { index, text -> 54 | Row( 55 | modifier = Modifier 56 | .fillMaxWidth() 57 | .clickable { onSelectedChanged(index) } 58 | .padding(vertical = 12.dp), 59 | horizontalArrangement = Arrangement.spacedBy(16.dp), 60 | ) { 61 | RadioButton(selected = index == selected, onClick = null) 62 | Text( 63 | text = text, 64 | style = MaterialTheme.typography.bodyMedium, 65 | ) 66 | } 67 | } 68 | } 69 | }, 70 | ) 71 | } 72 | 73 | @Composable 74 | fun LisuMultipleSelectDialog( 75 | title: String, 76 | options: List, 77 | selected: Set, 78 | onSelectedChanged: (Int) -> Unit, 79 | onDismiss: () -> Unit, 80 | ) { 81 | AlertDialog( 82 | onDismissRequest = onDismiss, 83 | confirmButton = { }, 84 | title = { Text(text = title) }, 85 | text = { 86 | Column { 87 | options.mapIndexed { index, text -> 88 | Row( 89 | modifier = Modifier 90 | .fillMaxWidth() 91 | .clickable { onSelectedChanged(index) } 92 | .padding(vertical = 12.dp), 93 | horizontalArrangement = Arrangement.spacedBy(16.dp), 94 | ) { 95 | Checkbox(checked = index in selected, onCheckedChange = null) 96 | Text( 97 | text = text, 98 | style = MaterialTheme.typography.bodyMedium, 99 | ) 100 | } 101 | } 102 | } 103 | }, 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/LisuListItem.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.ProvideTextStyle 6 | import androidx.compose.material3.Surface 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | import com.fishhawk.lisu.ui.theme.MediumEmphasis 14 | 15 | @Composable 16 | fun LisuListHeader( 17 | text: String, 18 | modifier: Modifier = Modifier, 19 | ) { 20 | MediumEmphasis { 21 | Text( 22 | text = text, 23 | modifier = modifier.padding(8.dp), 24 | style = MaterialTheme.typography.titleSmall, 25 | ) 26 | } 27 | } 28 | 29 | @Composable 30 | fun LisuListItem( 31 | leadingContent: @Composable () -> Unit, 32 | headlineText: @Composable () -> Unit, 33 | modifier: Modifier = Modifier, 34 | overlineText: @Composable (() -> Unit)? = null, 35 | supportingText: @Composable (() -> Unit)? = null, 36 | trailingContent: @Composable (() -> Unit)? = null, 37 | ) { 38 | Surface { 39 | Row( 40 | modifier = modifier 41 | .height(104.dp) 42 | .padding(horizontal = 8.dp, vertical = 2.dp), 43 | horizontalArrangement = Arrangement.spacedBy(8.dp), 44 | verticalAlignment = Alignment.CenterVertically, 45 | ) { 46 | leadingContent() 47 | Column(modifier = Modifier.weight(1f)) { 48 | overlineText?.let { 49 | MediumEmphasis { 50 | ProvideTextStyle(value = MaterialTheme.typography.labelSmall) { 51 | it() 52 | } 53 | } 54 | } 55 | ProvideTextStyle(value = MaterialTheme.typography.titleMedium.copy(lineHeight = 20.sp)) { 56 | headlineText() 57 | } 58 | supportingText?.let { 59 | MediumEmphasis { 60 | ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) { 61 | it() 62 | } 63 | } 64 | } 65 | } 66 | trailingContent?.let { it() } 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/LisuScaffold.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | 10 | @OptIn(ExperimentalMaterial3Api::class) 11 | @Composable 12 | fun LisuScaffold( 13 | modifier: Modifier = Modifier, 14 | topBar: @Composable () -> Unit = {}, 15 | bottomBar: @Composable () -> Unit = {}, 16 | snackbarHost: @Composable () -> Unit = {}, 17 | floatingActionButton: @Composable () -> Unit = {}, 18 | floatingActionButtonPosition: FabPosition = FabPosition.End, 19 | containerColor: Color = MaterialTheme.colorScheme.background, 20 | contentColor: Color = contentColorFor(containerColor), 21 | contentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0), 22 | content: @Composable (PaddingValues) -> Unit, 23 | ) = Scaffold( 24 | modifier = modifier, 25 | topBar = topBar, 26 | bottomBar = bottomBar, 27 | snackbarHost = snackbarHost, 28 | floatingActionButton = floatingActionButton, 29 | floatingActionButtonPosition = floatingActionButtonPosition, 30 | containerColor = containerColor, 31 | contentColor = contentColor, 32 | contentWindowInsets = contentWindowInsets, 33 | content = content 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/LisuSlider.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.interaction.MutableInteractionSource 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material3.* 7 | import androidx.compose.material3.SliderDefaults.Thumb 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.DpSize 13 | import androidx.compose.ui.unit.dp 14 | 15 | // see https://issuetracker.google.com/issues/254417424 16 | @OptIn(ExperimentalMaterial3Api::class) 17 | @Composable 18 | fun LisuSlider( 19 | value: Float, 20 | onValueChange: (Float) -> Unit, 21 | modifier: Modifier = Modifier, 22 | enabled: Boolean = true, 23 | valueRange: ClosedFloatingPointRange = 0f..1f, 24 | onValueChangeFinished: (() -> Unit)? = null, 25 | colors: SliderColors = SliderDefaults.colors( 26 | inactiveTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.24f), 27 | inactiveTickColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.24f), 28 | ), 29 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 30 | ) { 31 | Slider( 32 | value = value, 33 | onValueChange = onValueChange, 34 | modifier = modifier, 35 | enabled = enabled, 36 | valueRange = valueRange, 37 | onValueChangeFinished = onValueChangeFinished, 38 | colors = colors, 39 | interactionSource = interactionSource, 40 | thumb = { 41 | Box(modifier = Modifier.size(20.dp, 20.dp)) { 42 | Thumb( 43 | interactionSource = interactionSource, 44 | modifier = Modifier.align(Alignment.Center), 45 | colors = colors, 46 | enabled = enabled, 47 | thumbSize = DpSize(16.dp, 16.dp), 48 | ) 49 | } 50 | }, 51 | ) 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/LisuToolBar.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.material.icons.outlined.ArrowBack 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.text.style.TextOverflow 11 | import com.fishhawk.lisu.R 12 | import com.fishhawk.lisu.ui.theme.LisuIcons 13 | 14 | @Composable 15 | fun LisuToolBar( 16 | modifier: Modifier = Modifier, 17 | title: String? = null, 18 | transparent: Boolean = false, 19 | onNavUp: (() -> Unit)? = null, 20 | actions: @Composable RowScope.() -> Unit = {}, 21 | ) { 22 | LisuToolBar( 23 | title = { 24 | title?.let { 25 | Text( 26 | text = it, 27 | maxLines = 1, 28 | overflow = TextOverflow.Ellipsis 29 | ) 30 | } 31 | }, 32 | modifier = modifier, 33 | transparent = transparent, 34 | onNavUp = onNavUp, 35 | actions = actions, 36 | ) 37 | } 38 | 39 | @OptIn(ExperimentalMaterial3Api::class) 40 | @Composable 41 | fun LisuToolBar( 42 | title: @Composable () -> Unit, 43 | modifier: Modifier = Modifier, 44 | transparent: Boolean = false, 45 | onNavUp: (() -> Unit)? = null, 46 | actions: @Composable RowScope.() -> Unit = {}, 47 | ) { 48 | CenterAlignedTopAppBar( 49 | title = title, 50 | modifier = modifier, 51 | navigationIcon = { 52 | onNavUp?.let { 53 | TooltipIconButton( 54 | tooltip = stringResource(R.string.action_back), 55 | icon = LisuIcons.ArrowBack, 56 | onClick = { it() }, 57 | ) 58 | } 59 | }, 60 | actions = actions, 61 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 62 | containerColor = if (transparent) { 63 | Color.Transparent 64 | } else { 65 | MaterialTheme.colorScheme.surface 66 | }, 67 | ), 68 | ) 69 | } 70 | 71 | @Composable 72 | fun LisuNonCenterAlignedToolBar( 73 | modifier: Modifier = Modifier, 74 | title: String? = null, 75 | transparent: Boolean = false, 76 | onNavUp: (() -> Unit)? = null, 77 | actions: @Composable RowScope.() -> Unit = {}, 78 | ) { 79 | LisuNonCenterAlignedToolBar( 80 | title = { 81 | title?.let { 82 | Text( 83 | text = it, 84 | maxLines = 1, 85 | overflow = TextOverflow.Ellipsis 86 | ) 87 | } 88 | }, 89 | modifier = modifier, 90 | transparent = transparent, 91 | onNavUp = onNavUp, 92 | actions = actions, 93 | ) 94 | } 95 | 96 | @OptIn(ExperimentalMaterial3Api::class) 97 | @Composable 98 | fun LisuNonCenterAlignedToolBar( 99 | title: @Composable () -> Unit, 100 | modifier: Modifier = Modifier, 101 | transparent: Boolean = false, 102 | onNavUp: (() -> Unit)? = null, 103 | actions: @Composable RowScope.() -> Unit = {}, 104 | ) { 105 | TopAppBar( 106 | title = title, 107 | modifier = modifier, 108 | navigationIcon = { 109 | onNavUp?.let { 110 | TooltipIconButton( 111 | tooltip = stringResource(R.string.action_back), 112 | icon = LisuIcons.ArrowBack, 113 | onClick = { it() }, 114 | ) 115 | } 116 | }, 117 | actions = actions, 118 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 119 | containerColor = if (transparent) { 120 | Color.Transparent 121 | } else { 122 | MaterialTheme.colorScheme.surface 123 | }, 124 | ), 125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/MangaList.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.Image 6 | import androidx.compose.foundation.combinedClickable 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.lazy.grid.GridCells 9 | import androidx.compose.foundation.lazy.grid.GridItemSpan 10 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 11 | import androidx.compose.foundation.lazy.grid.itemsIndexed 12 | import androidx.compose.material3.* 13 | import androidx.compose.runtime.* 14 | import androidx.compose.runtime.saveable.rememberSaveable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.geometry.Offset 18 | import androidx.compose.ui.graphics.Brush 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.graphics.Shadow 21 | import androidx.compose.ui.graphics.compositeOver 22 | import androidx.compose.ui.layout.ContentScale 23 | import androidx.compose.ui.platform.LocalContext 24 | import androidx.compose.ui.text.style.TextOverflow 25 | import androidx.compose.ui.unit.dp 26 | import coil.compose.AsyncImagePainter 27 | import coil.compose.rememberAsyncImagePainter 28 | import coil.request.ImageRequest 29 | import com.fishhawk.lisu.data.network.base.PagedList 30 | import com.fishhawk.lisu.data.network.model.MangaDto 31 | import com.google.accompanist.placeholder.PlaceholderHighlight 32 | import com.google.accompanist.placeholder.material.fade 33 | import com.google.accompanist.placeholder.material.placeholder 34 | import com.google.accompanist.swiperefresh.SwipeRefresh 35 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 36 | 37 | @OptIn(ExperimentalFoundationApi::class) 38 | @Composable 39 | fun RefreshableMangaList( 40 | mangaList: PagedList, 41 | isRefreshing: Boolean, 42 | onRefresh: () -> Unit, 43 | onRequestNextPage: () -> Unit, 44 | onCardClick: (manga: MangaDto) -> Unit, 45 | onCardLongClick: (manga: MangaDto) -> Unit, 46 | aboveCover: @Composable BoxScope.(manga: MangaDto) -> Unit = {}, 47 | behindCover: @Composable BoxScope.(manga: MangaDto) -> Unit = {}, 48 | ) { 49 | var maxAccessed by rememberSaveable { mutableStateOf(0) } 50 | var hasRefreshed by rememberSaveable { mutableStateOf(false) } 51 | LaunchedEffect(isRefreshing) { 52 | if (isRefreshing) hasRefreshed = true 53 | if (hasRefreshed && !isRefreshing) maxAccessed = 0 54 | } 55 | 56 | SwipeRefresh( 57 | state = rememberSwipeRefreshState(isRefreshing), 58 | onRefresh = onRefresh, 59 | ) { 60 | if (mangaList.list.isEmpty()) { 61 | EmptyView(modifier = Modifier.fillMaxSize()) 62 | } 63 | LazyVerticalGrid( 64 | columns = GridCells.Adaptive(minSize = 96.dp), 65 | modifier = Modifier.fillMaxSize(), 66 | contentPadding = PaddingValues(8.dp), 67 | verticalArrangement = Arrangement.spacedBy(8.dp), 68 | horizontalArrangement = Arrangement.spacedBy(8.dp) 69 | ) { 70 | itemsIndexed(mangaList.list) { index, manga -> 71 | if (index > maxAccessed) { 72 | maxAccessed = index 73 | if ( 74 | mangaList.appendState?.isSuccess == true && 75 | maxAccessed < mangaList.list.size + 30 76 | ) onRequestNextPage() 77 | } 78 | Box { 79 | behindCover(manga) 80 | MangaCard( 81 | manga = manga, 82 | modifier = Modifier.combinedClickable( 83 | onClick = { onCardClick(manga) }, 84 | onLongClick = { onCardLongClick(manga) }, 85 | ) 86 | ) 87 | aboveCover(manga) 88 | } 89 | } 90 | 91 | fun itemFullWidth(content: @Composable () -> Unit) { 92 | item(span = { GridItemSpan(maxCurrentLineSpan) }) { Box {} } 93 | item(span = { GridItemSpan(maxCurrentLineSpan) }) { content() } 94 | } 95 | mangaList.appendState 96 | ?.onFailure { itemFullWidth { ErrorItem(it) { onRequestNextPage() } } } 97 | ?: itemFullWidth { LoadingItem() } 98 | } 99 | } 100 | } 101 | 102 | @Composable 103 | fun MangaCard( 104 | manga: MangaDto, 105 | modifier: Modifier = Modifier, 106 | ) { 107 | Card( 108 | modifier = modifier, 109 | shape = MaterialTheme.shapes.extraSmall, 110 | ) { 111 | Box { 112 | MangaCover(manga.cover) 113 | 114 | val textStyle = MaterialTheme.typography.bodySmall.copy( 115 | shadow = Shadow(Color.White, Offset.Zero, 1f) 116 | ) 117 | Canvas(modifier = Modifier.matchParentSize()) { 118 | val shadowHeight = ((textStyle.fontSize * 4 / 3).toPx() + 8.dp.toPx()) * 2 119 | drawRect( 120 | brush = Brush.verticalGradient( 121 | colors = listOf( 122 | Color.Transparent, 123 | Color(0xAA000000) 124 | ), 125 | startY = size.height - shadowHeight, 126 | ), 127 | ) 128 | } 129 | Text( 130 | modifier = Modifier 131 | .align(Alignment.BottomStart) 132 | .padding(8.dp), 133 | text = manga.title ?: manga.id, 134 | color = Color.White, 135 | maxLines = 2, 136 | overflow = TextOverflow.Ellipsis, 137 | style = textStyle, 138 | ) 139 | } 140 | } 141 | } 142 | 143 | @Composable 144 | fun MangaCard( 145 | cover: String?, 146 | modifier: Modifier = Modifier, 147 | ) { 148 | Card( 149 | modifier = modifier, 150 | shape = MaterialTheme.shapes.extraSmall, 151 | ) { 152 | Box { 153 | MangaCover(cover) 154 | } 155 | } 156 | } 157 | 158 | @Composable 159 | private fun MangaCover( 160 | cover: String?, 161 | modifier: Modifier = Modifier, 162 | ) { 163 | val painter = rememberAsyncImagePainter( 164 | ImageRequest.Builder(LocalContext.current) 165 | .data(cover) 166 | .crossfade(true) 167 | .crossfade(500) 168 | .build() 169 | ) 170 | 171 | val backgroundColor = MaterialTheme.colorScheme.surface 172 | val contentColor = contentColorFor(backgroundColor) 173 | val placeholderColor = contentColor.copy(0.1f).compositeOver(backgroundColor) 174 | Image( 175 | painter = painter, 176 | contentDescription = null, 177 | modifier = modifier 178 | .aspectRatio(0.75f) 179 | .placeholder( 180 | visible = painter.state is AsyncImagePainter.State.Loading, 181 | color = placeholderColor, 182 | highlight = PlaceholderHighlight.fade(), 183 | ), 184 | contentScale = ContentScale.Crop 185 | ) 186 | } 187 | 188 | 189 | @OptIn(ExperimentalMaterial3Api::class) 190 | @Composable 191 | fun BoxScope.MangaBadge( 192 | text: String, 193 | backgroundColor: Color = MaterialTheme.colorScheme.primary, 194 | ) { 195 | Badge( 196 | modifier = Modifier 197 | .align(Alignment.TopStart) 198 | .padding(4.dp), 199 | containerColor = backgroundColor, 200 | ) { 201 | Text( 202 | text = text, 203 | modifier = Modifier.padding(2.dp), 204 | ) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/OverflowMenuButton.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.layout.ColumnScope 4 | import androidx.compose.material.icons.outlined.MoreVert 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.* 7 | import com.fishhawk.lisu.ui.theme.LisuIcons 8 | 9 | @Composable 10 | fun OverflowMenuButton( 11 | content: @Composable ColumnScope.() -> Unit, 12 | ) { 13 | var expanded by remember { mutableStateOf(false) } 14 | IconButton(onClick = { expanded = !expanded }) { 15 | Icon( 16 | imageVector = LisuIcons.MoreVert, 17 | contentDescription = null, 18 | ) 19 | DropdownMenu( 20 | expanded = expanded, 21 | onDismissRequest = { expanded = false }, 22 | content = content, 23 | ) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/StateView.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.CircularProgressIndicator 5 | import androidx.compose.material3.MaterialTheme.typography 6 | import androidx.compose.material3.Text 7 | import androidx.compose.material3.TextButton 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.text.style.TextAlign 13 | import androidx.compose.ui.unit.dp 14 | import com.fishhawk.lisu.R 15 | 16 | @Composable 17 | fun StateView( 18 | result: Result?, 19 | onRetry: () -> Unit, 20 | modifier: Modifier = Modifier, 21 | content: @Composable (value: T, modifier: Modifier) -> Unit, 22 | ) { 23 | result 24 | ?.onSuccess { content(it, modifier) } 25 | ?.onFailure { ErrorView(it, onRetry, modifier) } 26 | ?: LoadingView(modifier) 27 | } 28 | 29 | @Composable 30 | fun StateView( 31 | result: Result?, 32 | onRetry: () -> Unit, 33 | modifier: Modifier = Modifier, 34 | content: @Composable (value: T) -> Unit, 35 | ) { 36 | result 37 | ?.onSuccess { content(it) } 38 | ?.onFailure { ErrorView(it, onRetry, modifier) } 39 | ?: LoadingView(modifier) 40 | } 41 | 42 | @Composable 43 | fun LoadingView( 44 | modifier: Modifier = Modifier, 45 | ) { 46 | Box(modifier = modifier.padding(48.dp)) { 47 | CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) 48 | } 49 | } 50 | 51 | @Composable 52 | fun ErrorView( 53 | throwable: Throwable, 54 | onRetry: () -> Unit, 55 | modifier: Modifier = Modifier, 56 | ) { 57 | Box( 58 | modifier = modifier, 59 | contentAlignment = Alignment.Center 60 | ) { 61 | Column( 62 | modifier = Modifier.padding(48.dp), 63 | verticalArrangement = Arrangement.spacedBy(16.dp), 64 | horizontalAlignment = Alignment.CenterHorizontally 65 | ) { 66 | Text( 67 | text = throwable.localizedMessage 68 | ?: stringResource(R.string.unknown_error), 69 | style = typography.titleMedium, 70 | textAlign = TextAlign.Center 71 | ) 72 | TextButton(onClick = onRetry) { 73 | Text(text = stringResource(R.string.action_retry)) 74 | } 75 | } 76 | } 77 | } 78 | 79 | @Composable 80 | fun EmptyView( 81 | modifier: Modifier = Modifier, 82 | ) { 83 | Box( 84 | modifier = modifier, 85 | contentAlignment = Alignment.Center 86 | ) { 87 | Text(text = "List is empty.") 88 | } 89 | } 90 | 91 | @Composable 92 | fun LoadingItem(modifier: Modifier = Modifier) { 93 | Box( 94 | modifier = modifier 95 | .fillMaxWidth() 96 | .padding(16.dp) 97 | ) { 98 | CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) 99 | } 100 | } 101 | 102 | @Composable 103 | fun ErrorItem( 104 | throwable: Throwable, 105 | onRetry: () -> Unit, 106 | ) { 107 | Row( 108 | modifier = Modifier.padding(16.dp), 109 | horizontalArrangement = Arrangement.SpaceBetween, 110 | verticalAlignment = Alignment.CenterVertically 111 | ) { 112 | Text( 113 | text = throwable.localizedMessage 114 | ?: stringResource(R.string.unknown_error), 115 | modifier = Modifier.weight(1f), 116 | style = typography.titleMedium, 117 | textAlign = TextAlign.Center 118 | ) 119 | TextButton(onClick = onRetry) { 120 | Text(text = stringResource(R.string.action_retry)) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/TooltipIconButton.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.material3.ExperimentalMaterial3Api 4 | import androidx.compose.material3.Icon 5 | import androidx.compose.material3.IconButton 6 | import androidx.compose.material3.PlainTooltipBox 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.vector.ImageVector 11 | 12 | @OptIn(ExperimentalMaterial3Api::class) 13 | @Composable 14 | fun TooltipIconButton( 15 | tooltip: String, 16 | icon: ImageVector, 17 | onClick: () -> Unit, 18 | modifier: Modifier = Modifier, 19 | ) { 20 | PlainTooltipBox(tooltip = { Text(tooltip) }) { 21 | IconButton( 22 | modifier = modifier.tooltipAnchor(), 23 | onClick = onClick, 24 | ) { 25 | Icon( 26 | imageVector = icon, 27 | contentDescription = tooltip, 28 | ) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fishhawk/lisu/widget/VerticalGrid.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu.widget 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.lazy.LazyListScope 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | 9 | fun LazyListScope.itemsVerticalGrid( 10 | items: Iterable, 11 | nColumns: Int, 12 | modifier: Modifier = Modifier, 13 | horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, 14 | content: @Composable RowScope.(item: T) -> Unit, 15 | ) { 16 | items(items.chunked(nColumns)) { rowItems -> 17 | Row( 18 | modifier = modifier, 19 | horizontalArrangement = horizontalArrangement, 20 | ) { 21 | rowItems.forEach { item -> 22 | content(item) 23 | } 24 | repeat(nColumns - rowItems.size) { 25 | Spacer(modifier = Modifier.weight(1f)) 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_close_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_refresh_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_system_update_alt_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png -------------------------------------------------------------------------------- /app/src/main/res/xml/provider_path.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/test/java/com/fishhawk/lisu/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.fishhawk.lisu 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 | tasks.register("clean", Delete::class) { 2 | delete(rootProject.buildDir) 3 | } 4 | -------------------------------------------------------------------------------- /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 | android.defaults.buildfeatures.buildconfig=true 25 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FishHawk/lisu-android/6922a01565026b0c1bba8098d4145817e0ad47ee/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Oct 18 17:54:15 CST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | maven { setUrl("https://jitpack.io") } 7 | maven { setUrl("https://plugins.gradle.org/m2") } 8 | } 9 | } 10 | 11 | dependencyResolutionManagement { 12 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 13 | repositories { 14 | google() 15 | mavenCentral() 16 | maven { setUrl("https://jitpack.io") } 17 | } 18 | } 19 | 20 | rootProject.name = "lisu-android" 21 | 22 | include(":app") 23 | --------------------------------------------------------------------------------