├── .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 | 
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