================================================
FILE: .idea/vcs.xml
================================================
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 shizheng233
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# CopyMangaJava
 
*很抱歉,本软件已不再维护。*
一个第三方的拷贝漫画带有M3(Material You)
风格,支持动态主题。该App已通过Kotlin来实现,只是标题没改。注意:如果您感觉加载慢的话而且没有使用VPN的话,建议打开设置中的“使用境外CDN”,说不定可以加载得出来。
希望大家可以多使用官方App,我写这个App只是用来练习。这个里面的代码都很简单,即使以后也可以给初学者一定的帮助。但我并不推荐您来查看我的代码。如果您想要学习的话,Kotatsu
和 Tachiyomi的代码完全够用。多看几遍就可以弄懂。
下载功能只能下载,在联网加载的时候会加载本地下载的漫画,但是我还没完成离线观看的逻辑。下载目录在
_Android/com.shicheeng.copymanga/Downloads_ 下面,您可以自己手动复制到其他地方下观看。
本项目部分迁移到Compose。
**注意:请务必卸载以前的那个早期版本**
## 灵感来源
* [fumiama/copymanga](https://github.com/fumiama/copymanga)
* [misaka10843/copymanga-downloader](https://github.com/misaka10843/copymanga-downloader)
* [tachiyomi](https://github.com/tachiyomiorg/tachiyomi)
* [kotatsu](https://github.com/KotatsuApp/Kotatsu)
## 截屏
## 关于Api
api 来源于官方app API
## 后续功能
* ~~下载~~(现在可离线查看下载的漫画)
* ~~记录位置~~(将历史记录保存在本地,_登录后可已上传到网页。_)
* ~~登录~~(可以登录了,但是**多用户**没有测试过。)
* ~~搜索~~(已完成)
## License
MIT License
### 中文解释如下(来自[维基百科](https://zh.wikipedia.org/wiki/MIT%E8%A8%B1%E5%8F%AF%E8%AD%89))
#### 被许可人权利
特此授予任何人免费获得本软件和相关文档文件(“软件”)副本的许可,不受限制地处理本软件,包括但不限于使用、复制、修改、合并
、发布、分发、再许可的权利, 被许可人有权利使用、复制、修改、合并、出版发行、散布、再许可和/或贩售软件及软件的副本,及授予被供应人同等权利,惟服从以下义务。
#### 被许可人义务
在软件和软件的所有副本中都必须包含以上著作权声明和本许可声明。
================================================
FILE: app/.gitignore
================================================
/build
================================================
FILE: app/build.gradle
================================================
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'org.jetbrains.kotlin.plugin.parcelize'
id 'androidx.navigation.safeargs'
id 'com.mikepenz.aboutlibraries.plugin'
id 'kotlin-android'
id 'com.google.dagger.hilt.android'
id 'com.google.devtools.ksp'
}
android {
compileSdk 34
defaultConfig {
applicationId "com.shicheeng.copymanga"
minSdk 26
targetSdk 34
versionCode 3
versionName "1.0.5-FIX-1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
compose true
buildConfig true
}
kotlinOptions {
jvmTarget = '17'
}
androidResources {
generateLocaleConfig true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.8'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
viewBinding {
enabled = true
}
namespace 'com.shicheeng.copymanga'
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.code.gson:gson:2.10.1'
// define a BOM and its version
implementation(platform("com.squareup.okhttp3:okhttp-bom:4.9.3"))
//SSIV
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
//Okhttp
// define any required OkHttp artifacts without version
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
implementation 'com.squareup.okio:okio:3.5.0'
//Glide
implementation 'com.github.bumptech.glide:glide:4.15.0'
//Core
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
ksp 'com.github.bumptech.glide:compiler:4.13.2'
def lifecycle_version = "2.6.1"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// ViewModel utilities for Compose
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
//Service
implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version"
implementation "androidx.fragment:fragment-ktx:1.6.2"
implementation "androidx.activity:activity-ktx:1.8.2"
implementation 'androidx.palette:palette-ktx:1.0.0'
//Cache
implementation 'com.github.solkin:disk-lru-cache:1.4'
//Navigation
def nav_version = "2.7.6"
// Kotlin
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
//Paging3
def paging_version = "3.1.1"
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
// Testing Fragments in Isolation
debugImplementation "androidx.fragment:fragment-testing:1.6.2"
//Room
implementation "androidx.room:room-ktx:2.6.1"
ksp "androidx.room:room-compiler:2.6.1"
androidTestImplementation "androidx.room:room-testing:2.6.1"
//AboutLibrary
def latestAboutLibsRelease = "10.5.2"
implementation "com.mikepenz:aboutlibraries-core:$latestAboutLibsRelease"
implementation "com.mikepenz:aboutlibraries:${latestAboutLibsRelease}"
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation 'androidx.hilt:hilt-work:1.1.0'
// When using Kotlin.
kapt 'androidx.hilt:hilt-compiler:1.1.0'
//Android Preference
implementation 'androidx.preference:preference-ktx:1.2.1'
//Compose
// Import the Compose BOM
implementation platform('androidx.compose:compose-bom:2024.01.00')
// Override Material Design 3 library version with a pre-release version
implementation 'androidx.compose.material3:material3:1.2.0-rc01'
// Import other Compose libraries without version numbers
implementation 'androidx.compose.foundation:foundation'
implementation "com.google.accompanist:accompanist-themeadapter-material3:0.33.1-alpha"
implementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation "com.google.accompanist:accompanist-pager-indicators:0.31.3-beta"
implementation "androidx.navigation:navigation-compose:2.7.6"
//Optional - Jetpack Compose integration
implementation "androidx.paging:paging-compose:3.2.1"
//Anime
implementation "androidx.compose.animation:animation-graphics"
implementation "io.github.fornewid:material-motion-compose-navigation:1.1.0"
//Coil
implementation "io.coil-kt:coil:2.4.0"
implementation "io.coil-kt:coil-compose:2.4.0"
//Retrofit
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
//Moshi
implementation "com.squareup.moshi:moshi-kotlin:1.15.0"
ksp "com.squareup.moshi:moshi-kotlin-codegen:1.14.0"
//Hilt
implementation "com.google.dagger:hilt-android:2.48.1"
kapt "com.google.dagger:hilt-compiler:2.48.1"
implementation 'androidx.hilt:hilt-navigation-compose:1.1.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
kapt {
correctErrorTypes true
}
================================================
FILE: app/debug/output-metadata.json
================================================
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.shicheeng.copymanga",
"variantName": "debug",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.2",
"outputFile": "app-debug.apk"
}
],
"elementType": "File"
}
================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class com.shicheeng.copymanga.data.MangaInfoChapterDataBean
-keep class com.shicheeng.copymanga.data.LastMangaDownload
-keep class com.shicheeng.copymanga.data.MangaDownloadChapterInfoModel
-keep class com.shicheeng.copymanga.data.MangaDownloads
-keep class com.shicheeng.copymanga.data.PersonalInnerDataModel
-keep class com.shicheeng.copymanga.data.MangaSortBean
# Please add these rules to your existing keep rules in order to suppress warnings.
# This is generated automatically by the Android Gradle plugin.
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
# EnclosingMethod is required to use InnerClasses.
-keepattributes Signature, InnerClasses, EnclosingMethod
# Retrofit does reflection on method and parameter annotations.
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
# Keep annotation default values (e.g., retrofit2.http.Field.encoded).
-keepattributes AnnotationDefault
# Retain service method parameters when optimizing.
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* ;
}
# Ignore annotation used for build tooling.
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
# Ignore JSR 305 annotations for embedding nullability information.
-dontwarn javax.annotation.**
# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
-dontwarn kotlin.Unit
# Top-level functions that can only be used by Kotlin.
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
-if interface * { @retrofit2.http.* ; }
-keep,allowobfuscation interface <1>
# Keep inherited services.
-if interface * { @retrofit2.http.* ; }
-keep,allowobfuscation interface * extends <1>
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# R8 full mode strips generic signatures from return types if not kept.
-if interface * { @retrofit2.http.* public *** *(...); }
-keep,allowoptimization,allowshrinking,allowobfuscation class <3>
================================================
FILE: app/release/output-metadata.json
================================================
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.shicheeng.copymanga",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 3,
"versionName": "1.0.5-FIX-1",
"outputFile": "app-release.apk"
}
],
"elementType": "File"
}
================================================
FILE: app/src/androidTest/java/com/shicheeng/copymanga/ExampleInstrumentedTest.java
================================================
package com.shicheeng.copymanga;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see Testing documentation
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.shicheeng.copymanga", appContext.getPackageName());
}
}
================================================
FILE: app/src/debug/res/drawable-anydpi/ic_explore_outline.xml
================================================
================================================
FILE: app/src/debug/res/drawable-anydpi/ic_setting_outline.xml
================================================
================================================
FILE: app/src/debug/res/drawable-anydpi/ic_swith_horiz.xml
================================================
================================================
FILE: app/src/debug/res/drawable-anydpi/ic_swith_vert.xml
================================================
================================================
FILE: app/src/debug/res/drawable-anydpi/ic_trend_up.xml
================================================
================================================
FILE: app/src/debug/res/mipmap-anydpi-v26/ic_copy.xml
================================================
================================================
FILE: app/src/debug/res/values/ic_copy_background.xml
================================================
#2A85C6
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/CrashHandler.kt
================================================
package com.shicheeng.copymanga
import android.content.Context
import com.shicheeng.copymanga.error.ErrorActivity
import java.lang.Thread.UncaughtExceptionHandler
@Deprecated("先暂时弃用,这个方法还没学会")
class CrashHandler(
private val context: Context,
) : UncaughtExceptionHandler {
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(
thread: Thread,
error: Throwable,
) {
ErrorActivity.newIntentInstance(context = context, error.message).let {
context.startActivity(it)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/MainActivity.kt
================================================
package com.shicheeng.copymanga
import android.Manifest
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Html
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.view.WindowCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.shicheeng.copymanga.app.AppAttachCompatActivity
import com.shicheeng.copymanga.data.VersionUnit
import com.shicheeng.copymanga.ui.screen.MainComposeNavigation
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.ui.theme.CopyMangaTheme
import com.shicheeng.copymanga.util.FileCacheUtils
import com.shicheeng.copymanga.util.collectRepeatLifecycle
import com.shicheeng.copymanga.viewmodel.RootViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppAttachCompatActivity() {
@Inject
lateinit var settingPref: SettingPref
private val mainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
requestNotificationsPermission()
setContent {
CopyMangaTheme {
CompositionLocalProvider(
LocalSettingPreference provides settingPref,
) {
MainComposeNavigation()
}
}
}
if (!settingPref.pauseUpdateDetector.value) {
mainViewModel.updateData.collectRepeatLifecycle(this) {
onUpdateAttach(it)
}
}
}
private fun requestNotificationsPermission() {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
/*context=*/this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
}
/**
* 更新弹窗。由于一般的更新内容为简体中文,故不做i18n处理。
* @param versionUnit 版本更新单位,为空则表示没有更新。
*
* @author ShihCheeng and refer to Kotatsu.
*/
private fun onUpdateAttach(versionUnit: VersionUnit?) {
if (versionUnit == null) {
return
}
val message = buildSpannedString {
append("版本:")
append(versionUnit.versionName)
appendLine("大小:" + FileCacheUtils.getFormatSize(versionUnit.apkSize.toDouble()))
appendLine("类型:" + versionUnit.versionId.type)
appendLine()
appendLine(versionUnit.description)
}
val dialog = MaterialAlertDialogBuilder(
this,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered
).apply {
setTitle(R.string.new_version)
setMessage(Html.fromHtml(message.toString(), Html.FROM_HTML_MODE_COMPACT))
setIcon(R.drawable.baseline_security_update_24)
}
dialog.setPositiveButton(R.string.update) { dialogInterface: DialogInterface, _: Int ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(versionUnit.apkUrl))
startActivity(intent)
dialogInterface.dismiss()
}
dialog.setNegativeButton(android.R.string.cancel) { dialogInterface: DialogInterface, _: Int ->
dialogInterface.dismiss()
}
dialog.setNeutralButton(R.string.website_look) { dialogInterface: DialogInterface, _: Int ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(versionUnit.htmlUrl))
startActivity(intent)
dialogInterface.dismiss()
}
dialog.show()
}
}
val LocalSettingPreference = staticCompositionLocalOf {
error("NO LOCAL SETTING PROVIDE")
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/MangaReaderActivity.kt
================================================
package com.shicheeng.copymanga
import android.annotation.TargetApi
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.drawable.RippleDrawable
import android.os.Build
import android.os.Bundle
import android.transition.Slide
import android.transition.TransitionManager
import android.transition.TransitionSet
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.WindowManager
import androidx.activity.viewModels
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.Insets
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2.LAYOUT_DIRECTION_LTR
import androidx.viewpager2.widget.ViewPager2.LAYOUT_DIRECTION_RTL
import com.google.android.material.shape.MaterialShapeDrawable
import com.shicheeng.copymanga.app.AppAttachCompatActivity
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.ReaderContent
import com.shicheeng.copymanga.data.ReaderState
import com.shicheeng.copymanga.databinding.ActivityMangaReaderBinding
import com.shicheeng.copymanga.dialog.ConfigPagerSheet
import com.shicheeng.copymanga.fm.delegate.IdlingDelegate
import com.shicheeng.copymanga.fm.reader.MangaLoader
import com.shicheeng.copymanga.fm.reader.ReaderManager
import com.shicheeng.copymanga.fm.reader.ReaderMode
import com.shicheeng.copymanga.fm.reader.noraml.PageSliderFormatter
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.GestureHelper
import com.shicheeng.copymanga.util.PageSelectPosition
import com.shicheeng.copymanga.util.ReaderSliderAttach
import com.shicheeng.copymanga.util.copy
import com.shicheeng.copymanga.util.getThemeColor
import com.shicheeng.copymanga.util.hasGlobalPoint
import com.shicheeng.copymanga.util.observe
import com.shicheeng.copymanga.util.transformPair
import com.shicheeng.copymanga.view.control.ReaderControl
import com.shicheeng.copymanga.viewmodel.ReaderViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@AndroidEntryPoint
class MangaReaderActivity : AppAttachCompatActivity(),
ConfigPagerSheet.CallBack,
PageSelectPosition,
GestureHelper.GestureListener,
ReaderControl.ControlDelegateListener,
IdlingDelegate.IdleCallback {
private lateinit var binding: ActivityMangaReaderBinding
private val viewModel by viewModels()
private val windowInsetsController by lazy {
WindowInsetsControllerCompat(window, binding.root)
}
@Inject
lateinit var settingPref: SettingPref
private lateinit var readerManager: ReaderManager
private lateinit var gestureHelper: GestureHelper
private lateinit var control: ReaderControl
private var isLast: Boolean = false
private var gestureInsets: Insets = Insets.NONE
private val idlingDelegate = IdlingDelegate(this)
override val readerMode: ReaderMode?
get() = readerManager.currentReaderMode
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMangaReaderBinding.inflate(layoutInflater)
setContentView(binding.root)
WindowCompat.setDecorFitsSystemWindows(window, false)
setCutoutShort(settingPref.cutoutDisplay)
windowsInsets(binding.root) { view, systemGesture ->
gestureInsets = systemGesture
binding.mangaReaderToolbar.updateLayoutParams {
topMargin = top
}
binding.mangaReaderBottomToolbar.updateLayoutParams {
bottomMargin = bottom
}
view.updateLayoutParams {
leftMargin = left
rightMargin = right
}
}
readerManager = ReaderManager(supportFragmentManager, R.id.manga_reader_container)
gestureHelper = GestureHelper(this, this)
control = ReaderControl(this, settingPref = settingPref)
viewModel.readerModel.observe(this, Lifecycle.State.STARTED) {
initializeReaderMode(it)
}
viewModel.information.transformPair().observe(this, this::onUIChange)
viewModel.errorHandler.observe(this, this::onError)
viewModel.loadingCounter.observe(this, this::onLoading)
viewModel.mangaContent.observe(this, this::withPageContent)
initializeBottomMenu()
binding.mangaReaderSlider.setLabelFormatter(PageSliderFormatter())
ReaderSliderAttach(this, viewModel).attach(binding.mangaReaderSlider)
binding.mangaReaderNext.setOnClickListener { loadChapter(true) }
binding.mangaReaderPrevious.setOnClickListener { loadChapter(false) }
idlingDelegate.bindToLifecycle(this)
}
private fun initializeReaderMode(readerMode: ReaderMode?) {
if (readerMode == null) return
binding.readerMangaModeTip.text = when (readerMode) {
ReaderMode.NORMAL -> getString(R.string.japanese_r_to_l)
ReaderMode.STANDARD -> getString(R.string.manga_mode_l_t_r)
ReaderMode.WEBTOON -> getString(R.string.korea_chinese_top_to_bottom)
}
if (readerManager.currentReaderMode != readerMode) {
readerManager.replace(readerMode)
}
}
private fun withPageContent(readerContent: ReaderContent) {
if (readerContent.list.isNotEmpty()) {
hideSystemBar(true)
}
}
private fun initializeBottomMenu() {
setSupportActionBar(binding.mangaReaderToolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.mangaReaderToolbar.setNavigationOnClickListener { finish() }
//The bottom menu refer from Tachiyomi
val materialShape = (binding.mangaReaderToolbar.background as MaterialShapeDrawable)
.apply {
elevation =
resources.getDimension(com.google.android.material.R.dimen.m3_sys_elevation_level2)
alpha = 242
}
binding.mangaReaderBottomToolbar.background = materialShape.copy(this@MangaReaderActivity)
binding.mangaReaderSeeker.background = materialShape.copy(this@MangaReaderActivity)?.apply {
setCornerSize(999f)
}
listOf(
binding.mangaReaderPrevious,
binding.mangaReaderNext,
binding.mangaReaderSetting
).forEach {
it.background = binding.mangaReaderSeeker.background.copy(this)
it.foreground = RippleDrawable(
ColorStateList.valueOf(getThemeColor(android.R.attr.colorControlHighlight)),
null,
it.background,
)
}
binding.mangaReaderSetting.setOnClickListener {
ConfigPagerSheet.show(
fragmentManager = supportFragmentManager,
reader = readerManager.currentReaderMode ?: return@setOnClickListener
)
}
val toolbarColor = ColorUtils.setAlphaComponent(
materialShape.resolvedTintColor,
materialShape.alpha
)
window.statusBarColor = toolbarColor
window.navigationBarColor = toolbarColor
}
// FIXME: 有时候没有提示
private fun onUIChange(pair: Pair) {
val (old: ReaderState?, state: ReaderState?) = pair
title = state?.mangaName ?: old?.mangaName ?: getString(android.R.string.unknownName)
if (state == null) {
supportActionBar?.subtitle = null
binding.mangaReaderSeeker.isVisible = false
return
}
binding.readerMangaSubtitle.text = state.subTime ?: getString(R.string.local)
supportActionBar?.subtitle =
state.chapterName ?: getString(android.R.string.unknownName)
isLast = state.currentPage == state.totalPage - 1
if (old?.chapterName != null && state.chapterName != old.chapterName) {
if (!state.chapterName.isNullOrEmpty()) {
binding.mangaReaderCircularProgressIndicator.tip(
state.chapterName,
TimeUnit.SECONDS.toMillis(1)
)
}
}
binding.mangaReaderPageIndicator.text =
getString(R.string.chapter_page_indicator, (state.currentPage + 1), state.totalPage)
binding.mangaReaderChapterTotalNumber.text = state.totalPage.toString()
binding.mangaReaderChapterNowNumber.text = (state.currentPage.plus(1)).toString()
if (!state.isSliderAvailable()) {
binding.mangaReaderSeeker.isInvisible = true
} else {
binding.mangaReaderSeeker.isInvisible = false
binding.mangaReaderSlider.valueTo = (state.totalPage.toFloat() - 1)
binding.mangaReaderSlider.value = state.currentPage.toFloat()
}
viewModel.saveLocalChapterState(state.currentPage)
}
override fun onPositionCallBack(page: MangaReaderPage) {
lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.mangaContent.value.list
val index = pages.indexOfFirst { it.urlHashCode == page.urlHashCode }
if (index != -1) {
withContext(Dispatchers.Main) {
readerManager.currentReader?.moveToPosition(position = index, index <= 2)
}
}
}
}
override fun onTouch(area: Int) {
control.onGridTouch(area, binding.mangaReaderContainer)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
return if (
rawX <= gestureInsets.left ||
rawY <= gestureInsets.top ||
rawX >= binding.root.width - gestureInsets.right ||
rawY >= binding.root.height - gestureInsets.bottom ||
binding.mangaReaderToolbar.hasGlobalPoint(rawX, rawY) ||
binding.mangaReaderBottomToolbar.hasGlobalPoint(rawX, rawY)
) {
false
} else {
val touchable = window.peekDecorView()?.touchables
touchable?.none { it.hasGlobalPoint(rawX, rawY) } ?: true
}
}
override fun scrollPage(delta: Int) {
readerManager.currentReader?.moveDelta(delta)
}
override fun hide() {
hideSystemBar(binding.mangaReaderToolbar.isVisible)
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
gestureHelper.dispatchTouchEvent(ev)
return super.dispatchTouchEvent(ev)
}
private fun loadChapter(isNext: Boolean) {
val uuid = viewModel.getCurrentReaderState().uuid
viewModel.loadNextPrvChapter(uuid, isNext)
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.currentState())
}
override fun onUserInteraction() {
super.onUserInteraction()
idlingDelegate.onUserInteraction()
}
override fun onModeChange(mode: ReaderMode) {
rebuildReaderNavigation(mode)
viewModel.switchMode(mode)
viewModel.saveCurrentState(readerManager.currentReader?.currentState())
}
private fun onError(e: Throwable?) {
with(binding.layoutErrorInclude) {
errorTextTip.setTextColor(getThemeColor(com.google.android.material.R.attr.colorSurface))
errorTextTipDesc.apply {
setTextColor(getThemeColor(com.google.android.material.R.attr.colorSurface))
text = e?.message
}
btnErrorRetry.setOnClickListener {
viewModel.retry()
this.root.isVisible = false
}
}
}
private fun onLoading(boolean: Boolean) {
val hasPages = viewModel.mangaContent.value.list.isNotEmpty()
binding.loadIndicator.isVisible = boolean && !hasPages
if (boolean && hasPages) {
binding.mangaReaderCircularProgressIndicator.show(R.string.in_loading_next_chapter)
} else {
binding.mangaReaderCircularProgressIndicator.hide()
}
}
private fun rebuildReaderNavigation(mode: ReaderMode) {
if (mode == ReaderMode.STANDARD || mode == ReaderMode.WEBTOON) {
binding.mangaReaderNav.layoutDirection = LAYOUT_DIRECTION_LTR
} else {
binding.mangaReaderNav.layoutDirection = LAYOUT_DIRECTION_RTL
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
readerManager.currentReader?.moveDelta(-1)
true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
readerManager.currentReader?.moveDelta(1)
true
}
else -> super.onKeyDown(keyCode, event)
}
}
private fun hideSystemBar(
isHide: Boolean,
) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(binding.mangaReaderToolbar))
.addTransition(Slide(Gravity.BOTTOM).addTarget(binding.mangaReaderBottomSheet))
TransitionManager.beginDelayedTransition(binding.root, transition)
binding.mangaReaderBottomSheet.isGone = isHide
binding.mangaReaderToolbar.isGone = isHide
binding.mangaReaderPageIndicator.isVisible = isHide
if (isHide) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
}
}
@TargetApi(Build.VERSION_CODES.P)
private fun setCutoutShort(enabled: Boolean) {
window.attributes.layoutInDisplayCutoutMode = when (enabled) {
true -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
false -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
// Trigger relayout
hideSystemBar(!binding.mangaReaderToolbar.isVisible)
}
companion object {
/**
* 跳转到[MangaReaderActivity]
* @param pathWord Path word
* @param uuid 章节uuid
*/
fun newInstance(
context: Context,
pathWord: String,
uuid: String,
): Intent {
val intent = Intent(context, MangaReaderActivity::class.java)
intent.putExtra(MangaLoader.MANGA_PATH_WORD, pathWord)
intent.putExtra(MangaLoader.MANGA_UUID, uuid)
return intent
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/MyApp.kt
================================================
package com.shicheeng.copymanga
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.google.android.material.color.DynamicColors
import com.shicheeng.copymanga.server.work.DetectMangaUpdateWork
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.ThemeMode
import com.shicheeng.copymanga.util.setSystemNightMode
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class MyApp : Application(), Configuration.Provider {
companion object {
lateinit var appContext: Context
}
@Inject
lateinit var workerFactory: HiltWorkerFactory
private lateinit var notificationManager: NotificationManager
@Inject
lateinit var settingPref: SettingPref
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
notificationManager = applicationContext
.getSystemService(NotificationManager::class.java)
DynamicColors.applyToActivitiesIfAvailable(this)
appContext = applicationContext
bindNotification()
val themeMode = ThemeMode.valueOf(settingPref.appThemeMode)
setSystemNightMode(themeMode)
}
private fun bindNotification() {
val name = getString(R.string.update_manga)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val mChannel = NotificationChannel(
/* id = */ DetectMangaUpdateWork.DETECT_UPDATE_CHANELLE,
/* name = */ name,
/* importance = */ importance
)
notificationManager.createNotificationChannel(mChannel)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/app/AppAttachCompatActivity.kt
================================================
package com.shicheeng.copymanga.app
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
open class AppAttachCompatActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
}
inline fun windowsInsets(
root: View,
crossinline update: Insets.(v: View, gestureInsets: Insets) -> Unit,
) {
ViewCompat.setOnApplyWindowInsetsListener(root) { view: View, windowInsetsCompat: WindowInsetsCompat ->
val insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars())
val systemGestureInsets =
windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemGestures())
update(insets, view, systemGestureInsets)
WindowInsetsCompat.Builder()
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
.build()
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/app/BaseFragment.kt
================================================
package com.shicheeng.copymanga.app
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
abstract class BaseFragment : Fragment(), View.OnAttachStateChangeListener {
private var _binding: VB? = null
protected val binding: VB get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = onViewBindingIn(inflater, container)
binding.root.addOnAttachStateChangeListener(this)
return binding.root
}
override fun onViewAttachedToWindow(v: View) {
val insetsCompat = ViewCompat.getRootWindowInsets(v)
val systemBarInsets = insetsCompat?.getInsets(WindowInsetsCompat.Type.systemBars())
onFragmentInsets(systemBarInsets, v)
}
override fun onViewDetachedFromWindow(v: View) {
}
abstract fun onFragmentInsets(systemBarInsets: Insets?, view: View)
abstract fun onViewBindingIn(inflater: LayoutInflater, container: ViewGroup?): VB
override fun onDestroy() {
_binding = null
super.onDestroy()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/dao/MangaLoginDao.kt
================================================
package com.shicheeng.copymanga.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.Upsert
import com.shicheeng.copymanga.data.login.LocalLoginDataModel
import kotlinx.coroutines.flow.Flow
@Dao
interface MangaLoginDao {
@Upsert
suspend fun updateOrInsertLoginData(localLoginDataModel: LocalLoginDataModel)
@Query("SELECT * FROM LocalLoginDataModel")
fun getLoginData(): Flow>
@Query("SELECT * FROM LocalLoginDataModel")
suspend fun getLoginDataAsync(): List
@Query("SELECT * FROM LocalLoginDataModel where userID = :userID LIMIT 1")
fun getLoginDataByUserId(userID: String): Flow
@Query("SELECT * FROM LocalLoginDataModel where userID = :userID LIMIT 1")
suspend fun getLoginDataByUserIdSafety(userID: String?): LocalLoginDataModel?
@Upsert
suspend fun updateOrInsertLoginData(vararg localLoginDataModels: LocalLoginDataModel)
@Query("SELECT token FROM LocalLoginDataModel where userID = :uuid LIMIT 1")
fun getCurrentToken(uuid: String): String
@Query("SELECT isExpired FROM LocalLoginDataModel where userID = :uuid LIMIT 1")
fun isExpired(uuid: String): Boolean
@Query("SELECT isExpired FROM LocalLoginDataModel where userID = :uuid LIMIT 1")
fun isExpiredFlow(uuid: String): Flow
@Delete
suspend fun deleteLoginData(localLoginDataModel: LocalLoginDataModel)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/dao/MangeLocalHistoryDao.kt
================================================
package com.shicheeng.copymanga.dao
import androidx.room.*
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import kotlinx.coroutines.flow.Flow
@Dao
interface MangeLocalHistoryDao {
@Query("SELECT * FROM manga_history_key ORDER by time DESC")
fun getAllHistory(): Flow>
@Query("SELECT * FROM manga_history_key ORDER by time DESC")
suspend fun fetchTotalManga(): List
@Query("SELECT * FROM manga_history_key WHERE pathWord LIKE :pathWord LIMIT 1")
suspend fun getHistoryForInfoByPathWord(pathWord: String): MangaHistoryDataModel?
@Query("SELECT * FROM manga_history_key WHERE pathWord LIKE :pathWord LIMIT 1")
fun fetchHistoryByPathWordInFlow(pathWord: String): Flow
@Transaction
@Query("SELECT * FROM manga_history_key WHERE pathWord = :pathWord LIMIT 1")
suspend fun getMangaByPathWord(pathWord: String): LocalSavableMangaModel?
@Query("SELECT * FROM LocalChapter WHERE comicPathWord = :pathWord")
suspend fun fetchMangaChaptersByPathWord(pathWord: String): List?
@Query("SELECT * FROM LocalChapter WHERE comicPathWord = :pathWord")
fun fetchMangaChaptersByPathWordFlow(pathWord: String): Flow?>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun addLocal(manga: MangaHistoryDataModel)
@Upsert
suspend fun addLocalChapter(chapter: LocalChapter)
@Upsert
suspend fun addLocalChapter(vararg chapter: LocalChapter)
@Upsert
suspend fun updateLocal(manga: MangaHistoryDataModel)
@Query("DELETE FROM manga_history_key")
suspend fun deleteAllHistory()
@Delete
suspend fun deleteSingle(manga: MangaHistoryDataModel)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/dao/SearchHistoryDao.kt
================================================
package com.shicheeng.copymanga.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.Upsert
import com.shicheeng.copymanga.data.searchhistory.SearchHistory
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchHistoryDao {
@Query("SELECT * FROM SearchHistory ORDER by time DESC")
fun loadWordHistory(): Flow>
@Delete
suspend fun detectSearchedWordHistory(searchHistory: SearchHistory)
@Query("DELETE FROM SearchHistory")
suspend fun delThing()
@Upsert
suspend fun upsertWord(searchHistory: SearchHistory)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/BannerList.java
================================================
package com.shicheeng.copymanga.data;
import com.google.gson.JsonObject;
public class BannerList {
private JsonObject jsonObject;
public void setJsonObject(JsonObject jsonObject) {
this.jsonObject = jsonObject;
}
public JsonObject getJsonObject() {
return jsonObject;
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/ChipTextBean.kt
================================================
package com.shicheeng.copymanga.data
import androidx.annotation.DrawableRes
data class ChipTextBean(
var text: String,
var pathWord: String,
@DrawableRes val ids: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/DataBannerBean.java
================================================
package com.shicheeng.copymanga.data;
public class DataBannerBean {
private String bannerImageUrl;
private String bannerBrief;
private String uuidManga;
public DataBannerBean() {
}
public String getBannerBrief() {
return bannerBrief;
}
public void setBannerBrief(String bannerBrief) {
this.bannerBrief = bannerBrief;
}
public String getBannerImageUrl() {
return bannerImageUrl;
}
public void setBannerImageUrl(String bannerImageUrl) {
this.bannerImageUrl = bannerImageUrl;
}
public String getUuidManga() {
return uuidManga;
}
public void setUuidManga(String uuidManga) {
this.uuidManga = uuidManga;
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/ListBeanManga.kt
================================================
package com.shicheeng.copymanga.data
data class ListBeanManga(
var nameManga: String,
var authorManga: String,
var urlCoverManga: String,
var pathWordManga: String,
) {
constructor() : this(
nameManga = "",
authorManga = "",
urlCoverManga = "",
pathWordManga = ""
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/LocalManga.kt
================================================
package com.shicheeng.copymanga.data
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import java.io.File
data class LocalManga(
val localSavableMangaModel: LocalSavableMangaModel,
val file:File,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MainPageDataModel.kt
================================================
package com.shicheeng.copymanga.data
data class MainPageDataModel(
val listBanner: List,
val listRecommend: List,
val listRankDay: List,
val listRankWeek: List,
val listRankMonth: List,
val listHot: List,
val listNewest: List,
val listFinished: List,
val topicList:List
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MainTopicDataModel.kt
================================================
package com.shicheeng.copymanga.data
data class MainTopicDataModel(
val name: String,
val type: Int,
val brief: String,
val pathWord: String,
val coverUrl: String,
val datetimeCreated: String,
val period: String,
val journal: String,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaGenernal.kt
================================================
package com.shicheeng.copymanga.data
data class MangaInfoData(
val title: String,
val alias: String,
val mangaDetail: String,
val mangaStatus: String,
val authorList: String,
val themeList: List,
val mangaCoverUrl: String,
val mangaUUID: String,
val mangaStatusId: Int,
val mangaRegion: String,
val mangaLastUpdate: String,
val mangaPopularNumber: String,
)
data class MangaRankMiniModel(
val name: String,
val author: String,
val urlCover: String,
val popular: String,
val riseHot: String,
val pathWord: String,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaHistoryDataModel.kt
================================================
package com.shicheeng.copymanga.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.shicheeng.copymanga.data.info.Author
import com.shicheeng.copymanga.database.AuthorToStringConvert
import com.shicheeng.copymanga.database.StringToBeanConvert
@TypeConverters(StringToBeanConvert::class, AuthorToStringConvert::class)
@Entity(tableName = "manga_history_key")
data class MangaHistoryDataModel(
val name: String,
val time: Long,
val alias: String?,
val url: String,
@PrimaryKey val pathWord: String,
val comicUUID:String,
val nameChapter: String,
val positionChapter: Int,
val positionPage: Int,
val readerModeId: Int,
val mangaDetail: String,
val mangaStatus: String,
val authorList: List,
val themeList: List,
val mangaStatusId: Int,
val mangaRegion: String,
val mangaLastUpdate: String,
val mangaPopularNumber: String,
val isSubscribe: Boolean,
)
data class MangaState(
val uuid: String,
val page: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaInfoChapterDataBean.kt
================================================
package com.shicheeng.copymanga.data
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class MangaInfoChapterDataBean(
val chapterTitle: String,
val chapterTime: String,
val uuidText: String,
val readerProgress: Int?,
val isDownloading: Boolean = false,
val pathWord: String,
val isSaved: Boolean = false,
) : Parcelable {
@IgnoredOnParcel
var isSelect: Boolean = false
fun toDownloadChapter(): MangaDownloadChapterInfoModel {
return MangaDownloadChapterInfoModel(chapterTitle, uuidText, pathWord)
}
fun toMangaState(): MangaState {
return MangaState(uuidText, readerProgress ?: 0)
}
}
@Parcelize
data class LastMangaDownload(
/**
* 漫画名字
*/
val mangaName: String,
val coverUrl: String,
val list: List,
) :
Parcelable
@Parcelize
data class MangaDownloadChapterInfoModel(
val chapterTitle: String,
/**
* 漫画章节的UUID
*/
val uuidText: String,
/**
* 漫画的pathWord
*/
val pathWord: String,
) : Parcelable {
override fun hashCode(): Int {
return pathWord.hashCode() + uuidText.length + chapterTitle.hashCode()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaDownloadChapterInfoModel
if (chapterTitle != other.chapterTitle) return false
if (uuidText != other.uuidText) return false
if (pathWord != other.pathWord) return false
return true
}
}
@Parcelize
data class MangaDownloads(
val urlList: List,
val wordsList: List,
) : Parcelable {
override fun hashCode(): Int {
return urlList.size + wordsList.size
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaDownloads
if (urlList != other.urlList) return false
if (wordsList != other.wordsList) return false
return true
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaReadInformation.kt
================================================
package com.shicheeng.copymanga.data
data class MangaReadInformation(
val subtitle: String?,
val time: String?,
val size: Int?,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaReaderPage.kt
================================================
package com.shicheeng.copymanga.data
data class MangaReaderPage(
val url: String,
val uuid: String?,
val index: Int,
val urlHashCode: Int = url.hashCode(),
) {
override fun hashCode(): Int {
return url.hashCode() + uuid.hashCode() * 212
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaReaderPage
if (url != other.url) return false
if (uuid != other.uuid) return false
if (index != other.index) return false
if (urlHashCode != other.urlHashCode) return false
return true
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaSortBean.java
================================================
package com.shicheeng.copymanga.data;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
public class MangaSortBean implements Parcelable {
private String pathName;
private String pathWord;
public MangaSortBean(String pathName, String pathWord) {
this.pathName = pathName;
this.pathWord = pathWord;
}
public MangaSortBean() {
}
protected MangaSortBean(Parcel in) {
pathName = in.readString();
pathWord = in.readString();
}
public static final Creator CREATOR = new Creator() {
@Override
public MangaSortBean createFromParcel(Parcel in) {
return new MangaSortBean(in);
}
@Override
public MangaSortBean[] newArray(int size) {
return new MangaSortBean[size];
}
};
public String getPathWord() {
return pathWord;
}
public void setPathWord(String pathWord) {
this.pathWord = pathWord;
}
public String getPathName() {
return pathName;
}
public void setPathName(String pathName) {
this.pathName = pathName;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
dest.writeString(pathName);
dest.writeString(pathWord);
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/PersonalDataModel.kt
================================================
package com.shicheeng.copymanga.data
import android.net.Uri
import android.os.Parcelable
import androidx.annotation.StringRes
import kotlinx.parcelize.Parcelize
data class PersonalDataModel(@StringRes val title: Int, val list: List)
@Parcelize
data class PersonalInnerDataModel(
val name: String,
val url: Uri?,
val pathWord: String?,
) : Parcelable
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/ReaderDataModels.kt
================================================
package com.shicheeng.copymanga.data
data class ReaderContent(val list: List, val state: MangaState?)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/ReaderState.kt
================================================
package com.shicheeng.copymanga.data
data class ReaderState(
val mangaName:String?,
val chapterName: String?,
val subTime: String?,
val uuid: String?,
val totalPage: Int,
val currentPage: Int,
val chapterPosition: Int,
){
fun isSliderAvailable(): Boolean {
return totalPage > 1 && currentPage < totalPage
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/UpdateMetadata.kt
================================================
package com.shicheeng.copymanga.data
data class VersionId(
val major: Int,
val minor: Int,
val build: Int,
val type: String,
val typeRNum: Int,
) : Comparable {
override fun compareTo(other: VersionId): Int {
var diff = major.compareTo(other.major)
if (diff != 0) return diff
diff = minor.compareTo(other.minor)
if (diff != 0) return diff
diff = build.compareTo(other.build)
if (diff != 0) return diff
diff = typeCompareWeight(type).compareTo(typeCompareWeight(other.type))
if (diff != 0) return diff
return typeRNum.compareTo(other.typeRNum)
}
private fun typeCompareWeight(type: String): Int = when (type) {
"FIX" -> 8
"PATCH" -> 4
"" -> 2
else -> 0
}
}
data class VersionUnit(
val id: Long,
val htmlUrl: String,
val versionName: String,
val apkUrl: String,
val apkSize: Long,
val description: String,
val time: String,
val versionId: VersionId = versionId(versionName),
)
fun versionId(nameTag: String): VersionId {
val part = nameTag.substringBefore("-").split(".")
val name = nameTag.substringAfter("-", "")
return VersionId(
major = part.getOrNull(0)?.toIntOrNull() ?: 0,
minor = part.getOrNull(1)?.toIntOrNull() ?: 0,
build = part.getOrNull(2)?.toIntOrNull() ?: 0,
type = name.filter(Char::isUpperCase),
typeRNum = name.filter(Char::isDigit).toIntOrNull() ?: 0
)
}
val VersionId.isNormal get() = type.isEmpty()
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/Author.kt
================================================
package com.shicheeng.copymanga.data.authormanga
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/AuthorMangaItem.kt
================================================
package com.shicheeng.copymanga.data.authormanga
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class AuthorMangaItem(
@Json(name = "author")
val author: List,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_updated")
val datetimeUpdated: String,
@Json(name = "females")
val females: List,
@Json(name = "free_type")
val freeType: FreeType,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "theme")
val theme: List
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/AuthorsMangaDataModel.kt
================================================
package com.shicheeng.copymanga.data.authormanga
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class AuthorsMangaDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/FreeType.kt
================================================
package com.shicheeng.copymanga.data.authormanga
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class FreeType(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/Results.kt
================================================
package com.shicheeng.copymanga.data.authormanga
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/chapter/Chapter.kt
================================================
package com.shicheeng.copymanga.data.chapter
import androidx.annotation.Keep
import com.shicheeng.copymanga.data.local.LocalChapter
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Chapter(
@Json(name = "comic_id")
val comicId: String,
@Json(name = "comic_path_word")
val comicPathWord: String,
@Json(name = "count")
val count: Int,
var datetime_created: String,
@Json(name = "group_id")
val groupId: Any?,
@Json(name = "group_path_word")
val groupPathWord: String,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "index")
val index: Int,
@Json(name = "name")
val name: String,
@Json(name = "news")
val news: String,
@Json(name = "next")
val next: String?,
@Json(name = "ordered")
val ordered: Int,
@Json(name = "prev")
val prev: String?,
@Json(name = "size")
val size: Int,
@Json(name = "type")
val type: Int,
@Json(name = "uuid")
val uuid: String,
)
fun Chapter.toLocalChapter(
readIndex: Int,
isReadInProgress: Boolean,
isDownloaded: Boolean,
isReadFinish: Boolean,
): LocalChapter {
return LocalChapter(
comicId = comicId,
comicPathWord = comicPathWord,
count = count,
datetime_created = datetime_created,
groupId = groupId as String?,
groupPathWord = groupPathWord,
imgType = imgType,
index = index,
readIndex = readIndex,
isReadProgress = isReadInProgress,
name = name,
news = news,
next = next,
ordered = ordered,
prev = prev,
size = size,
type = type,
uuid = uuid,
isDownloaded = isDownloaded,
isReadFinish = isReadFinish
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/chapter/ChapterDataModel.kt
================================================
package com.shicheeng.copymanga.data.chapter
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class ChapterDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/chapter/Results.kt
================================================
package com.shicheeng.copymanga.data.chapter
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/collect/ComicCollectDataModel.kt
================================================
package com.shicheeng.copymanga.data.collect
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class ComicCollectDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Any?
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/commentpush/CommentPushDataModel.kt
================================================
package com.shicheeng.copymanga.data.commentpush
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class CommentPushDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Any?
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/downloadmodel/DownloadUiDataModel.kt
================================================
package com.shicheeng.copymanga.data.downloadmodel
import android.text.format.DateUtils
import androidx.work.WorkInfo
import androidx.work.Worker
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import java.lang.invoke.MethodHandles.Lookup
import java.util.UUID
data class DownloadUiDataModel(
val localSavableMangaModel: LocalSavableMangaModel,
val pathWord: String,
val progress: Int,
val max: Int,
val error: String?,
val isIndeterminate: Boolean,
val isPause: Boolean,
val isStopped: Boolean,
val workerState: WorkInfo.State,
val timeStamp: Long,
val totalChapter: Int,
val id: UUID,
val eta: Long,
) : Comparable {
val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f
val hasEta: Boolean
get() = workerState == WorkInfo.State.RUNNING && !isPause && eta > 0L
override fun compareTo(other: DownloadUiDataModel): Int {
return timeStamp.compareTo(other.timeStamp)
}
val canResume: Boolean
get() = workerState == WorkInfo.State.RUNNING && isPause
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS,
)
} else {
null
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Author.kt
================================================
package com.shicheeng.copymanga.data.finished
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/FinishedMangaDataModel.kt
================================================
package com.shicheeng.copymanga.data.finished
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class FinishedMangaDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/FreeType.kt
================================================
package com.shicheeng.copymanga.data.finished
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class FreeType(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Item.kt
================================================
package com.shicheeng.copymanga.data.finished
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "author")
val author: List,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_updated")
val datetimeUpdated: String?,
@Json(name = "females")
val females: List,
@Json(name = "free_type")
val freeType: FreeType,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "theme")
val theme: List,
) {
fun authorReformation() = buildString {
author.forEachIndexed { index, a ->
append(a.name)
if (index != author.lastIndex) {
append(",")
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Results.kt
================================================
package com.shicheeng.copymanga.data.finished
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Theme.kt
================================================
package com.shicheeng.copymanga.data.finished
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Theme(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Author.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Comic.kt
================================================
package com.shicheeng.copymanga.data.info
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "alias")
val alias: String?,
@Json(name = "author")
val author: List,
@Json(name = "brief")
val brief: String,
@Json(name = "clubs")
val clubs: List,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_updated")
val datetimeUpdated: String?,
@Json(name = "females")
val females: List,
@Json(name = "free_type")
val freeType: FreeType,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "last_chapter")
val lastChapter: LastChapter,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "parodies")
val parodies: List,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "reclass")
val reclass: Reclass,
@Json(name = "region")
val region: Region,
@Json(name = "restrict")
val restrict: Restrict,
@Json(name = "seo_baidu")
val seoBaidu: String,
@Json(name = "status")
val status: Status,
@Json(name = "theme")
val theme: List,
@Json(name = "uuid")
val uuid: String,
){
fun authorReformation() = buildString {
author.forEachIndexed { index, a ->
append(a.name)
if (index != author.lastIndex) {
append(",")
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Default.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Default(
@Json(name = "count")
val count: Int,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/FreeType.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class FreeType(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Groups.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Groups(
@Json(name = "default")
val default: Default?
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/LastChapter.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class LastChapter(
@Json(name = "name")
val name: String,
@Json(name = "uuid")
val uuid: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/MangaInfoDataModel.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MangaInfoDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Reclass.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Reclass(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Region.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Region(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Restrict.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Restrict(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Results.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "comic")
val comic: Comic,
@Json(name = "groups")
val groups: Groups,
@Json(name = "is_lock")
val isLock: Boolean,
@Json(name = "is_login")
val isLogin: Boolean,
@Json(name = "is_mobile_bind")
val isMobileBind: Boolean,
@Json(name = "is_vip")
val isVip: Boolean,
@Json(name = "popular")
val popular: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Status.kt
================================================
package com.shicheeng.copymanga.data.info
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Status(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Theme.kt
================================================
package com.shicheeng.copymanga.data.info
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Theme(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/local/Chapter.kt
================================================
package com.shicheeng.copymanga.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.shicheeng.copymanga.data.MangaState
@Entity
data class LocalChapter(
val comicId: String,
val comicPathWord: String,
val count: Int,
var datetime_created: String,
val groupId: String?,
val groupPathWord: String,
val imgType: Int,
val index: Int,
val readIndex: Int,
val isReadProgress: Boolean,
val name: String,
val news: String,
val next: String?,
val ordered: Int,
val prev: String?,
val size: Int,
val type: Int,
@PrimaryKey val uuid: String,
val isDownloaded: Boolean,
val isReadFinish: Boolean,
)
fun LocalChapter.toMangaState(): MangaState {
return MangaState(
uuid = uuid,
page = if (isReadFinish) 0 else readIndex
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/local/LocalSavableMangaModel.kt
================================================
package com.shicheeng.copymanga.data.local
import androidx.room.Embedded
import androidx.room.Relation
import com.shicheeng.copymanga.data.MangaHistoryDataModel
data class LocalSavableMangaModel(
@Embedded val mangaHistoryDataModel: MangaHistoryDataModel,
@Relation(
parentColumn = "pathWord",
entityColumn = "comicPathWord"
)
val list: List,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/lofininfo/LoginInfoDataModel.kt
================================================
package com.shicheeng.copymanga.data.lofininfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class LoginInfoDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/lofininfo/Results.kt
================================================
package com.shicheeng.copymanga.data.lofininfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "ads_vip_end")
val adsVipEnd: Any?,
@Json(name = "avatar")
val avatar: String,
@Json(name = "b_sstv")
val bSstv: Boolean,
@Json(name = "b_verify_email")
val bVerifyEmail: Boolean,
@Json(name = "cartoon_vip")
val cartoonVip: Int,
@Json(name = "cartoon_vip_end")
val cartoonVipEnd: Any?,
@Json(name = "cartoon_vip_start")
val cartoonVipStart: Any?,
@Json(name = "close_report")
val closeReport: Boolean,
@Json(name = "comic_vip")
val comicVip: Int,
@Json(name = "comic_vip_end")
val comicVipEnd: Any?,
@Json(name = "comic_vip_start")
val comicVipStart: Any?,
@Json(name = "datetime_created")
val datetimeCreated: String,
@Json(name = "day_downloads")
val dayDownloads: Int,
@Json(name = "day_downloads_refresh")
val dayDownloadsRefresh: String,
@Json(name = "downloads")
val downloads: Int,
@Json(name = "email")
val email: String,
@Json(name = "invite_code")
val inviteCode: Any?,
@Json(name = "invited")
val invited: Any?,
@Json(name = "is_authenticated")
val isAuthenticated: Boolean,
@Json(name = "mobile")
val mobile: Any?,
@Json(name = "mobile_region")
val mobileRegion: Any?,
@Json(name = "nickname")
val nickname: String,
@Json(name = "point")
val point: Int,
@Json(name = "reward_downloads")
val rewardDownloads: Int,
@Json(name = "scy_answer")
val scyAnswer: Boolean,
@Json(name = "user_id")
val userId: String,
@Json(name = "username")
val username: String,
@Json(name = "vip_downloads")
val vipDownloads: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/login/LocalLoginDataModel.kt
================================================
package com.shicheeng.copymanga.data.login
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.shicheeng.copymanga.data.lofininfo.LoginInfoDataModel
@Entity
data class LocalLoginDataModel(
val avatarImageUrl: String,
val nikeName: String,
val userName: String,
val token: String,
@PrimaryKey val userID: String,
val email: String,
val selected: Boolean,
val isExpired: Boolean,
)
fun LoginDataModel.toLoginDataModel(isSelected: Boolean = false) = LocalLoginDataModel(
nikeName = results.nickname,
userName = results.username,
email = results.email,
token = results.token,
userID = results.userId,
avatarImageUrl = results.avatar,
selected = isSelected,
isExpired = false
)
fun LoginInfoDataModel.toLoginDataModel(
localLoginDataModel: LocalLoginDataModel,
isSelected: Boolean = false,
isExpired: Boolean,
) = LocalLoginDataModel(
nikeName = results.nickname,
userName = results.username,
email = results.email,
token = localLoginDataModel.token,
userID = results.userId,
avatarImageUrl = results.avatar,
selected = isSelected,
isExpired = isExpired
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/login/LoginDataModel.kt
================================================
package com.shicheeng.copymanga.data.login
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class LoginDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/login/Results.kt
================================================
package com.shicheeng.copymanga.data.login
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "ads_vip_end")
val adsVipEnd: Any?,
@Json(name = "avatar")
val avatar: String,
@Json(name = "b_sstv")
val bSstv: Boolean,
@Json(name = "b_verify_email")
val bVerifyEmail: Boolean,
@Json(name = "cartoon_vip")
val cartoonVip: Int,
@Json(name = "cartoon_vip_end")
val cartoonVipEnd: Any?,
@Json(name = "cartoon_vip_start")
val cartoonVipStart: Any?,
@Json(name = "close_report")
val closeReport: Boolean,
@Json(name = "comic_vip")
val comicVip: Int,
@Json(name = "comic_vip_end")
val comicVipEnd: Any?,
@Json(name = "comic_vip_start")
val comicVipStart: Any?,
@Json(name = "datetime_created")
val datetimeCreated: String,
@Json(name = "downloads")
val downloads: Int,
@Json(name = "email")
val email: String,
@Json(name = "invite_code")
val inviteCode: Any?,
@Json(name = "invited")
val invited: Any?,
@Json(name = "is_authenticated")
val isAuthenticated: Boolean,
@Json(name = "mobile")
val mobile: Any?,
@Json(name = "mobile_region")
val mobileRegion: Any?,
@Json(name = "nickname")
val nickname: String,
@Json(name = "point")
val point: Int,
@Json(name = "reward_downloads")
val rewardDownloads: Int,
@Json(name = "scy_answer")
val scyAnswer: Boolean,
@Json(name = "token")
val token: String,
@Json(name = "user_id")
val userId: String,
@Json(name = "username")
val username: String,
@Json(name = "vip_downloads")
val vipDownloads: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/Gender.kt
================================================
package com.shicheeng.copymanga.data.logininfoshort
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Gender(
@Json(name = "key")
val key: Int,
@Json(name = "value")
val value: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/GenderX.kt
================================================
package com.shicheeng.copymanga.data.logininfoshort
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class GenderX(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/Info.kt
================================================
package com.shicheeng.copymanga.data.logininfoshort
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Info(
@Json(name = "avatar")
val avatar: String,
@Json(name = "avatar_rp")
val avatarRp: String,
@Json(name = "gender")
val gender: GenderX,
@Json(name = "invite_code")
val inviteCode: Any?,
@Json(name = "mobile")
val mobile: Any?,
@Json(name = "mobile_region")
val mobileRegion: Any?,
@Json(name = "nickname")
val nickname: String,
@Json(name = "username")
val username: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/LoginInfoShortDataModel.kt
================================================
package com.shicheeng.copymanga.data.logininfoshort
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class LoginInfoShortDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/Results.kt
================================================
package com.shicheeng.copymanga.data.logininfoshort
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "genders")
val genders: List,
@Json(name = "info")
val info: Info
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacomment/MangaCommentDataModel.kt
================================================
package com.shicheeng.copymanga.data.mangacomment
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class MangaCommentDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacomment/MangaCommentListItem.kt
================================================
package com.shicheeng.copymanga.data.mangacomment
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class MangaCommentListItem(
@Json(name = "comment")
val comment: String,
@Json(name = "count")
val count: Int,
@Json(name = "create_at")
val createAt: String,
@Json(name = "id")
val id: Int,
@Json(name = "parent_id")
val parentId: Any?,
@Json(name = "parent_user_id")
val parentUserId: Any?,
@Json(name = "parent_user_name")
val parentUserName: Any?,
@Json(name = "user_avatar")
val userAvatar: String,
@Json(name = "user_id")
val userId: String,
@Json(name = "user_name")
val userName: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacomment/Results.kt
================================================
package com.shicheeng.copymanga.data.mangacomment
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Chapter.kt
================================================
package com.shicheeng.copymanga.data.mangacontent
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Chapter(
@Json(name = "comic_id")
val comicId: String,
@Json(name = "comic_path_word")
val comicPathWord: String,
@Json(name = "contents")
val contents: List,
@Json(name = "count")
val count: Int,
@Json(name = "datetime_created")
val datetimeCreated: String,
@Json(name = "group_id")
val groupId: Any?,
@Json(name = "group_path_word")
val groupPathWord: String,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "index")
val index: Int,
@Json(name = "is_long")
val isLong: Boolean,
@Json(name = "name")
val name: String,
@Json(name = "news")
val news: String,
@Json(name = "next")
val next: String?,
@Json(name = "ordered")
val ordered: Int,
@Json(name = "prev")
val prev: String?,
@Json(name = "size")
val size: Int,
@Json(name = "type")
val type: Int,
@Json(name = "uuid")
val uuid: String,
@Json(name = "words")
val words: List
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Comic.kt
================================================
package com.shicheeng.copymanga.data.mangacontent
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "restrict")
val restrict: Restrict,
@Json(name = "uuid")
val uuid: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Content.kt
================================================
package com.shicheeng.copymanga.data.mangacontent
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Content(
@Json(name = "url")
val url: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/MangaContentDataModel.kt
================================================
package com.shicheeng.copymanga.data.mangacontent
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class MangaContentDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Restrict.kt
================================================
package com.shicheeng.copymanga.data.mangacontent
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Restrict(
@Json(name = "display")
val display: String,
@Json(name = "value")
val value: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Results.kt
================================================
package com.shicheeng.copymanga.data.mangacontent
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "chapter")
val chapter: Chapter,
@Json(name = "comic")
val comic: Comic,
@Json(name = "is_lock")
val isLock: Boolean,
@Json(name = "is_login")
val isLogin: Boolean,
@Json(name = "is_mobile_bind")
val isMobileBind: Boolean,
@Json(name = "is_vip")
val isVip: Boolean,
@Json(name = "show_app")
val showApp: Boolean
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/Author.kt
================================================
package com.shicheeng.copymanga.data.newsest
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/Comic.kt
================================================
package com.shicheeng.copymanga.data.newsest
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "author")
val author: List,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_updated")
val datetimeUpdated: String,
@Json(name = "females")
val females: List,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "last_chapter_name")
val lastChapterName: String,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "theme")
val theme: List,
) {
fun authorReformation() = buildString {
author.forEachIndexed { index, a ->
append(a.name)
if (index != author.lastIndex) {
append(",")
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/MangaBlock.kt
================================================
package com.shicheeng.copymanga.data.newsest
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class MangaBlock(
@Json(name = "comic")
val comic: Comic,
@Json(name = "datetime_created")
val datetimeCreated: String,
@Json(name = "name")
val name: String,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/NewestListDataModel.kt
================================================
package com.shicheeng.copymanga.data.newsest
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class NewestListDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/Results.kt
================================================
package com.shicheeng.copymanga.data.newsest
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Author.kt
================================================
package com.shicheeng.copymanga.data.rank
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Comic.kt
================================================
package com.shicheeng.copymanga.data.rank
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "author")
val author: List,
@Json(name = "cover")
val cover: String,
@Json(name = "females")
val females: List,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "theme")
val theme: List,
) {
fun authorThat() = buildString {
author.forEachIndexed { index: Int, authorIn: Author ->
append(authorIn.name)
if (index != author.lastIndex) {
append(",")
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Item.kt
================================================
package com.shicheeng.copymanga.data.rank
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "comic")
val comic: Comic,
@Json(name = "date_type")
val dateType: Int,
@Json(name = "popular")
val popular: Int,
@Json(name = "rise_num")
val riseNum: Int,
@Json(name = "rise_sort")
val riseSort: Int,
@Json(name = "sort")
val sort: Int,
@Json(name = "sort_last")
val sortLast: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/RankDataModel.kt
================================================
package com.shicheeng.copymanga.data.rank
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class RankDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Results.kt
================================================
package com.shicheeng.copymanga.data.rank
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/recommend/RecommendDataModel.kt
================================================
package com.shicheeng.copymanga.data.recommend
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class RecommendDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results,
) {
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
) {
@Keep
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "comic")
val comic: Comic,
@Json(name = "type")
val type: Int,
) {
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "author")
val author: List,
@Json(name = "cover")
val cover: String,
@Json(name = "females")
val females: List,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "theme")
val theme: List,
) {
fun authorReformation() = buildString {
author.forEachIndexed { index, a ->
append(a.name)
if (index != author.lastIndex) {
append(",")
}
}
}
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
)
@Keep
@JsonClass(generateAdapter = true)
data class Theme(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
)
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/search/Author.kt
================================================
package com.shicheeng.copymanga.data.search
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "alias")
val alias: String?,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/search/Results.kt
================================================
package com.shicheeng.copymanga.data.search
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/search/SearchDataModel.kt
================================================
package com.shicheeng.copymanga.data.search
import com.squareup.moshi.Json
import androidx.annotation.Keep
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class SearchDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/search/SearchResultDataModel.kt
================================================
package com.shicheeng.copymanga.data.search
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class SearchResultDataModel(
@Json(name = "alias")
val alias: String?,
@Json(name = "author")
val author: List,
@Json(name = "cover")
val cover: String,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
) {
fun authorReformation() = buildString {
author.forEachIndexed { index, a ->
append(a.name)
if (index != author.lastIndex) {
append(",")
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/searchhelpword/SearchTermWordDataModel.kt
================================================
package com.shicheeng.copymanga.data.searchhelpword
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class SearchTermWordDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "data")
val `data`: List,
@Json(name = "msg")
val msg: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/searchhistory/SearchHistory.kt
================================================
package com.shicheeng.copymanga.data.searchhistory
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class SearchHistory(
@PrimaryKey
val word: String,
val time:Long,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/searchrecommend/Data.kt
================================================
package com.shicheeng.copymanga.data.searchrecommend
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "title")
val title: String,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/searchrecommend/SearchRecommendDataModel.kt
================================================
package com.shicheeng.copymanga.data.searchrecommend
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class SearchRecommendDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "data")
val `data`: List,
@Json(name = "msg")
val msg: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/Ordering.kt
================================================
package com.shicheeng.copymanga.data.sorttag
import com.squareup.moshi.Json
import androidx.annotation.Keep
@Keep
data class Ordering(
@Json(name = "datetime_updated")
val datetimeUpdated: String,
@Json(name = "popular")
val popular: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/Results.kt
================================================
package com.shicheeng.copymanga.data.sorttag
import androidx.annotation.Keep
import com.squareup.moshi.Json
@Keep
data class Results(
@Json(name = "ordering")
val ordering: Ordering,
@Json(name = "theme")
val theme: List,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/SortTagsDataModel.kt
================================================
package com.shicheeng.copymanga.data.sorttag
import com.squareup.moshi.Json
import androidx.annotation.Keep
@Keep
data class SortTagsDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/Theme.kt
================================================
package com.shicheeng.copymanga.data.sorttag
import android.util.Log
import androidx.annotation.Keep
import com.shicheeng.copymanga.data.MangaSortBean
import com.squareup.moshi.Json
@Keep
data class Theme(
@Json(name = "count")
val count: Int,
@Json(name = "initials")
val initials: Int,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
) {
init {
Log.d("TAG", "THEME: $pathWord")
}
fun toMangaSortBean() = MangaSortBean(
/* pathName = */ name,
/* pathWord = */ pathWord
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/Results.kt
================================================
package com.shicheeng.copymanga.data.topicalllist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/Series.kt
================================================
package com.shicheeng.copymanga.data.topicalllist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Series(
@Json(name = "color")
val color: String,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/TopicAllListDataModel.kt
================================================
package com.shicheeng.copymanga.data.topicalllist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class TopicAllListDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/TopicAllListItem.kt
================================================
package com.shicheeng.copymanga.data.topicalllist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class TopicAllListItem(
@Json(name = "brief")
val brief: String,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_created")
val datetimeCreated: String,
@Json(name = "journal")
val journal: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "period")
val period: String,
@Json(name = "series")
val series: Series,
@Json(name = "title")
val title: String,
@Json(name = "type")
val type: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/Last.kt
================================================
package com.shicheeng.copymanga.data.topicinfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Last(
@Json(name = "path_word")
val pathWord: String,
@Json(name = "title")
val title: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/Results.kt
================================================
package com.shicheeng.copymanga.data.topicinfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "brief")
val brief: String,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_created")
val datetimeCreated: String,
@Json(name = "intro")
val intro: String,
@Json(name = "journal")
val journal: String,
@Json(name = "last")
val last: Last,
@Json(name = "path")
val path: Any?,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "period")
val period: String,
@Json(name = "series")
val series: Series,
@Json(name = "title")
val title: String,
@Json(name = "type")
val type: Int,
@Json(name = "version")
val version: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/Series.kt
================================================
package com.shicheeng.copymanga.data.topicinfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Series(
@Json(name = "color")
val color: String,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/TopicInfoDataModelX.kt
================================================
package com.shicheeng.copymanga.data.topicinfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class TopicInfoDataModelX(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/Author.kt
================================================
package com.shicheeng.copymanga.data.topiclist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/Results.kt
================================================
package com.shicheeng.copymanga.data.topiclist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/Theme.kt
================================================
package com.shicheeng.copymanga.data.topiclist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Theme(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/TopicItem.kt
================================================
package com.shicheeng.copymanga.data.topiclist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class TopicItem(
@Json(name = "author")
val author: List,
@Json(name = "c_type")
val cType: Int,
@Json(name = "cover")
val cover: String,
@Json(name = "females")
val females: List,
@Json(name = "img_type")
val imgType: Int,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "parodies")
val parodies: List,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "theme")
val theme: List,
@Json(name = "type")
val type: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/TopicListDataModel.kt
================================================
package com.shicheeng.copymanga.data.topiclist
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class TopicListDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Author.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Browse.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Browse(
@Json(name = "chapter_name")
val chapterName: String,
@Json(name = "chapter_uuid")
val chapterUuid: String,
@Json(name = "comic_uuid")
val comicUuid: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Comic.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "author")
val author: List,
@Json(name = "b_display")
val bDisplay: Boolean,
@Json(name = "browse")
val browse: Browse?,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_updated")
val datetimeUpdated: String,
@Json(name = "females")
val females: List,
@Json(name = "last_chapter_id")
val lastChapterId: String,
@Json(name = "last_chapter_name")
val lastChapterName: String,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "status")
val status: Int,
@Json(name = "theme")
val theme: List,
@Json(name = "uuid")
val uuid: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/LastBrowse.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class LastBrowse(
@Json(name = "last_browse_id")
val lastBrowseId: String,
@Json(name = "last_browse_name")
val lastBrowseName: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Results.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/WebBookshelf.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class WebBookshelf(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/WebBookshelfItem.kt
================================================
package com.shicheeng.copymanga.data.webbookshelf
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class WebBookshelfItem(
@Json(name = "b_folder")
val bFolder: Boolean,
@Json(name = "comic")
val comic: Comic,
@Json(name = "folder_id")
val folderId: Any?,
@Json(name = "last_browse")
val lastBrowse: LastBrowse?,
@Json(name = "name")
val name: Any?,
@Json(name = "uuid")
val uuid: Int
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webcomichistory/Browse.kt
================================================
package com.shicheeng.copymanga.data.webcomichistory
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Browse(
@Json(name = "chapter_id")
val chapterId: String,
@Json(name = "chapter_name")
val chapterName: String,
@Json(name = "chapter_uuid")
val chapterUuid: String,
@Json(name = "comic_id")
val comicId: String,
@Json(name = "comic_uuid")
val comicUuid: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webcomichistory/Results.kt
================================================
package com.shicheeng.copymanga.data.webcomichistory
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "browse")
val browse: Browse?,
@Json(name = "collect")
val collect: Int?,
@Json(name = "is_lock")
val isLock: Boolean,
@Json(name = "is_login")
val isLogin: Boolean,
@Json(name = "is_mobile_bind")
val isMobileBind: Boolean,
@Json(name = "is_vip")
val isVip: Boolean,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webcomichistory/WebComicHistory.kt
================================================
package com.shicheeng.copymanga.data.webcomichistory
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
import com.shicheeng.copymanga.data.local.LocalChapter
@Keep
@JsonClass(generateAdapter = true)
data class WebComicHistory(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/Author.kt
================================================
package com.shicheeng.copymanga.data.webhistory
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Author(
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/Comic.kt
================================================
package com.shicheeng.copymanga.data.webhistory
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class Comic(
@Json(name = "author")
val author: List,
@Json(name = "b_display")
val bDisplay: Boolean,
@Json(name = "cover")
val cover: String,
@Json(name = "datetime_updated")
val datetimeUpdated: String,
@Json(name = "females")
val females: List,
@Json(name = "last_chapter_id")
val lastChapterId: String,
@Json(name = "last_chapter_name")
val lastChapterName: String,
@Json(name = "males")
val males: List,
@Json(name = "name")
val name: String,
@Json(name = "path_word")
val pathWord: String,
@Json(name = "popular")
val popular: Int,
@Json(name = "status")
val status: Int,
@Json(name = "theme")
val theme: List,
@Json(name = "uuid")
val uuid: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/Results.kt
================================================
package com.shicheeng.copymanga.data.webhistory
import androidx.annotation.Keep
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
data class Results(
@Json(name = "limit")
val limit: Int,
@Json(name = "list")
val list: List,
@Json(name = "offset")
val offset: Int,
@Json(name = "total")
val total: Int,
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/WebHistoryDataModel.kt
================================================
package com.shicheeng.copymanga.data.webhistory
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class WebHistoryDataModel(
@Json(name = "code")
val code: Int,
@Json(name = "message")
val message: String,
@Json(name = "results")
val results: Results
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/WebHistoryItem.kt
================================================
package com.shicheeng.copymanga.data.webhistory
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import androidx.annotation.Keep
@Keep
@JsonClass(generateAdapter = true)
data class WebHistoryItem(
@Json(name = "comic")
val comic: Comic,
@Json(name = "id")
val id: Int,
@Json(name = "last_chapter_id")
val lastChapterId: String,
@Json(name = "last_chapter_name")
val lastChapterName: String
)
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/database/MangaHistoryDataBase.kt
================================================
package com.shicheeng.copymanga.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.shicheeng.copymanga.dao.MangeLocalHistoryDao
import com.shicheeng.copymanga.dao.SearchHistoryDao
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.data.searchhistory.SearchHistory
@Database(
entities = [MangaHistoryDataModel::class, LocalChapter::class, SearchHistory::class],
version = 6,
exportSchema = false
)
abstract class MangaHistoryDataBase : RoomDatabase() {
abstract fun historyDao(): MangeLocalHistoryDao
abstract fun keyWordDao(): SearchHistoryDao
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/database/MangaLoginDatabase.kt
================================================
package com.shicheeng.copymanga.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.shicheeng.copymanga.dao.MangaLoginDao
import com.shicheeng.copymanga.data.login.LocalLoginDataModel
@Database(
entities = [LocalLoginDataModel::class],
version = 2,
exportSchema = false
)
abstract class MangaLoginDatabase : RoomDatabase() {
abstract fun loginDao(): MangaLoginDao
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/database/StringToBeanConvert.kt
================================================
package com.shicheeng.copymanga.database
import androidx.room.TypeConverter
import com.shicheeng.copymanga.data.MangaSortBean
import com.shicheeng.copymanga.data.info.Author
class StringToBeanConvert {
companion object {
private const val SPLIT_OUT = ","
private const val SPLIT_INNER = "-"
}
@TypeConverter
fun stringToListBean(string: String): List {
return buildList {
string.split(SPLIT_OUT).forEach {
val inner = it.split(SPLIT_INNER)
if (inner.size == 1) {
add(MangaSortBean(inner[0], inner[0]))
} else {
add(MangaSortBean(inner[0], inner[1]))
}
}
}
}
@TypeConverter
fun listBeanToString(list: List): String {
return buildString {
list.forEachIndexed { index, sortBean ->
append("${sortBean.pathName}$SPLIT_INNER${sortBean.pathWord}")
if (index != list.lastIndex) {
append(SPLIT_OUT)
}
}
}
}
}
class AuthorToStringConvert {
companion object {
private const val SPLIT_OUT = ","
private const val SPLIT_INNER = "-"
}
@TypeConverter
fun stringToListBean(string: String): List {
return buildList {
string.split(SPLIT_OUT).forEach {
val inner = it.split(SPLIT_INNER)
if (inner.size == 1) {
add(Author(inner[0], inner[0]))
} else {
add(Author(inner[0], inner[1]))
}
}
}
}
@TypeConverter
fun listBeanToString(list: List): String {
return buildString {
list.forEachIndexed { index, sortBean ->
append("${sortBean.name}$SPLIT_INNER${sortBean.pathWord}")
if (index != list.lastIndex) {
append(SPLIT_OUT)
}
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/dialog/ConfigPagerSheet.kt
================================================
package com.shicheeng.copymanga.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButtonToggleGroup
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.databinding.SheetMangaModelSwitcherBinding
import com.shicheeng.copymanga.fm.reader.ReaderMode
class ConfigPagerSheet : BottomSheetDialogFragment(),
MaterialButtonToggleGroup.OnButtonCheckedListener {
private var _binding: SheetMangaModelSwitcherBinding? = null
private val binding get() = _binding!!
private lateinit var mode: ReaderMode
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(MODE_BUNDLE)?.let {
ReaderMode.idOf(it)
} ?: ReaderMode.NORMAL
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = SheetMangaModelSwitcherBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.readerSwitcherToHorizontal.isChecked = mode == ReaderMode.WEBTOON
binding.readerSwitcherToVert.isChecked = mode == ReaderMode.NORMAL
binding.readerSwitcherToLToR.isChecked = mode == ReaderMode.STANDARD
binding.readerSwitchersGroup.addOnButtonCheckedListener(this)
super.onViewCreated(view, savedInstanceState)
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.reader_switcher_to_vert -> ReaderMode.NORMAL
R.id.reader_switcher_to_horizontal -> ReaderMode.WEBTOON
R.id.reader_switcher_to_l_to_r -> ReaderMode.STANDARD
else -> return
}
if (newMode == mode) {
return
}
findCallBackSetMode()?.onModeChange(newMode) ?: return
mode = newMode
}
private fun findCallBackSetMode(): CallBack? {
return (parentFragment as? CallBack) ?: (activity as? CallBack)
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
interface CallBack {
fun onModeChange(mode: ReaderMode)
}
companion object {
private const val TAG = "TAG_CONFIG_PAGER"
private const val MODE_BUNDLE = "bundle_reader_mode"
fun show(fragmentManager: FragmentManager, reader: ReaderMode) {
val args = Bundle()
args.putInt(MODE_BUNDLE, reader.id)
val fragment = ConfigPagerSheet()
fragment.arguments = args
return fragment.show(fragmentManager, TAG)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/dialog/ListPreferenceXTheme.kt
================================================
package com.shicheeng.copymanga.dialog
import android.content.Context
import android.util.AttributeSet
import androidx.preference.ListPreference
class ListPreferenceXTheme @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = 0,
) :ListPreference(context,attr,defStyleAttr, defStyleRes){
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/domin/CopyMangaApi.kt
================================================
package com.shicheeng.copymanga.domin
import androidx.annotation.Keep
import com.shicheeng.copymanga.data.authormanga.AuthorsMangaDataModel
import com.shicheeng.copymanga.data.chapter.ChapterDataModel
import com.shicheeng.copymanga.data.commentpush.CommentPushDataModel
import com.shicheeng.copymanga.data.finished.FinishedMangaDataModel
import com.shicheeng.copymanga.data.info.MangaInfoDataModel
import com.shicheeng.copymanga.data.lofininfo.LoginInfoDataModel
import com.shicheeng.copymanga.data.login.LoginDataModel
import com.shicheeng.copymanga.data.logininfoshort.LoginInfoShortDataModel
import com.shicheeng.copymanga.data.mangacomment.MangaCommentDataModel
import com.shicheeng.copymanga.data.mangacontent.MangaContentDataModel
import com.shicheeng.copymanga.data.newsest.NewestListDataModel
import com.shicheeng.copymanga.data.rank.RankDataModel
import com.shicheeng.copymanga.data.recommend.RecommendDataModel
import com.shicheeng.copymanga.data.search.SearchDataModel
import com.shicheeng.copymanga.data.topicalllist.TopicAllListDataModel
import com.shicheeng.copymanga.data.topicinfo.TopicInfoDataModelX
import com.shicheeng.copymanga.data.topiclist.TopicListDataModel
import com.shicheeng.copymanga.data.webbookshelf.WebBookshelf
import com.shicheeng.copymanga.data.webcomichistory.WebComicHistory
import com.shicheeng.copymanga.data.webhistory.WebHistoryDataModel
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
@Keep
interface CopyMangaApi {
@GET("/api/v3/ranks")
suspend fun getRank(
@Query("limit")
limit: Int = 21,
@Query("offset")
offset: Int,
@Query("date_type")
dateType: String,
): RankDataModel
@GET("/api/v3/comic2/{path_word}")
suspend fun getMangaInfo(
@Path("path_word") pathWord: String,
@Query("platform") platform: Int = 3,
@Query("format") format: String = "json",
): MangaInfoDataModel
@GET("/api/v3/recs")
suspend fun getMangaRecommend(
@Query("pos") pos: Int = 3200102,
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
): RecommendDataModel
@GET("/api/v3/update/newest")
suspend fun getMangaNewest(
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
): NewestListDataModel
@GET("/api/v3/comics")
suspend fun fetchMangaFilter(
@Query("free_type") freeType: Int = 1,
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
@Query("top") top: String? = null,
@Query("theme") theme: String? = null,
@Query("ordering", encoded = true) ordering: String? = null,
@Query("_update") update: Boolean = true,
): FinishedMangaDataModel
@GET("/api/v3/comic/{path_word}/group/default/chapters")
suspend fun fetchChapters(
@Path("path_word") pathWord: String,
@Query("limit") limit: Int = 500,
@Query("offset") offset: Int = 0,
@Query("platform") platform: Int = 1,
): ChapterDataModel
@GET("/api/v3/search/comic")
suspend fun search(
@Query("format") format: String = "json",
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
@Query("platform") platform: Int = 1,
@Query("q") q: String,
): SearchDataModel
@GET("/api/v3/comic/{path_word}/chapter2/{uuid}")
suspend fun fetchMangaContentPicture(
@Path("path_word") pathWord: String,
@Path("uuid") uuid: String,
@Query("platform") platform: Int = 1,
): MangaContentDataModel
@GET("/api/v3/topic/{name}")
suspend fun getMangaTopicInfo(
@Path("name") name: String,
@Query("platform") platform: Int = 1,
): TopicInfoDataModelX
@GET("/api/v3/topic/{name}/contents")
suspend fun getMangaTopicList(
@Path("name") name: String,
@Query("type") type: Int,
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
@Query("platform") platform: Int = 1,
): TopicListDataModel
@GET("/api/v3/topics")
suspend fun fetchAllTopicListItem(
@Query("type") type: Int = 1,
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
@Query("_update") update: Boolean = true,
): TopicAllListDataModel
@POST("/api/v3/login")
@FormUrlEncoded
suspend fun login(
@Field("username") username: String,
@Field("password") passwordB64: String,
@Field("salt") salt: Int,
@Field("source") source: String = "freeSite",
@Field("version") version: String = "2023.08.14",
@Field("platform") platform: Int = 1,
): LoginDataModel
@GET("/api/v3/member/browse/comics")
suspend fun browsedComics(
@Query("free_type") freeType: Int = 1,
@Query("offset") offset: Int,
@Query("limit") limit: Int = 20,
@Query("_update") update: Boolean = true,
): WebHistoryDataModel
@GET("/api/v3/member/collect/comics")
suspend fun bookshelfWeb(
@Query("free_type") freeType: Int = 1,
@Query("limit") limit: Int = 21,
@Query("offset") offset: Int,
@Query("_update") update: Boolean = true,
@Query("ordering") ordering: String = "-datetime_modifier",
): WebBookshelf
@GET("/api/v3/member/update/info")
suspend fun shortInfo(
@Query("nickname") nickname: String = "",
@Query("avatar") avatar: String = "",
@Query("gender") gender: String = "",
@Query("birthday") birthday: String = "",
): LoginInfoShortDataModel
@GET("/api/v3/comic2/{word}/query")
suspend fun comicWebHistory(
@Path("word") word: String,
@Query("platform") platform: Int = 1,
@Query("_update") update: Boolean = true,
): WebComicHistory
@GET("/api/v3/comics")
suspend fun comicAuthors(
@Query("free_type") freeType: Int = 1,
@Query("author") author: String,
@Query("limit") limit: Int = 100,
@Query("offset") offset: Int,
@Query("ordering") ordering: String = "-datetime_updated",
): AuthorsMangaDataModel
@GET("/api/v3/comments")
suspend fun comicComments(
@Query("comic_id") comicID: String,
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int,
): MangaCommentDataModel
@GET("/api/v3/member/info")
suspend fun loginInfo(): LoginInfoDataModel
@FormUrlEncoded
@POST("/api/v3/member/collect/comic")
suspend fun comicCollect(
@Field("comic_id") comicID: String,
@Field("is_collect") isCollect: Int,
@Field("_update") update: Boolean = true,
)
@FormUrlEncoded
@POST("/api/v3/member/comment")
suspend fun commentPush(
@Field("comic_id") comicId: String,
@Field("comment") comment: String,
@Field("reply_id") replyId: String = "",
): CommentPushDataModel
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/domin/DownloadFileDetectUtil.kt
================================================
package com.shicheeng.copymanga.domin
import android.content.Context
import android.os.Environment
import androidx.core.net.toUri
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.PersonalInnerDataModel
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import com.shicheeng.copymanga.resposity.MangaHistoryRepository
import com.shicheeng.copymanga.util.KeyWordSwap
import com.shicheeng.copymanga.util.asStringOrNull
import com.shicheeng.copymanga.util.getOrNull
import com.shicheeng.copymanga.util.nullWillBe
import com.shicheeng.copymanga.util.parserAsJson
import com.shicheeng.copymanga.util.transformToJsonObjectSafety
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileFilter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DownloadFileDetectUtil @Inject constructor(
@ApplicationContext private val context: Context,
private val mangaHistoryRepository: MangaHistoryRepository,
) {
private val fileRootPath by lazy {
File("${context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)}/${KeyWordSwap.SAVED_LOCAL_CHAPTER_NAME}")
}
private val fileRootPathV2 by lazy {
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
}
private val allDownloadFiles by lazy { fileRootPathV2?.walk() }
/**
* 获取下载的地址,一般是下载主文件夹名字和漫画名字
* @param localSavableMangaModel 本地保存的漫画信息数据模型
*/
fun getRootFile(localSavableMangaModel: LocalSavableMangaModel): File {
return File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"/${localSavableMangaModel.mangaHistoryDataModel.name}"
)
}
/**
* 通过[uuid]检测漫画章节是否下载。
* @param pathWord 空安全的漫画PathWord。
* @param uuid 空安全的漫画章节uuid。
*/
// TODO: 章节检测不再使用本地暴力检测
suspend fun detectChapterDownloadedByUUID(
pathWord: String?,
uuid: String?,
): Boolean = runInterruptible(Dispatchers.IO) {
if (!fileRootPath.exists()) {
return@runInterruptible false
}
val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x ->
x.asJsonObject["path_word"].asString == pathWord
}?.asJsonObject
if (json != null) {
json.get("manga_downloaded")
?.asJsonArray
?.find { x -> x.asJsonObject["uuid"].asString == uuid }
?.isJsonNull == false
} else {
val jsonV2 = allDownloadFiles
?.filter { it.extension == "json" }
?.find {
it.readText().parserAsJson().transformToJsonObjectSafety()
?.get("path_word")?.asString == pathWord
}?.readText()?.parserAsJson()?.asJsonObject
val chapterInJson = if (jsonV2?.has("chapters") == true) {
jsonV2.get("chapters")?.asJsonObject
} else null
chapterInJson?.has(uuid) == true
}
}
suspend fun detectMangaDownloadWithName(
name: String,
pathWord: String?,
uuid: String,
): Boolean = withContext(Dispatchers.IO) {
if (!fileRootPath.exists()) {
return@withContext false
}
val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x ->
x.asJsonObject["path_word"].asString == pathWord
}?.asJsonObject
if (json != null) {
json.get("manga_downloaded")
?.asJsonArray
?.find { x -> x.asJsonObject["uuid"].asString == uuid }
?.isJsonNull == false
} else {
val mangaPath = File(fileRootPathV2, "/$name/${KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON}")
if (!mangaPath.exists()) {
return@withContext false
} else {
val jsonMangaIndex =
mangaPath.readText().parserAsJson().transformToJsonObjectSafety()
return@withContext jsonMangaIndex?.has(uuid) == true
}
}
}
suspend fun detectMangaDownloadWithChapterName(
name: String,
pathWord: String?,
uuid: String,
): Boolean = withContext(Dispatchers.IO) {
if (!fileRootPath.exists()) {
return@withContext false
}
val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x ->
x.asJsonObject["path_word"].asString == pathWord
}?.asJsonObject
if (json != null) {
json.get("manga_downloaded")
?.asJsonArray
?.find { x -> x.asJsonObject["uuid"].asString == uuid }
?.isJsonNull == false
} else {
val mangaPath = File(fileRootPathV2, "/$name/${KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON}")
if (!mangaPath.exists()) {
return@withContext false
} else {
val jsonMangaIndex =
mangaPath.readText().parserAsJson().transformToJsonObjectSafety()
return@withContext jsonMangaIndex?.has(uuid) == true
}
}
}
/**
* 找出下载过章节的漫画:通过读取[fileRootPath]和[fileRootPathV2]的文件。
*/
fun findDownloadManga() = flow {
val files = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.listFiles()
if (files == null) {
emit(emptyList())
return@flow
}
val list = files
.filter { it.isDirectory }
.map { file ->
val indexJson = File("${file.path}/${KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON}").takeIf {
it.canRead()
}?.readText()?.parserAsJson()?.transformToJsonObjectSafety()
val coverPath = indexJson?.getOrNull("cover_entry")?.asString.nullWillBe {
"cover.png"
}
PersonalInnerDataModel(
name = file.name,
url = ("${file.path}/$coverPath").toUri(),
pathWord = findChapterPathWordWithName(file.name)
?: indexJson?.getOrNull("path_word")?.asStringOrNull
)
}
emit(list)
}
/**
* 通过读取文件来获取漫画的pathWord。
* @param name 既是漫画名字也是文件夹的名字。
*/
private fun findChapterPathWordWithName(name: String): String? {
if (!fileRootPath.exists()) {
return null
}
val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x ->
x.asJsonObject["name"].asString == name
}?.asJsonObject
return json?.get("path_word")?.asString
}
suspend fun isChapterDownloadedWithStringList(
pathWord: String?,
uuid: String?,
): Boolean = runInterruptible(Dispatchers.IO) {
if (!fileRootPath.exists()) {
return@runInterruptible false
}
val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x ->
x.asJsonObject["path_word"].asString == pathWord
}?.asJsonObject
val jsonNewVersion = fileRootPathV2?.walk()?.filter {
it.extension == "json"
}?.find { x ->
x.readText()
.parserAsJson()
.transformToJsonObjectSafety()?.get("path_word")?.asString == pathWord
}?.readText()?.parserAsJson()
?.asJsonObject?.let {
if (it.has("chapters")) it.get("chapters").asJsonObject else null
}
json?.get("manga_downloaded")
?.asJsonArray?.find { x -> x.asJsonObject["uuid"].asString == uuid }
?.isJsonNull == false || jsonNewVersion?.has(uuid) == true
}
//TODO : 完美的漫画本地检测
suspend fun ifChapterDownloaded(
pathWord: String,
uuid: String?,
): List? = runInterruptible(Dispatchers.IO) {
if (!fileRootPath.exists() || fileRootPathV2?.exists() == false) {
return@runInterruptible null
}
val json = File(fileRootPath, KeyWordSwap.SAVED_LOCAL_CHAPTER_NAME)
.takeIf { it.exists() }
?.readText()
?.parserAsJson()
?.asJsonArray?.find { x ->
x.asJsonObject["path_word"].asString == pathWord
}?.asJsonObject
if (json != null) {
val mangaName = json["name"]?.asString
val chapterName = json["manga_downloaded"]?.asJsonArray?.find { x ->
x.asJsonObject["uuid"].asString == uuid
}?.asJsonObject?.get("chapter_name")?.asString
val savePath =
"${context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)}/${mangaName}/${chapterName}"
val file = File(savePath)
buildList {
file.listFiles(chaptersFileFilter)?.forEachIndexed { index, file ->
add(MangaReaderPage(file.path, uuid, index))
}
}
} else {
val jsonInner = allDownloadFiles
?.filter { it.extension == "json" }
?.find {
it.readText().parserAsJson().transformToJsonObjectSafety()
?.get("path_word")?.asString == pathWord
}?.readText()?.parserAsJson()?.asJsonObject ?: return@runInterruptible null
if (!jsonInner.has("chapters") && !jsonInner.get("chapters").asJsonObject.has(uuid)) {
return@runInterruptible null
} else {
val mangaName = jsonInner.get("name").asString
val chapterName = jsonInner
.get("chapters").asJsonObject
.get(uuid).asJsonObject["chapter_name"].asString
val file = File(fileRootPathV2, "${mangaName}/$chapterName")
buildList {
file.listFiles(chaptersFileFilter)?.forEachIndexed { index, fileInner ->
add(MangaReaderPage(fileInner.path, uuid, index))
}
}
}
}
}
private val chaptersFileFilter = FileFilter {
it.extension == "jpg"
|| it.extension == "webp"
|| it.extension == "jpg"
|| it.extension == "jpeg"
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/error/ContinuationCallCallback.kt
================================================
package com.shicheeng.copymanga.error
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CompletionHandler
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resumeWithException
class ContinuationCallCallback(
private val call: Call,
private val cancellation: CancellableContinuation,
) : Callback, CompletionHandler {
override fun onFailure(call: Call, e: IOException) {
if (!call.isCanceled() && cancellation.isActive) {
cancellation.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
if (cancellation.isActive) {
cancellation.resume(response)
}
}
override fun invoke(cause: Throwable?) {
runCatching {
call.cancel()
}.onFailure {
cause?.addSuppressed(it)
}
}
}
fun Continuation.resume(value: T): Unit =
resumeWith(Result.success(value))
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/error/DownloadErrorException.kt
================================================
package com.shicheeng.copymanga.error
class DownloadErrorException(
val comicPathWord: String,
val chapterUUID: String,
) : Exception("下载错误")
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/error/EmptyJsonArray.kt
================================================
package com.shicheeng.copymanga.error
class EmptyJsonArray:Exception() {
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/error/ErrorActivity.kt
================================================
package com.shicheeng.copymanga.error
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import com.shicheeng.copymanga.app.AppAttachCompatActivity
import com.shicheeng.copymanga.ui.screen.error.ErrorScreen
import com.shicheeng.copymanga.ui.theme.CopyMangaTheme
class ErrorActivity : AppAttachCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
val message = intent?.getStringExtra(ERROR_MESSAGE)
setContent {
CopyMangaTheme {
ErrorScreen(message = message) {
finish()
}
}
}
}
companion object {
fun newIntentInstance(
context: Context,
errorMessage: String?,
): Intent {
val intent = Intent(context, ErrorActivity::class.java)
intent.putExtra(ERROR_MESSAGE, errorMessage)
return intent
}
private const val ERROR_MESSAGE = "ERROR_MESSAGE"
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/delegate/IdlingDelegate.kt
================================================
package com.shicheeng.copymanga.fm.delegate
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.TimeUnit
/**
* 大部分代码来自Kotatsu。
*/
class IdlingDelegate(private val idleCallback: IdleCallback) : DefaultLifecycleObserver {
private val handler = Handler(Looper.getMainLooper())
private val idleRunnable = Runnable {
idleCallback.onIdle()
}
fun bindToLifecycle(owner: LifecycleOwner) {
owner.lifecycle.addObserver(this)
}
fun onUserInteraction() {
handler.removeCallbacks(idleRunnable)
handler.postDelayed(idleRunnable, TimeUnit.SECONDS.toMillis(10))
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
owner.lifecycle.removeObserver(this)
handler.removeCallbacks(idleRunnable)
}
fun interface IdleCallback {
fun onIdle()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/ChapterLoader.kt
================================================
package com.shicheeng.copymanga.fm.domain
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.domin.DownloadFileDetectUtil
import com.shicheeng.copymanga.resposity.MangaInfoRepository
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@ViewModelScoped
class ChapterLoader @Inject constructor(
private val fileDetectUtil: DownloadFileDetectUtil,
private val repository: MangaInfoRepository,
) {
val chapters = LinkedHashMap()
val nextChapterLoadingState =
MutableStateFlow(NextChapterLoadState.NotLoading)
private val chapterPage = ChapterPages()
private val mutex = Mutex()
suspend fun init(list: List?) = mutex.withLock {
chapters.clear()
list?.forEach {
chapters[it.uuid] = it
}
}
suspend fun loadPrevNextChapter(
list: List?,
uuid: String?,
isNext: Boolean,
) {
nextChapterLoadingState.emit(NextChapterLoadState.Loading)
val chapters = list ?: return
val predicate: (LocalChapter) -> Boolean = { it.uuid == uuid }
val index =
if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
if (index == -1) return
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
try {
val newPages = loadChapter(newChapter.comicPathWord, newChapter.uuid)
mutex.withLock {
if (chapterPage.chapterSize > 1) {
if (chapterPage.size > 130) {
if (isNext) {
chapterPage.removeFirst()
} else {
chapterPage.removeLast()
}
}
}
if (isNext) {
chapterPage.addLast(newChapter.uuid, newPages)
} else {
chapterPage.addFirst(newChapter.uuid, newPages)
}
nextChapterLoadingState.emit(NextChapterLoadState.NotLoading)
}
} catch (e: Exception) {
nextChapterLoadingState.emit(NextChapterLoadState.Error(e))
}
}
suspend fun loadSingleChapter(pathWord: String, uuid: String) {
val page = loadChapter(pathWord, uuid)
mutex.withLock {
chapterPage.clear()
chapterPage.addLast(uuid, page)
}
}
fun getPage(uuid: String): List {
return chapterPage.subList(uuid)
}
operator fun get(uuid: String): Int {
return chapterPage.size(uuid)
}
fun snapshot() = chapterPage.toList()
fun last() = chapterPage.last()
fun first() = chapterPage.first()
val size get() = chapters.size
private suspend fun loadChapter(pathWord: String, uui: String): List {
val chapter = checkNotNull(chapters[uui]) { "NO CHAPTER FOUND" }
val isDownload = fileDetectUtil.isChapterDownloadedWithStringList(pathWord, chapter.uuid)
val listLocal =
if (isDownload) fileDetectUtil.ifChapterDownloaded(pathWord, chapter.uuid) else null
return repository.fetchContentMayLocal(listLocal, pathWord, chapter.uuid)
}
sealed class NextChapterLoadState {
object Loading : NextChapterLoadState()
data class Error(val e: Throwable) : NextChapterLoadState()
object NotLoading : NextChapterLoadState()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/ChapterPages.kt
================================================
package com.shicheeng.copymanga.fm.domain
import com.shicheeng.copymanga.data.MangaReaderPage
/**
* Copy from Kotatsu
*
* The class was _reformed_ for this App
*/
class ChapterPages private constructor(private val pages: ArrayDeque) :
List by pages {
private val indices = LinkedHashMap()
constructor() : this(ArrayDeque())
val chapterSize: Int get() = indices.size
fun removeFirst() {
val chapterId = pages.first().uuid
indices.remove(chapterId)
var delta = 0
while (pages.first().uuid == chapterId) {
pages.removeFirst()
delta--
}
shiftIndices(delta)
}
fun removeLast() {
val chapterId = pages.last().uuid
indices.remove(chapterId)
while (pages.last().uuid == chapterId) {
pages.removeLast()
}
}
fun addLast(id: String, newPages: List) {
indices[id] = pages.size until (pages.size + newPages.size)
pages.addAll(newPages)
}
fun addFirst(id: String, newPages: List) {
shiftIndices(newPages.size)
indices[id] = newPages.indices
pages.addAll(0, newPages)
}
fun clear() {
indices.clear()
pages.clear()
}
fun size(id: String) = indices[id]?.run {
endInclusive - start + 1
} ?: 0
fun subList(id: String): List {
val range = indices[id] ?: return emptyList()
return pages.subList(range.first, range.last + 1)
}
private fun shiftIndices(delta: Int) {
indices.forEach { (t, u) ->
indices[t] = u + delta
}
}
private operator fun IntRange.plus(delta: Int): IntRange {
return IntRange(start + delta, endInclusive + delta)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/PageHolderDelegate.kt
================================================
package com.shicheeng.copymanga.fm.domain
import android.net.Uri
import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import java.io.File
import java.io.IOException
class PageHolderDelegate(
private val loader: PagerLoader,
private val callback: Callback,
) : DefaultOnImageEventListener {
private var job: Job? = null
private val scope = loader.loaderScope + Dispatchers.Main.immediate
private var state = State.EMPTY
private var file: File? = null
fun onBind(url: String) {
val prevJop = job
job = scope.launch {
prevJop?.cancelAndJoin()
doLoad(url, false)
}
}
fun retry(url: String) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(url, true)
}
}
fun onRecycler() {
state = State.EMPTY
file = null
job?.cancel()
}
override fun onReady() {
super.onReady()
state = State.SHOWING
callback.onImageShowing()
}
override fun onImageLoaded() {
super.onImageLoaded()
state = State.SHOWN
callback.onImageShown()
}
override fun onImageLoadError(e: Throwable) {
val file = this.file
if (state == State.LOADED && e is IOException && file != null && file.exists()) {
tryConvert(file, e)
} else {
state = State.ERROR
callback.onError(e = e)
}
callback.onError(e)
}
private suspend fun doLoad(url: String, force: Boolean) {
state = State.LOADING
callback.onLoadingStarted()
try {
val task = loader.loadImageFromUrlAsync(url, force)
file = coroutineScope {
task.await()
}
state = State.LOADED
callback.onImageReady(checkNotNull(file).toUri())
} catch (e: Exception) {
state = State.ERROR
callback.onError(e)
}
}
private fun tryConvert(file: File, e: Exception) {
val prevJob = job
job = scope.launch {
prevJob?.join()
state = State.CONVERTING
try {
loader.convertInPlace(file)
state = State.CONVERTED
callback.onImageReady(file.toUri())
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e = e)
}
}
}
private enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
}
interface Callback {
fun onLoadingStarted()
fun onError(e: Throwable)
fun onImageReady(uri: Uri)
fun onImageShowing()
fun onImageShown()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/PagerCache.kt
================================================
package com.shicheeng.copymanga.fm.domain
import android.content.Context
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PagerCache @Inject constructor(
@ApplicationContext context: Context,
settingPref: SettingPref,
) {
private val cache = (context.externalCacheDirs + context.cacheDir).firstNotNullOfOrNull {
it.makeDirIfNoExist()
}.let { file ->
checkNotNull(file) {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") {
it.absolutePath
}
"Cannot find directory for PagesCache: [$dirs]"
}
}
private val lruCache = createDiskLruCacheSafe(
dir = cache,
size = FileSize.MEGABYTES.convert(settingPref.cacheSize.toLong(), FileSize.BYTES),
)
operator fun get(url: String): File? {
return lruCache.get(url)?.takeIfReadable()
}
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
val file = File(cache.parentFile, url.longHashCode().toString())
try {
file.outputStream().use { out ->
inputStream.copyToSuspending(out)
}
lruCache.put(url, file)
} finally {
file.delete()
}
}
private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
return try {
DiskLruCache.create(dir, size)
} catch (e: Exception) {
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}
}
/**
* Copy from kotatsu
*/
private suspend fun InputStream.copyToSuspending(
out: OutputStream,
bufferSize: Int = DEFAULT_BUFFER_SIZE,
): Long = withContext(Dispatchers.IO) {
val job = currentCoroutineContext()[Job]
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
job?.ensureActive()
bytes = read(buffer)
job?.ensureActive()
}
bytesCopied
}
/**
* Come from Kotatsu
*/
private fun String.longHashCode(): Long {
var h = 1125899906842597L
val len: Int = this.length
for (i in 0 until len) {
h = 31 * h + this[i].code
}
return h
}
/**
* Come from Kotatsu
*/
private fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
}
fun File.makeDirIfNoExist(): File {
if (!this.exists()) {
if (this.parentFile?.exists() != true) this.parentFile?.mkdir()
if (this.parentFile?.canWrite() != true) this.parentFile?.canWrite()
this.mkdir()
}
return this
}
/**
*
* Copy from Kotatsu
*/
enum class FileSize(private val multiplier: Int) {
BYTES(1), KILOBYTES(1024), MEGABYTES(1024 * 1024);
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/PagerLoader.kt
================================================
package com.shicheeng.copymanga.fm.domain
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
import com.shicheeng.copymanga.util.RetainedLifecycleCoroutineScope
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.invoke
import kotlinx.coroutines.plus
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.InputStream
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@ActivityRetainedScoped
class PagerLoader @Inject constructor(
lifecycle: ActivityRetainedLifecycle,
private val cache: PagerCache,
private val headers: Headers,
private val okHttpClient: OkHttpClient,
) : RetainedLifecycle.OnClearedListener {
val loaderScope =
RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray>()
private val prefetchQueue = LinkedList()
private val counter = AtomicInteger(0)
private val convertLock = Mutex()
init {
lifecycle.addOnClearedListener(this)
}
private fun onIdle() {
synchronized(prefetchQueue) {
while (prefetchQueue.isNotEmpty()) {
val url = prefetchQueue.pollFirst() ?: return
if (cache[url] == null) {
synchronized(tasks) {
tasks[url.hashCode().toLong()] = loadPageAsync(url)
}
return
}
}
}
}
fun loadImageFromUrlAsync(url: String, force: Boolean): Deferred {
if (!force) {
cache[url]?.let {
return getCompletedTaskAsync(it)
}
}
var task = tasks[url.hashCode().toLong()]
if (force) {
task?.cancel()
} else if (task?.isCancelled == false) {
return task
}
task = loadPageAsync(url)
synchronized(tasks) {
tasks[url.hashCode().toLong()] = task
}
return task
}
private fun loadPageAsync(url: String): Deferred {
val deferred = loaderScope.async {
try {
loadPagePicBitmap(url)
} finally {
if (counter.decrementAndGet() == 0) {
onIdle()
}
}
}
return deferred
}
suspend fun convertInPlace(file: File) {
convertLock.withLock {
runInterruptible(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
file.outputStream().use { out ->
image.compress(Bitmap.CompressFormat.PNG, 100, out)
}
} finally {
image.recycle()
}
}
}
}
private suspend fun loadPagePicBitmap(url: String): File = Dispatchers.IO {
val uri = Uri.parse(url)
if (uri.scheme == "https") {
val request = Request.Builder()
.headers(headers).url(url).get()
.build()
okHttpClient.newCall(request).execute().use { res ->
val ins = checkNotNull(res.body).byteStream()
cache.put(url, ins)
}
} else {
val input: InputStream = File(url).inputStream()
cache.put(url, input)
}
}
private fun getCompletedTaskAsync(file: File): Deferred {
return CompletableDeferred(file)
}
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTrace()
}
}
override fun onCleared() {
synchronized(tasks) {
tasks.clear()
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/BaseReader.kt
================================================
package com.shicheeng.copymanga.fm.reader
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.viewbinding.ViewBinding
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.MangaState
import com.shicheeng.copymanga.util.observe
import com.shicheeng.copymanga.viewmodel.ReaderViewModel
abstract class BaseReader : Fragment() {
private var _binding: VB? = null
protected val viewModel by activityViewModels()
protected val binding: VB get() = checkNotNull(_binding)
protected var readerAdapter: BaseReaderAdapter<*>? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
val binding = onCreateViewInflater(inflater, container)
_binding = binding
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
readerAdapter = createAdapter()
viewModel.mangaContent.observe(viewLifecycleOwner) {
onLoadUrlChangeSuccess(it.list, it.state)
}
super.onViewCreated(view, savedInstanceState)
}
override fun onDestroyView() {
_binding = null
readerAdapter = null
super.onDestroyView()
}
protected fun bindingOrNull() = _binding
protected abstract fun onCreateViewInflater(inflater: LayoutInflater, container: ViewGroup?): VB
protected abstract suspend fun onLoadUrlChangeSuccess(
list: List,
state: MangaState?,
)
protected fun requireBinding() = requireNotNull(_binding) {
"NO BIND VIEW HERE"
}
protected fun requireAdapter() = checkNotNull(readerAdapter) {
"NO ADAPTER HERE"
}
protected abstract fun createAdapter(): BaseReaderAdapter<*>
abstract fun currentState(): MangaState?
abstract fun moveToPosition(position: Int, smooth: Boolean)
abstract fun moveDelta(delta: Int)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/BaseReaderAdapter.kt
================================================
package com.shicheeng.copymanga.fm.reader
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.shicheeng.copymanga.data.MangaReaderPage
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis")
abstract class BaseReaderAdapter> :
RecyclerView.Adapter() {
private val diff = AsyncListDiffer(this, DIffCallBack())
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
onCreateViewHolder(parent)
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(url = diff.currentList[position].url)
}
open fun getItem(position: Int): MangaReaderPage = diff.currentList[position]
open fun getItemOrNull(position: Int): MangaReaderPage? = diff.currentList.getOrNull(position)
override fun getItemCount(): Int = diff.currentList.size
suspend fun subItems(list: List) = suspendCoroutine { continuation ->
diff.submitList(list) {
continuation.resume(Unit)
}
}
override fun onViewRecycled(holder: VH) {
holder.onRecycler()
super.onViewRecycled(holder)
}
protected abstract fun onCreateViewHolder(parent: ViewGroup): VH
private class DIffCallBack : DiffUtil.ItemCallback() {
override fun areItemsTheSame(
oldItem: MangaReaderPage,
newItem: MangaReaderPage,
): Boolean = oldItem === newItem
override fun areContentsTheSame(
oldItem: MangaReaderPage,
newItem: MangaReaderPage,
): Boolean = oldItem == newItem
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/BaseReaderViewHolder.kt
================================================
package com.shicheeng.copymanga.fm.reader
import android.content.Context
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewbinding.ViewBinding
import com.shicheeng.copymanga.databinding.LayoutImageLoadBinding
import com.shicheeng.copymanga.fm.domain.PageHolderDelegate
import com.shicheeng.copymanga.fm.domain.PagerLoader
@Suppress("LeakingThis")
abstract class BaseReaderViewHolder(
protected val binding: VB,
imageLoader: PagerLoader,
) : ViewHolder(binding.root), PageHolderDelegate.Callback {
val context: Context get() = itemView.context
protected val bindingInfo = LayoutImageLoadBinding.bind(binding.root)
protected val delegate = PageHolderDelegate(imageLoader, this)
fun bind(url: String) {
delegate.onBind(url)
onBind(url)
}
open fun onRecycler() {
delegate.onRecycler()
}
abstract fun onBind(url: String)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/MangaLoader.kt
================================================
package com.shicheeng.copymanga.fm.reader
import androidx.lifecycle.SavedStateHandle
class MangaLoader(
savedStateHandle: SavedStateHandle,
) {
val mangaPathWord = savedStateHandle.get(MANGA_PATH_WORD) ?: NONE
val mangaChapterUUID = savedStateHandle.get(MANGA_UUID) ?: CHAPTER_NONE
companion object {
const val MANGA_PATH_WORD = "MANGA_PATH_WORD"
const val MANGA_UUID = "MANGA_UUID"
const val NONE = "NONE"
const val CHAPTER_NONE = "CHAPTER_NONE"
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/ReaderManager.kt
================================================
package com.shicheeng.copymanga.fm.reader
import androidx.annotation.IdRes
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.fm.reader.noraml.ReaderPageFragment
import com.shicheeng.copymanga.fm.reader.standard.ReaderPagerStandardFragment
import com.shicheeng.copymanga.fm.reader.webtoon.WebtoonReaderFragment
import java.util.EnumMap
class ReaderManager(
private val supportFragmentManager: FragmentManager,
@IdRes private val containerId: Int,
) {
private val modeMap = EnumMap>>(ReaderMode::class.java)
init {
modeMap[ReaderMode.NORMAL] = ReaderPageFragment::class.java
modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
modeMap[ReaderMode.STANDARD] = ReaderPagerStandardFragment::class.java
}
val currentReader: BaseReader<*>?
get() = supportFragmentManager.findFragmentById(containerId) as? BaseReader<*>
val currentReaderMode: ReaderMode?
get() {
val readerClass = currentReader?.javaClass ?: return null
return modeMap.entries.find { it.value == readerClass }?.key
}
fun replace(newMode: ReaderMode) {
val readerClass = requireNotNull(modeMap[newMode])
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(containerId, readerClass, null, null)
}
}
fun replace(reader: BaseReader<*>) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(containerId, reader)
}
}
}
enum class ReaderMode(@IdRes val id: Int) {
NORMAL(R.string.japanese_r_to_l),
WEBTOON(R.string.korea_chinese_top_to_bottom),
STANDARD(R.string.manga_mode_l_t_r);
companion object {
fun idOf(id: Int?) = values().firstOrNull {
it.id == id
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/PageSliderFormatter.kt
================================================
package com.shicheeng.copymanga.fm.reader.noraml
import com.google.android.material.slider.LabelFormatter
class PageSliderFormatter : LabelFormatter {
override fun getFormattedValue(value: Float): String {
return (value + 1).toInt().toString()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/ReaderPageAdapter.kt
================================================
package com.shicheeng.copymanga.fm.reader.noraml
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import com.shicheeng.copymanga.databinding.ItemPageBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter
class ReaderPageAdapter(private val owner: LifecycleOwner, private val imageLoader: PagerLoader) :
BaseReaderAdapter() {
override fun onCreateViewHolder(parent: ViewGroup): ReaderPageViewHolder = ReaderPageViewHolder(
ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
imageLoader, owner
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/ReaderPageFragment.kt
================================================
package com.shicheeng.copymanga.fm.reader.noraml
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.snackbar.Snackbar
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.MangaState
import com.shicheeng.copymanga.databinding.FragmentReaderNormalBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReader
import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter
import com.shicheeng.copymanga.util.onPageChangeCallback
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.yield
import javax.inject.Inject
@AndroidEntryPoint
open class ReaderPageFragment : BaseReader() {
@Inject
lateinit var pagerLoader: PagerLoader
override fun onCreateViewInflater(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentReaderNormalBinding = FragmentReaderNormalBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.mangaReaderViewpager2.layoutDirection = View.LAYOUT_DIRECTION_RTL
with(binding.mangaReaderViewpager2) {
adapter = readerAdapter
offscreenPageLimit = 1
onPageChangeCallback {
viewModel.onPagePositionChange(it)
}
}
}
override fun onDestroyView() {
requireBinding().mangaReaderViewpager2.adapter = null
super.onDestroyView()
}
override fun moveToPosition(position: Int, smooth: Boolean) {
binding.mangaReaderViewpager2.setCurrentItem(position, smooth)
}
override fun currentState(): MangaState? = bindingOrNull()?.run {
val adapter = mangaReaderViewpager2.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(mangaReaderViewpager2.currentItem) ?: return@run null
MangaState(page.uuid ?: return@run null, page.index)
}
override fun moveDelta(delta: Int) {
binding.mangaReaderViewpager2.currentItem =
binding.mangaReaderViewpager2.currentItem + delta
}
override suspend fun onLoadUrlChangeSuccess(
list: List,
state: MangaState?,
) = coroutineScope {
val items = async {
requireAdapter().subItems(list)
yield()
}
if (state != null) {
val position = list.indexOfFirst {
it.uuid == state.uuid && it.index == state.page
}
items.await()
if (position != -1) {
binding.mangaReaderViewpager2.setCurrentItem(position, false)
viewModel.onPagePositionChange(position)
} else {
Snackbar.make(requireView(), getString(R.string.no_content), Snackbar.LENGTH_LONG)
.show()
}
} else {
items.await()
}
}
override fun createAdapter(): BaseReaderAdapter<*> {
return ReaderPageAdapter(
owner = viewLifecycleOwner,
imageLoader = pagerLoader
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/ReaderPageViewHolder.kt
================================================
package com.shicheeng.copymanga.fm.reader.noraml
import android.annotation.SuppressLint
import android.net.Uri
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.shicheeng.copymanga.databinding.ItemPageBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReaderViewHolder
@SuppressLint("ClickableViewAccessibility")
class ReaderPageViewHolder(
binding: ItemPageBinding,
imageLoader: PagerLoader,
owner: LifecycleOwner,
) :
BaseReaderViewHolder(binding, imageLoader) {
private var url: String? = null
init {
binding.bivPager.bindToLifecycle(owner)
binding.bivPager.addOnImageEventListener(delegate)
}
override fun onBind(url: String) {
this.url = url
}
override fun onLoadingStarted() {
binding.errorLayout.errorTextLayout.isVisible = false
bindingInfo.loadIndicator.isVisible = true
binding.bivPager.recycle()
}
override fun onError(e: Throwable) {
e.printStackTrace()
with(binding.errorLayout) {
errorTextLayout.isVisible = true
errorTextTipDesc.text = e.message
btnErrorRetry.setOnClickListener {
url?.let { it1 ->
delegate.retry(it1)
}
}
}
bindingInfo.loadIndicator.isVisible = false
}
override fun onImageReady(uri: Uri) {
binding.bivPager.setImage(ImageSource.Uri(uri))
}
override fun onImageShowing() {
binding.bivPager.maxScale = 2f * maxOf(
binding.bivPager.width / binding.bivPager.sWidth.toFloat(),
binding.bivPager.height / binding.bivPager.sHeight.toFloat(),
)
}
override fun onImageShown() {
bindingInfo.loadIndicator.isVisible = false
binding.errorLayout.errorTextLayout.isVisible = false
}
override fun onRecycler() {
super.onRecycler()
binding.bivPager.recycle()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/standard/ReaderPagerStandardFragment.kt
================================================
package com.shicheeng.copymanga.fm.reader.standard
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.snackbar.Snackbar
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.MangaState
import com.shicheeng.copymanga.databinding.FragmentReaderNormalBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReader
import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter
import com.shicheeng.copymanga.fm.reader.noraml.ReaderPageAdapter
import com.shicheeng.copymanga.util.onPageChangeCallback
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.yield
import javax.inject.Inject
@AndroidEntryPoint
class ReaderPagerStandardFragment : BaseReader() {
@Inject
lateinit var pagerLoader: PagerLoader
override fun onCreateViewInflater(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentReaderNormalBinding {
return FragmentReaderNormalBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.mangaReaderViewpager2.layoutDirection = View.LAYOUT_DIRECTION_LTR
with(binding.mangaReaderViewpager2) {
adapter = readerAdapter
offscreenPageLimit = 1
onPageChangeCallback {
viewModel.onPagePositionChange(it)
}
}
}
override suspend fun onLoadUrlChangeSuccess(list: List, state: MangaState?) {
coroutineScope {
val items = async {
requireAdapter().subItems(list)
yield()
}
if (state != null) {
val position = list.indexOfFirst {
it.uuid == state.uuid && it.index == state.page
}
items.await()
if (position != -1) {
binding.mangaReaderViewpager2.setCurrentItem(position, false)
viewModel.onPagePositionChange(position)
} else {
Snackbar.make(
requireView(),
getString(R.string.no_content),
Snackbar.LENGTH_LONG
)
.show()
}
} else {
items.await()
}
}
}
override fun createAdapter(): BaseReaderAdapter<*> {
return ReaderPageAdapter(
owner = viewLifecycleOwner,
imageLoader = pagerLoader
)
}
override fun currentState(): MangaState? = bindingOrNull()?.run {
val adapter = mangaReaderViewpager2.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(mangaReaderViewpager2.currentItem) ?: return@run null
MangaState(page.uuid ?: return@run null, page.index)
}
override fun moveToPosition(position: Int, smooth: Boolean) {
binding.mangaReaderViewpager2.setCurrentItem(
position,
smooth
)
}
override fun moveDelta(delta: Int) {
binding.mangaReaderViewpager2.currentItem = binding.mangaReaderViewpager2.currentItem + 1
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonFrameLayout.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import com.shicheeng.copymanga.R
class WebtoonFrameLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
private val target by lazy(LazyThreadSafetyMode.NONE) {
findViewById(R.id.biv_pager_webtoon)
}
fun dispatchVerticalScroll(dy: Int): Int {
if (dy == 0) {
return 0
}
val oldScroll = target.getScroll()
target.scrollBy(dy)
return target.getScroll() - oldScroll
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonImageView.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import android.view.View
import android.view.ViewParent
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
private const val SCROLL_UNKNOWN = -1
class WebtoonImageView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF()
private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN
fun scrollBy(delta: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
return
}
val newScroll = scrollPos + delta
scrollToInternal(newScroll.coerceIn(0, maxScroll))
}
fun scrollTo(y: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
resetScaleAndCenter()
return
}
scrollToInternal(y.coerceIn(0, maxScroll))
}
fun getScroll() = scrollPos
fun getScrollRange(): Int {
if (scrollRange == SCROLL_UNKNOWN) {
computeScrollRange()
}
return scrollRange.coerceAtLeast(0)
}
override fun recycle() {
scrollRange = SCROLL_UNKNOWN
scrollPos = 0
super.recycle()
}
override fun getSuggestedMinimumHeight(): Int {
var desiredHeight = super.getSuggestedMinimumHeight()
if (sHeight == 0) {
val parentHeight = parentHeight()
if (desiredHeight < parentHeight) {
desiredHeight = parentHeight
}
}
return desiredHeight
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
val parentWidth = MeasureSpec.getSize(widthMeasureSpec)
val parentHeight = MeasureSpec.getSize(heightMeasureSpec)
val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY
val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY
var width = parentWidth
var height = parentHeight
if (sWidth > 0 && sHeight > 0) {
if (resizeWidth && resizeHeight) {
width = sWidth
height = sHeight
} else if (resizeHeight) {
height = (sHeight.toDouble() / sWidth.toDouble() * width).toInt()
} else if (resizeWidth) {
width = (sWidth.toDouble() / sHeight.toDouble() * height).toInt()
}
}
width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, parentHeight())
setMeasuredDimension(width, height)
}
private fun scrollToInternal(pos: Int) {
scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)
setScaleAndCenter(minScale, ct)
}
private fun computeScrollRange() {
if (!isReady) {
return
}
val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0)
}
private fun parentHeight(): Int {
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
}
}
fun Float.toIntUp(): Int {
val intValue = toInt()
return if (this == intValue.toFloat()) {
intValue
} else {
intValue + 1
}
}
private val View.parents: Sequence
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonLayoutManager.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign
/**
* From Kotatsu Reader
*/
@Suppress("unused")
class WebtoonLayoutManager : LinearLayoutManager {
private var scrollDirection: Int = 0
constructor(context: Context) : super(context)
constructor(
context: Context,
orientation: Int,
reverseLayout: Boolean,
) : super(context, orientation, reverseLayout)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int,
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun scrollVerticallyBy(
dy: Int,
recycler: RecyclerView.Recycler?,
state: RecyclerView.State,
): Int {
scrollDirection = dy.sign
return super.scrollVerticallyBy(dy, recycler, state)
}
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
if (state.hasTargetScrollPosition()) {
super.calculateExtraLayoutSpace(state, extraLayoutSpace)
return
}
val pageSize = height
extraLayoutSpace[0] = if (scrollDirection < 0) pageSize else 0
extraLayoutSpace[1] = if (scrollDirection < 0) 0 else pageSize
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonReaderAdapter.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import com.shicheeng.copymanga.databinding.ItemPageWebtoonBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter
class WebtoonReaderAdapter(
private val owner: LifecycleOwner,
private val imageLoader: PagerLoader,
) : BaseReaderAdapter() {
override fun onCreateViewHolder(parent: ViewGroup): WebtoonReaderViewHolder =
WebtoonReaderViewHolder(
ItemPageWebtoonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
), imageLoader, owner
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonReaderFragment.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.MangaState
import com.shicheeng.copymanga.databinding.FragmentReaderWebtoonBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReader
import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter
import com.shicheeng.copymanga.util.findCurrentPagePosition
import com.shicheeng.copymanga.util.firstVisibleItemPosition
import com.shicheeng.copymanga.util.setFirstVisibleItemPositionSmooth
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.yield
import javax.inject.Inject
@AndroidEntryPoint
class WebtoonReaderFragment : BaseReader() {
private val scrollInterpolator = AccelerateDecelerateInterpolator()
@Inject
lateinit var pagerLoader: PagerLoader
override fun onCreateViewInflater(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentReaderWebtoonBinding =
FragmentReaderWebtoonBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding.mangaReaderWebtoonRecyclerview) {
setHasFixedSize(true)
adapter = readerAdapter
addOnScrollListener(RecyclerViewScrollListener())
}
}
override fun currentState(): MangaState? = bindingOrNull()?.run {
val firstItem = mangaReaderWebtoonRecyclerview.findCurrentPagePosition()
val adapter = mangaReaderWebtoonRecyclerview.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(firstItem) ?: return@run null
MangaState(page.uuid ?: return@run null, page.index)
}
override fun onDestroyView() {
requireBinding().mangaReaderWebtoonRecyclerview.adapter = null
super.onDestroyView()
}
override fun moveToPosition(position: Int, smooth: Boolean) {
binding.mangaReaderWebtoonRecyclerview.setFirstVisibleItemPositionSmooth(
position,
smooth
)
}
override fun moveDelta(delta: Int) {
binding.mangaReaderWebtoonRecyclerview.smoothScrollBy(
0,
(binding.mangaReaderWebtoonRecyclerview.height * 0.9).toInt() * delta,
scrollInterpolator,
)
}
override suspend fun onLoadUrlChangeSuccess(
list: List,
state: MangaState?,
) = coroutineScope {
val items = async {
requireAdapter().subItems(list)
yield()
}
if (state != null) {
val position = list.indexOfFirst {
it.uuid == state.uuid && it.index == state.page
}
items.await()
if (position != -1) {
with(binding.mangaReaderWebtoonRecyclerview) {
firstVisibleItemPosition = position
}
viewModel.onPagePositionChange(position)
} else {
Snackbar.make(requireView(), getString(R.string.no_content), Snackbar.LENGTH_LONG)
.show()
}
} else {
items.await()
}
}
override fun createAdapter(): BaseReaderAdapter<*> {
return WebtoonReaderAdapter(
owner = viewLifecycleOwner,
imageLoader = pagerLoader
)
}
inner class RecyclerViewScrollListener : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
viewModel.onPagePositionChange(recyclerView.findCurrentPagePosition())
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonReaderViewHolder.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.net.Uri
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import com.shicheeng.copymanga.databinding.ItemPageWebtoonBinding
import com.shicheeng.copymanga.fm.domain.PagerLoader
import com.shicheeng.copymanga.fm.reader.BaseReaderViewHolder
class WebtoonReaderViewHolder(
itemPageWebtoonBinding: ItemPageWebtoonBinding,
imageLoader: PagerLoader,
owner: LifecycleOwner,
) : BaseReaderViewHolder(
binding = itemPageWebtoonBinding,
imageLoader = imageLoader
) {
private var url: String? = null
private var scrollToRestore = 0
init {
binding.bivPagerWebtoon.bindToLifecycle(owner)
binding.bivPagerWebtoon.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory()
binding.bivPagerWebtoon.addOnImageEventListener(delegate)
}
override fun onBind(url: String) {
this.url = url
}
override fun onLoadingStarted() {
binding.errorLayout.errorTextLayout.isVisible = false
bindingInfo.loadIndicator.isVisible = true
binding.bivPagerWebtoon.recycle()
}
override fun onError(e: Throwable) {
with(binding.errorLayout) {
errorTextLayout.isVisible = true
errorTextTipDesc.text = e.message
btnErrorRetry.setOnClickListener {
url?.let { it1 ->
delegate.retry(it1)
}
}
}
bindingInfo.loadIndicator.isVisible = false
}
override fun onImageReady(uri: Uri) {
binding.bivPagerWebtoon.setImage(ImageSource.Uri(uri))
}
override fun onImageShowing() {
with(binding.bivPagerWebtoon) {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
minScale = width / sWidth.toFloat()
maxScale = minScale
scrollTo(
when {
scrollToRestore != 0 -> scrollToRestore
itemView.top < 0 -> getScrollRange()
else -> 0
},
)
scrollToRestore = 0
}
}
override fun onImageShown() {
bindingInfo.loadIndicator.isVisible = false
binding.errorLayout.errorTextLayout.isVisible = false
}
override fun onRecycler() {
super.onRecycler()
binding.bivPagerWebtoon.recycle()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonRecyclerView.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.content.Context
import android.util.AttributeSet
import androidx.core.view.ViewCompat.TYPE_TOUCH
import androidx.recyclerview.widget.RecyclerView
import com.shicheeng.copymanga.util.findCurrentPagePosition
import java.util.LinkedList
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
) : RecyclerView(context, attrs, defStyleAttr) {
private var onPageScrollListeners: MutableList? = null
override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH)
override fun startNestedScroll(axes: Int, type: Int): Boolean = true
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?,
) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH)
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?,
type: Int,
): Boolean {
val consumedY = consumeVerticalScroll(dy)
if (consumed != null) {
consumed[0] = 0
consumed[1] = consumedY
}
notifyScrollChanged(dy)
return consumedY != 0 || dy == 0
}
private fun consumeVerticalScroll(dy: Int): Int {
if (childCount == 0) {
return 0
}
when {
dy > 0 -> {
val child = getChildAt(0) as WebtoonFrameLayout
var consumedByChild = child.dispatchVerticalScroll(dy)
if (consumedByChild < dy) {
if (childCount > 1) {
val nextChild = getChildAt(1) as WebtoonFrameLayout
val unconsumed =
dy - consumedByChild - nextChild.top //will be consumed by scroll
if (unconsumed > 0) {
consumedByChild += nextChild.dispatchVerticalScroll(unconsumed)
}
}
}
return consumedByChild
}
dy < 0 -> {
val child = getChildAt(childCount - 1) as WebtoonFrameLayout
var consumedByChild = child.dispatchVerticalScroll(dy)
if (consumedByChild > dy) {
if (childCount > 1) {
val nextChild = getChildAt(childCount - 2) as WebtoonFrameLayout
val unconsumed =
dy - consumedByChild + (height - nextChild.bottom) //will be consumed by scroll
if (unconsumed < 0) {
consumedByChild += nextChild.dispatchVerticalScroll(unconsumed)
}
}
}
return consumedByChild
}
}
return 0
}
fun addOnPageScrollListener(listener: OnPageScrollListener) {
val list = onPageScrollListeners
?: LinkedList().also { onPageScrollListeners = it }
list.add(listener)
}
fun removeOnPageScrollListener(listener: OnPageScrollListener) {
onPageScrollListeners?.remove(listener)
}
private fun notifyScrollChanged(dy: Int) {
val listeners = onPageScrollListeners
if (listeners.isNullOrEmpty()) {
return
}
val centerPosition = findCurrentPagePosition()
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
}
abstract class OnPageScrollListener {
private var lastPosition = NO_POSITION
fun dispatchScroll(recyclerView: WebtoonRecyclerView, dy: Int, centerPosition: Int) {
onScroll(recyclerView, dy)
if (centerPosition != NO_POSITION && centerPosition != lastPosition) {
lastPosition = centerPosition
onPageChanged(recyclerView, centerPosition)
}
}
open fun onScroll(recyclerView: WebtoonRecyclerView, dy: Int) = Unit
open fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) = Unit
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonScalingFrame.kt
================================================
package com.shicheeng.copymanga.fm.reader.webtoon
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
private const val MAX_SCALE = 2.5f
private const val MIN_SCALE = 1f // under-scaling disabled due to buggy nested scroll
class WebtoonScalingFrame @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyles: Int = 0,
) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener {
private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) }
private val scaleDetector = ScaleGestureDetector(context, this)
private val gestureDetector = GestureDetectorCompat(context, GestureListener())
private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator())
private val transformMatrix = Matrix()
private val matrixValues = FloatArray(9)
private val scale
get() = matrixValues[Matrix.MSCALE_X]
private val transX
get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X]
private val transY
get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y]
private var halfWidth = 0f
private var halfHeight = 0f
private val translateBounds = RectF()
private val targetHitRect = Rect()
private var pendingScroll = 0
var isZoomEnable = true
set(value) {
field = value
if (scale != 1f) {
scaleChild(1f, halfWidth, halfHeight)
}
}
init {
syncMatrixValues()
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (!isZoomEnable || ev == null) {
return super.dispatchTouchEvent(ev)
}
if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) {
overScroller.forceFinished(true)
}
gestureDetector.onTouchEvent(ev)
scaleDetector.onTouchEvent(ev)
// Offset event to inside the child view
if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) {
ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f)
}
// Send action cancel to avoid recycler jump when scale end
if (scaleDetector.isInProgress) {
ev.action = MotionEvent.ACTION_CANCEL
}
return super.dispatchTouchEvent(ev)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
halfWidth = measuredWidth / 2f
halfHeight = measuredHeight / 2f
}
private fun invalidateTarget() {
adjustBounds()
targetChild.run {
scaleX = scale
scaleY = scale
translationX = transX
translationY = transY
}
val newHeight = if (scale < 1f) (height / scale).toInt() else height
if (newHeight != targetChild.height) {
targetChild.layoutParams.height = newHeight
targetChild.requestLayout()
}
if (scale < 1) {
targetChild.getHitRect(targetHitRect)
targetChild.scrollBy(0, pendingScroll)
pendingScroll = 0
}
}
private fun syncMatrixValues() {
transformMatrix.getValues(matrixValues)
}
private fun adjustBounds() {
syncMatrixValues()
val dx = when {
transX < translateBounds.left -> translateBounds.left - transX
transX > translateBounds.right -> translateBounds.right - transX
else -> 0f
}
val dy = when {
transY < translateBounds.top -> translateBounds.top - transY
transY > translateBounds.bottom -> translateBounds.bottom - transY
else -> 0f
}
pendingScroll = dy.toInt()
transformMatrix.postTranslate(dx, dy)
syncMatrixValues()
}
private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) {
val factor = newScale / scale
if (newScale > 1) {
translateBounds.set(
halfWidth * (1 - newScale),
halfHeight * (1 - newScale),
halfWidth * (newScale - 1),
halfHeight * (newScale - 1),
)
} else {
translateBounds.set(
0f,
halfHeight - halfHeight / newScale,
0f,
halfHeight - halfHeight / newScale,
)
}
transformMatrix.postScale(factor, factor, focusX, focusY)
invalidateTarget()
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE)
scaleChild(newScale, detector.focusX, detector.focusY)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true
override fun onScaleEnd(p0: ScaleGestureDetector) {
pendingScroll = 0
}
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
if (scale <= 1f) return false
transformMatrix.postTranslate(-distanceX, -distanceY)
invalidateTarget()
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f
ObjectAnimator.ofFloat(scale, newScale).run {
interpolator = AccelerateDecelerateInterpolator()
duration = 300
addUpdateListener {
scaleChild(it.animatedValue as Float, e.x, e.y)
}
start()
}
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
if (scale <= 1) return false
overScroller.fling(
transX.toInt(),
transY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
translateBounds.left.toInt(),
translateBounds.right.toInt(),
translateBounds.top.toInt(),
translateBounds.bottom.toInt(),
)
postOnAnimation(this)
return true
}
override fun run() {
if (overScroller.computeScrollOffset()) {
transformMatrix.postTranslate(
overScroller.currX - transX, overScroller.currY - transY
)
invalidateTarget()
postOnAnimation(this)
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/json/MainBannerJson.kt
================================================
package com.shicheeng.copymanga.json
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.shicheeng.copymanga.data.BannerList
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.parserToJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MainBannerJson @Inject constructor(
private val settingPref: SettingPref,
) {
private val apiHeader
get() = settingPref.apiSelected
private val mainPageUrl =
"https://api.$apiHeader/api/v3/h5/homeIndex?platform=3&format=json"
@Inject
lateinit var okHttpClient: OkHttpClient
suspend fun fetchMainListData(): JsonObject = withContext(Dispatchers.Default) {
val request: Request = Request.Builder()
.url(mainPageUrl)
.build()
okHttpClient.newCall(request).execute().use { response ->
val jsonData = requireNotNull(response.body).string()
jsonData.parserToJson().asJsonObject.getAsJsonObject("results")
}
}
fun getBannerMain(jsonObject: JsonObject): ArrayList {
val array1 = jsonObject["banners"].asJsonArray
val bannerLists = ArrayList()
for (ele in array1) {
val element = ele.asJsonObject["type"]
if (element.asInt == 1) {
val list = BannerList()
list.jsonObject = ele.asJsonObject
bannerLists.add(list)
}
}
return bannerLists
}
fun getRecMain(jsonObject: JsonObject): JsonArray =
jsonObject["recComics"].asJsonObject.getAsJsonArray("list")
fun getHotMain(jsonObject: JsonObject): JsonArray = jsonObject["hotComics"].asJsonArray
fun getNewMain(jsonObject: JsonObject): JsonArray = jsonObject["newComics"].asJsonArray
fun getFinishMain(jsonObject: JsonObject): JsonArray {
return jsonObject.getAsJsonObject("finishComics").getAsJsonArray("list")
}
fun getRecTopic(jsonObject: JsonObject): JsonArray {
return jsonObject["topics"].asJsonObject["list"].asJsonArray
}
fun getDayRankMain(jsonObject: JsonObject): HashMap {
val map = HashMap()
map[0] = jsonObject["rankDayComics"].asJsonObject.getAsJsonArray("list")
map[1] = jsonObject["rankWeekComics"].asJsonObject.getAsJsonArray("list")
map[2] = jsonObject["rankMonthComics"].asJsonObject.getAsJsonArray("list")
return map
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/json/MangaSortJson.kt
================================================
package com.shicheeng.copymanga.json
import com.shicheeng.copymanga.data.MangaSortBean
enum class MangaSortJson {
ORDER, THEME, PATH;
companion object {
@JvmStatic
val order: List
get() {
val list: MutableList = ArrayList()
val bean1 = MangaSortBean()
bean1.pathName = "最久更新"
bean1.pathWord = "datetime_updated"
list.add(bean1)
val dateUpdateNearly = MangaSortBean()
dateUpdateNearly.pathName = "最近更新"
dateUpdateNearly.pathWord = "-datetime_updated"
list.add(dateUpdateNearly)
val bean2 = MangaSortBean()
bean2.pathName = "最热"
bean2.pathWord = "-popular"
list.add(bean2)
val unpopular = MangaSortBean()
unpopular.pathName = "最冷"
unpopular.pathWord = "popular"
list.add(unpopular)
return list
}
@JvmStatic
val topPath = listOf(
MangaSortBean("无", ""),
MangaSortBean("日本", "japan"),
MangaSortBean("已完结", "finish"),
MangaSortBean("韩国", "korea"),
MangaSortBean("欧美", "west"),
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/json/UpdateMetaDataJson.kt
================================================
package com.shicheeng.copymanga.json
import com.shicheeng.copymanga.BuildConfig
import com.shicheeng.copymanga.data.VersionUnit
import com.shicheeng.copymanga.data.versionId
import com.shicheeng.copymanga.util.asArrayList
import com.shicheeng.copymanga.util.await
import com.shicheeng.copymanga.util.parserAsJson
import com.shicheeng.copymanga.util.timeStampConvert
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UpdateMetaDataJson @Inject constructor(
private val okHttpClient: OkHttpClient,
) {
private val updateMetadata =
"https://api.github.com/repos/shizheng233/CopyMangaJava/releases?page=1&per_page=10"
private val availableUpdate = MutableStateFlow(null)
fun availableUpdateVersion() = availableUpdate.asStateFlow()
private suspend fun getUpdateInfoVersion(): List {
val request = Request.Builder().url(updateMetadata).build()
val call = okHttpClient.newCall(request)
val res = call.await()
return buildList {
mapToList(res) {
add(it)
}
}
}
suspend fun fetchUpdate(): VersionUnit? = withContext(Dispatchers.Default) {
runCatching {
val thisVersion = versionId(BuildConfig.VERSION_NAME)
val allVersion = getUpdateInfoVersion().asArrayList()
allVersion.sortBy { it.versionId }
allVersion.maxByOrNull { it.versionId }?.takeIf {
it.versionId > thisVersion
}
}.onFailure {
it.printStackTrace()
}.onSuccess {
availableUpdate.emit(it)
}.getOrNull()
}
private inline fun mapToList(
response: Response,
crossinline block: (VersionUnit) -> Unit,
) {
val json = response.body?.string()?.parserAsJson()?.asJsonArray ?: return
for (element in json) {
val singleObj = element.asJsonObject
val arrest = singleObj["assets"].asJsonArray[0].asJsonObject
val versionName = singleObj["tag_name"].asString
val url = arrest["browser_download_url"].asString
val apkSize = arrest["size"].asLong
val htmlUrl = singleObj["html_url"].asString
val id = singleObj["id"].asLong
val time = singleObj["published_at"].asString.timeStampConvert()
val description = singleObj["body"].asString
val versionUnit = VersionUnit(id, htmlUrl, versionName, url, apkSize, description, time)
block(versionUnit)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/modula/CopyMangaApiModula.kt
================================================
package com.shicheeng.copymanga.modula
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CopyMangaApiModula {
@Provides
@Singleton
fun provideCopyMangaApi(
retrofit: Retrofit,
): CopyMangaApi {
return retrofit.create(CopyMangaApi::class.java)
}
@Provides
@Singleton
fun provideRetrofit(
settingPref: SettingPref,
okHttpClient: OkHttpClient,
): Retrofit {
val headerTheKey = "https://api."
val apiName = settingPref.apiSelected
return Retrofit.Builder()
.client(okHttpClient)
.baseUrl("$headerTheKey$apiName")
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/modula/LoginRoomModula.kt
================================================
package com.shicheeng.copymanga.modula
import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.shicheeng.copymanga.database.MangaLoginDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object LoginRoomModula {
@Provides
@Singleton
fun initDatabase(@ApplicationContext context: Context): MangaLoginDatabase {
return Room.databaseBuilder(
context = context,
klass = MangaLoginDatabase::class.java,
name = "login_data"
)
.addMigrations(_version1to2)
.build()
}
@Provides
@Singleton
fun provideLoginDao(mangaLoginDatabase: MangaLoginDatabase) = mangaLoginDatabase.loginDao()
}
private val _version1to2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE LocalLoginDataModel ADD COLUMN isExpired INTEGER NOT NULL DEFAULT 0")
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/modula/OkhttpProvider.kt
================================================
package com.shicheeng.copymanga.modula
import android.content.Context
import coil.ImageLoader
import com.shicheeng.copymanga.resposity.LoginTokenRepository
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.KeyWordSwap
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.Headers
import okhttp3.OkHttpClient
import javax.inject.Singleton
private val queryUrlRegex = "/api/v3/comic2/.*/query".toRegex()
private val comic2UrlRegex = "/api/v3/comic/.*/chapter2/.*".toRegex()
@Module
@InstallIn(SingletonComponent::class)
object OkhttpProvider {
@Singleton
@Provides
fun provideImageLoader(
@ApplicationContext context: Context,
okHttpClient: OkHttpClient,
): ImageLoader {
return ImageLoader.Builder(context).okHttpClient(okHttpClient).build()
}
@Provides
@Singleton
fun headersProvide(
settingPref: SettingPref,
): Headers = Headers.Builder()
.add(
"region",
if (settingPref.useForeignApi) "1" else "0"
)
.add("webp", "0")
.add("platform", "1")
.add("version", "2023.08.14")
.add("referer", "https://www.copymanga.site/")
.add(KeyWordSwap.USER_AGENT_WORD, KeyWordSwap.FAKE_USER_AGENT)
.build()
@Provides
@Singleton
fun provideOkhttp(
headers: Headers,
loginTokenRepository: LoginTokenRepository,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor { chain ->
val oldRequest = chain.request()
val token = loginTokenRepository.token
// TODO 能否使用更简单的方式来进行判断
val headersNew = if (
(oldRequest.url.toUrl().path == "/api/v3/member/browse/comics" ||
oldRequest.url.toUrl().path == "/api/v3/member/collect/comics" ||
oldRequest.url.toUrl().path == "/api/v3/member/update/info" ||
oldRequest.url.toUrl().path.matches(queryUrlRegex) ||
oldRequest.url.toUrl().path.matches(comic2UrlRegex) ||
oldRequest.url.toUrl().path == "/api/v3/member/info" ||
oldRequest.url.toUrl().path == "/api/v3/member/collect/comic" ||
oldRequest.url.toUrl().path == "/api/v3/member/comment")
&& token != null && !loginTokenRepository.isExpired
) {
headers.newBuilder()
.add("Authorization", "Token $token")
.build()
} else {
headers
}
val newRequest = oldRequest.newBuilder().headers(headersNew)
chain.proceed(newRequest.build())
}
.build()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/modula/RoomModula.kt
================================================
package com.shicheeng.copymanga.modula
import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.shicheeng.copymanga.database.MangaHistoryDataBase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RoomModula {
@Provides
@Singleton
fun provideHistoryDataBase(@ApplicationContext context: Context) = Room
.databaseBuilder(
context = context,
klass = MangaHistoryDataBase::class.java,
name = "manga_history_database_2"
).addMigrations(
VERSION1to2,
VERSION2to3,
VERSION3to4,
VERSION4to5,
VERSION5to6
).build()
@Provides
@Singleton
fun provideHistoryDao(mangaHistoryDataBase: MangaHistoryDataBase) =
mangaHistoryDataBase.historyDao()
@Provides
@Singleton
fun provideKeyWordHistoryDao(mangaHistoryDataBase: MangaHistoryDataBase) =
mangaHistoryDataBase.keyWordDao()
}
/**
* 迁移新的历史记录,该历史记录保存更加详细的内容。
*/
object VERSION1to2 : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
//for local information
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN alias TEXT NULL DEFAULT NULL")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaDetail TEXT NOT NULL DEFAULT \"空\" ")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaStatus TEXT NOT NULL DEFAULT \"空\" ")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN authorList TEXT NOT NULL DEFAULT \"空\"")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN themeList TEXT NOT NULL DEFAULT \"空\"")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaStatusId INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaRegion TEXT NOT NULL DEFAULT \"空\"")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaLastUpdate TEXT NOT NULL DEFAULT \"空\"")
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaPopularNumber TEXT NOT NULL DEFAULT \"空\"")
//for local chapter
database.execSQL(
"CREATE TABLE LocalChapter (" +
"uuid TEXT PRIMARY KEY NOT NULL," +
"groupId TEXT NULL DEFAULT NULL," +
"comicId TEXT NOT NULL," +
"comicPathWord TEXT NOT NULL," +
"groupPathWord TEXT NOT NULL," +
"name TEXT NOT NULL," +
"imgType INTEGER NOT NULL," +
"isReadProgress INTEGER NOT NULL," +
"next TEXT NULL," +
"ordered INTEGER NOT NULL," +
"prev TEXT NULL," +
"type INTEGER NOT NULL," +
"size INTEGER NOT NULL," +
"datetime_created TEXT NOT NULL," +
"count INTEGER NOT NULL," +
"readIndex INTEGER NOT NULL," +
"news TEXT NOT NULL," +
"`index` INTEGER NOT NULL" +
")"
)
}
}
/**
* 迁移到新的版本
*/
object VERSION2to3 : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN isSubscribe INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE LocalChapter ADD COLUMN isDownloaded INTEGER NOT NULL DEFAULT 0")
}
}
/**
* 加入搜索历史
*/
object VERSION3to4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE SearchHistory (" +
"word TEXT PRIMARY KEY NOT NULL," +
"time INTEGER NOT NULL" +
")"
)
}
}
/**
* 加入阅读完成
*/
object VERSION4to5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE LocalChapter ADD COLUMN isReadFinish INTEGER NOT NULL DEFAULT 0")
}
}
object VERSION5to6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga_history_key ADD COLUMN comicUUID TEXT NOT NULL DEFAULT \"unknown\" ")
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/modula/WorkerModula.kt
================================================
package com.shicheeng.copymanga.modula
import android.content.Context
import androidx.work.WorkManager
import coil.ImageLoader
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object WorkerModula {
@Singleton
@Provides
fun provideWorkerManager(
@ApplicationContext context: Context,
): WorkManager {
return WorkManager.getInstance(context)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/AuthorsMangaPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.authormanga.AuthorMangaItem
import com.shicheeng.copymanga.domin.CopyMangaApi
import kotlin.coroutines.Continuation
class AuthorsMangaPagingSource(
private val pathWord: String,
private val copyMangaApi: CopyMangaApi,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
val offset = params.key ?: 0
return try {
val data = copyMangaApi.comicAuthors(author = pathWord, offset = offset)
LoadResult.Page(
data = data.results.list,
nextKey = if (data.results.offset > data.results.total) null else offset + 21,
prevKey = null
)
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/ComicCommentPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.mangacomment.MangaCommentListItem
import com.shicheeng.copymanga.domin.CopyMangaApi
class ComicCommentPagingSource(
private val uuid: String,
private val copyMangaApi: CopyMangaApi,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
val offset = params.key ?: 0
return try {
val model = copyMangaApi.comicComments(comicID = uuid, offset = offset)
LoadResult.Page(
data = model.results.list,
prevKey = null,
nextKey = if (model.results.offset > model.results.total) null else offset + 20
)
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/ExplorePagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.finished.Item
import com.shicheeng.copymanga.domin.CopyMangaApi
class ExplorePagingSource(
private val copyMangaApi: CopyMangaApi,
private val order: String?,
private val themeWord: String?,
private val top: String?,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val offset = params.key ?: 0
val data = copyMangaApi.fetchMangaFilter(
offset = offset,
ordering = order,
theme = themeWord,
top = top
)
if (offset <= data.results.total) {
LoadResult.Page(data.results.list, null, offset + 21)
} else {
LoadResult.Page(data.results.list, null, null)
}
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/FinishedPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.finished.Item
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.json.MangaSortJson
class FinishedPagingSource(
private val copyMangaApi: CopyMangaApi,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val offset = params.key ?: 0
val data = copyMangaApi.fetchMangaFilter(
offset = offset,
top = MangaSortJson.topPath.find { x ->
x.pathName == "已完结"
}?.pathWord
)
if (offset <= data.results.total) {
LoadResult.Page(data.results.list, null, offset + 21)
} else {
LoadResult.Page(data.results.list, null, null)
}
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/HotPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.finished.Item
import com.shicheeng.copymanga.resposity.MangaHotRepository
class HotPagingSource(
private val repository: MangaHotRepository,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val offset = params.key ?: 0
val data = repository.fetchHotMangas(offset)
if (offset <= data.results.total) {
LoadResult.Page(data.results.list, null, offset + 21)
} else {
LoadResult.Page(data.results.list, null, null)
}
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/MangaTopicListPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.topicalllist.TopicAllListItem
import com.shicheeng.copymanga.domin.CopyMangaApi
class MangaTopicListPagingSource(
private val copyMangaApi: CopyMangaApi,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
val offset = params.key ?: 0
return try {
val data = copyMangaApi.fetchAllTopicListItem(offset = offset)
LoadResult.Page(
data = data.results.list,
prevKey = null,
nextKey = if (data.results.offset >= data.results.total) null else offset + 21
)
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/NewestPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.newsest.MangaBlock
import com.shicheeng.copymanga.resposity.MangaNewestRepository
class NewestPagingSource(
private val repository: MangaNewestRepository,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val offset = params.key ?: 0
val data = repository.fetchNewestMangas(offset)
if (offset <= data.results.total) {
LoadResult.Page(data.results.list, null, offset + 21)
} else {
LoadResult.Page(data.results.list, null, null)
}
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/RankPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.rank.Item
import com.shicheeng.copymanga.domin.CopyMangaApi
class RankPagingSource(
private val copyMangaApi: CopyMangaApi,
private val rankType: String,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val nextPageKey = params.key ?: 0
val ranks = copyMangaApi.getRank(offset = nextPageKey, dateType = rankType)
if (nextPageKey <= ranks.results.total) {
LoadResult.Page(ranks.results.list, prevKey = null, nextKey = nextPageKey + 21)
} else {
LoadResult.Page(emptyList(), prevKey = null, nextKey = null)
}
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/RecommendPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.recommend.RecommendDataModel
import com.shicheeng.copymanga.resposity.MangaRecommendRepository
class RecommendPagingSource(
private val recommendRepository: MangaRecommendRepository,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val nextPageNumber = params.key ?: 0
val responseData = recommendRepository.fetchRecommendMangas(nextPageNumber)
if (nextPageNumber <= responseData.results.total) {
LoadResult.Page(
data = responseData.results.list,
prevKey = null,
nextKey = nextPageNumber + 21
)
} else {
LoadResult.Page(data = emptyList(), prevKey = null, nextKey = null)
}
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/SearchResultPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.search.SearchResultDataModel
import com.shicheeng.copymanga.domin.CopyMangaApi
class SearchResultPagingSource(
private val word: String,
private val copyMangaApi: CopyMangaApi,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
return try {
val nextPageNumber = params.key ?: 0
val responseData = copyMangaApi.search(q = word, offset = nextPageNumber)
if (nextPageNumber <= responseData.results.total) {
LoadResult.Page(
data = responseData.results.list,
prevKey = null,
nextKey = nextPageNumber + 21
)
} else {
LoadResult.Page(data = responseData.results.list, prevKey = null, nextKey = null)
}
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/TopicDetailListPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.topiclist.TopicItem
import com.shicheeng.copymanga.domin.CopyMangaApi
class TopicDetailListPagingSource(
private val copyMangaApi: CopyMangaApi,
private val pathWord: String,
private val type: Int,
) :
PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
val offset = params.key ?: 0
return try {
val data = copyMangaApi.getMangaTopicList(
offset = offset,
type = type,
name = pathWord
)
LoadResult.Page(
data = data.results.list,
prevKey = null,
nextKey = if (data.results.offset >= data.results.total) null else offset + 21
)
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/WebHistoryPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.webhistory.WebHistoryItem
import com.shicheeng.copymanga.domin.CopyMangaApi
class WebHistoryPagingSource(private val copyMangaApi: CopyMangaApi) :
PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
val offset = params.key ?: 0
return try {
val data = copyMangaApi.browsedComics(offset = offset)
return LoadResult.Page(
data = data.results.list,
prevKey = null,
nextKey = if (data.results.offset > data.results.total) null else offset + 21
)
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/WebShelfPagingSource.kt
================================================
package com.shicheeng.copymanga.pagingsource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shicheeng.copymanga.data.webbookshelf.WebBookshelfItem
import com.shicheeng.copymanga.domin.CopyMangaApi
class WebShelfPagingSource(
private val copyMangaApi: CopyMangaApi,
) : PagingSource() {
override fun getRefreshKey(state: PagingState): Int? {
return null
}
override suspend fun load(params: LoadParams): LoadResult {
val offset = params.key ?: 0
return try {
val data = copyMangaApi.bookshelfWeb(offset = offset)
LoadResult.Page(
data = data.results.list,
prevKey = null,
nextKey = if (data.results.offset > data.results.total) null else offset + 21
)
} catch (e: Exception) {
e.printStackTrace()
LoadResult.Error(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/AuthorsMangaRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.shicheeng.copymanga.data.authormanga.AuthorMangaItem
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.AuthorsMangaPagingSource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthorsMangaRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi
) {
fun fetchMangaByPathWord(pathWord:String): Flow> {
return Pager(
config = PagingConfig(pageSize = 1)
){
AuthorsMangaPagingSource(pathWord, copyMangaApi)
}.flow
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/ComicCommentRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.ComicCommentPagingSource
import com.shicheeng.copymanga.util.SendUIState
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ComicCommentRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun loadComment(uuid: String) = Pager(
config = PagingConfig(1)
) {
ComicCommentPagingSource(uuid, copyMangaApi)
}.flow
fun push(comic: String, comment: String) = flow {
emit(SendUIState.Loading)
try {
val data = copyMangaApi.commentPush(comic, comment)
emit(SendUIState.Success(data))
} catch (e: Exception) {
e.printStackTrace()
emit(SendUIState.Error(e))
} finally {
kotlinx.coroutines.delay(3000)
emit(SendUIState.Idle)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/LoginDetailRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.data.logininfoshort.LoginInfoShortDataModel
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.util.UIState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoginDetailRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun detail(): Flow> = flow {
emit(UIState.Loading)
try {
val data = copyMangaApi.shortInfo()
emit(UIState.Success(data))
} catch (e: Exception) {
emit(UIState.Error(e))
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/LoginRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import android.util.Base64
import com.shicheeng.copymanga.dao.MangaLoginDao
import com.shicheeng.copymanga.data.login.LocalLoginDataModel
import com.shicheeng.copymanga.data.login.toLoginDataModel
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.LoginState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import java.nio.charset.Charset
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoginRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
private val loginDao: MangaLoginDao,
private val settingPref: SettingPref,
) {
suspend fun login(username: String, password: String) = flow {
emit(LoginState.Loading)
val salt = (0..Int.MAX_VALUE).random()
val passwordEncode = "$password-${salt}".toByteArray(Charset.forName("utf-8"))
val passwordB64 = Base64.encodeToString(passwordEncode, Base64.DEFAULT)
try {
val loginDataModel =
copyMangaApi.login(username, passwordB64 = passwordB64, salt = salt)
val loginLocal = loginDataModel.toLoginDataModel(isSelected = true)
loginDao.updateOrInsertLoginData(loginLocal)
val newList = loginDao
.getLoginDataAsync()
.map { x -> x.copy(selected = x.userID == loginLocal.userID) }
settingPref.selectedUUId(uuid = loginLocal.userID)
loginDao.updateOrInsertLoginData(localLoginDataModels = newList.toTypedArray())
emit(LoginState.Success(loginDataModel))
} catch (e: Exception) {
e.printStackTrace()
emit(LoginState.Error(e))
}
}
fun getAllLoginInstance() = loginDao.getLoginData()
fun getUserByUUid(uuid: String) = loginDao.getLoginDataByUserId(uuid)
fun deleteOneInstance(localLoginDataModel: LocalLoginDataModel) = loginDao::deleteLoginData
suspend fun selectOne(
uuid: String,
) = withContext(Dispatchers.IO) {
val newList = loginDao.getLoginDataAsync().map { x ->
x.copy(selected = x.userID == uuid)
}
loginDao.updateOrInsertLoginData(localLoginDataModels = newList.toTypedArray())
settingPref.selectedUUId(uuid)
}
fun testLoginStatus(
uuid: String? = settingPref.loginPerson,
) = flow {
val prevLoginInfo = loginDao.getLoginDataByUserIdSafety(uuid)
if (prevLoginInfo == null) {
emit(null)
return@flow
}
try {
val info = copyMangaApi.loginInfo()
.toLoginDataModel(
localLoginDataModel = prevLoginInfo,
isSelected = prevLoginInfo.selected,
isExpired = false
)
loginDao.updateOrInsertLoginData(info)
emit(null)
} catch (e: Exception) {
val info = prevLoginInfo.copy(isExpired = true)
loginDao.updateOrInsertLoginData(info)
emit(e)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/LoginTokenRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.dao.MangaLoginDao
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* 防止依赖注入循环。
*/
@Singleton
class LoginTokenRepository @Inject constructor(
private val loginDao: MangaLoginDao,
private val settingPref: SettingPref,
) {
/**
* 登录Token,没有则返回null。
*/
val token: String?
get() {
return loginDao.getCurrentToken(settingPref.loginPerson ?: return null)
}
val isExpired: Boolean
get() {
return loginDao.isExpired(settingPref.loginPerson ?: return true)
}
val isExpiredFlow:Flow get() {
return loginDao.isExpiredFlow(settingPref.loginPerson ?: return emptyFlow())
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaFilterRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import android.util.Log
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.shicheeng.copymanga.data.MangaSortBean
import com.shicheeng.copymanga.data.finished.Item
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.ExplorePagingSource
import com.shicheeng.copymanga.util.await
import com.shicheeng.copymanga.util.parserAsJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaFilterRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
private val okHttpClient: OkHttpClient,
private val retrofit: Retrofit,
) {
/**
* 需要自己手动解析
*/
suspend fun theme(): List = withContext(Dispatchers.Default) {
val request =
Request.Builder()
.url("https://" + retrofit.baseUrl().host + "/api/v3/h5/filterIndex/comic/tags")
.build()
val call = okHttpClient.newCall(request).await()
call.body?.string()?.let {
buildList {
add(MangaSortBean("无", ""))
it.parserAsJson().asJsonObject["results"].asJsonObject["theme"].asJsonArray.forEach {
val name = it.asJsonObject["name"].asString
val pathWord = it.asJsonObject["path_word"].asString
add(MangaSortBean(name, pathWord))
}
}
} ?: emptyList()
}
fun filterMangas(
top: String? = null,
theme: String? = null,
ordering: String? = null,
): Flow> {
return Pager(
config = PagingConfig(pageSize = 21),
pagingSourceFactory = {
ExplorePagingSource(copyMangaApi, ordering, theme, top)
}
).flow
}
}
fun Any.logD(tag: String = "com.shihcheeng.logd") {
Log.d(tag, "$this")
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaFinishedRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.json.MangaSortJson
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaFinishedRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
suspend fun fetchFinishManga(offset: Int) = copyMangaApi.fetchMangaFilter(
top = MangaSortJson.topPath.find { x -> x.pathName == "已完结" }?.pathWord,
offset = offset
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaHistoryRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.dao.MangeLocalHistoryDao
import com.shicheeng.copymanga.dao.SearchHistoryDao
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import com.shicheeng.copymanga.data.searchhistory.SearchHistory
import com.shicheeng.copymanga.util.processLifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaHistoryRepository @Inject constructor(
private val mangeLocalHistoryDao: MangeLocalHistoryDao,
private val searchedWordDao: SearchHistoryDao,
) {
val allHistoryDao: Flow> = mangeLocalHistoryDao.getAllHistory()
suspend fun totalHistoryManga() =
mangeLocalHistoryDao.fetchTotalManga()
suspend fun getMangaByPathWord(pathWord: String): LocalSavableMangaModel? {
return mangeLocalHistoryDao.getMangaByPathWord(pathWord)
}
suspend fun fetchMangaChapterByPathWord(pathWord: String): List? {
return mangeLocalHistoryDao.fetchMangaChaptersByPathWord(pathWord)
}
fun fetchMangaChapterByPathWordFlow(pathWord: String): Flow?> {
return mangeLocalHistoryDao.fetchMangaChaptersByPathWordFlow(pathWord)
}
fun fetchMangaByPathWordInFlow(pathWord: String) =
mangeLocalHistoryDao.fetchHistoryByPathWordInFlow(pathWord)
suspend fun update(mangaLocalHistory: MangaHistoryDataModel) {
mangeLocalHistoryDao.updateLocal(mangaLocalHistory)
}
/**
* 保存漫画历史。其生命周期不随ViewModel。
*/
fun updateAsync(mangaLocalHistory: MangaHistoryDataModel) {
processLifecycleScope.launch(Dispatchers.IO) {
try {
mangeLocalHistoryDao.updateLocal(mangaLocalHistory)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
suspend fun updateLocalChapter(localChapter: LocalChapter) {
mangeLocalHistoryDao.addLocalChapter(localChapter)
}
suspend fun updateLocalChapter(localChapter: List) {
mangeLocalHistoryDao.addLocalChapter(*localChapter.toTypedArray())
}
suspend fun getHistoryByMangaPathWord(pathWord: String): MangaHistoryDataModel? =
mangeLocalHistoryDao.getHistoryForInfoByPathWord(pathWord)
suspend fun delHistory() {
mangeLocalHistoryDao.deleteAllHistory()
}
suspend fun deleteSingleHistory(mangaHistoryDataModel: MangaHistoryDataModel) {
mangeLocalHistoryDao.deleteSingle(mangaHistoryDataModel)
}
fun historySearchedWord() = searchedWordDao.loadWordHistory()
suspend fun delKeyWordHistory(searchHistory: SearchHistory) =
searchedWordDao.detectSearchedWordHistory(searchHistory)
suspend fun upsertSearchWord(searchHistory: SearchHistory) {
searchedWordDao.upsertWord(searchHistory)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaHotRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.json.MangaSortJson
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaHotRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
suspend fun fetchHotMangas(offset: Int) = copyMangaApi.fetchMangaFilter(
ordering = MangaSortJson.order.find { x -> x.pathName == "最热" }?.pathWord,
offset = offset
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaInfoRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.MangaReaderPage
import com.shicheeng.copymanga.data.MangaSortBean
import com.shicheeng.copymanga.data.chapter.toLocalChapter
import com.shicheeng.copymanga.data.info.MangaInfoDataModel
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.domin.DownloadFileDetectUtil
import com.shicheeng.copymanga.fm.reader.ReaderMode
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.formNumberToRead
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaInfoRepository @Inject constructor(
private val detectUtil: DownloadFileDetectUtil,
private val copyMangaApi: CopyMangaApi,
private val mangaHistoryRepository: MangaHistoryRepository,
private val settingPref: SettingPref,
) {
suspend fun fetchMangaChapters(pathWord: String): List {
return mangaHistoryRepository.fetchMangaChapterByPathWord(pathWord)
?.takeIf { it.isNotEmpty() }
?: copyMangaApi.fetchChapters(pathWord = pathWord).let {
it.results.list.map { remoteChapter ->
remoteChapter.toLocalChapter(
readIndex = 0,
isReadInProgress = false,
isDownloaded = detectUtil.detectChapterDownloadedByUUID(
pathWord,
remoteChapter.uuid
),
isReadFinish = false
)
}
}.also {
mangaHistoryRepository.updateLocalChapter(it)
}
}
suspend fun fetchMangaChaptersForce(pathWord: String): List {
val mangaLocalChapters = mangaHistoryRepository.fetchMangaChapterByPathWord(pathWord)
return copyMangaApi.fetchChapters(pathWord = pathWord).let {
it.results.list.map { remoteChapter ->
remoteChapter.toLocalChapter(
readIndex = mangaLocalChapters?.find { x ->
x.uuid == remoteChapter.uuid
}?.readIndex ?: 0,
isReadInProgress = mangaLocalChapters?.find { x ->
x.uuid == remoteChapter.uuid
}?.isReadProgress ?: false,
isDownloaded = detectUtil.detectChapterDownloadedByUUID(
pathWord,
remoteChapter.uuid
),
isReadFinish = mangaLocalChapters?.find { x ->
x.uuid == remoteChapter.uuid
}?.isReadFinish ?: false
)
}
}.also {
mangaHistoryRepository.updateLocalChapter(it)
}
}
suspend fun collect(comicId: String, isCollect: Boolean): Boolean {
return try {
copyMangaApi.comicCollect(comicId, isCollect = if (isCollect) 1 else 0)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
suspend fun fetchMangaInfo(pathWord: String): MangaHistoryDataModel {
return mangaHistoryRepository.getHistoryByMangaPathWord(pathWord)
?: copyMangaApi.getMangaInfo(pathWord = pathWord).toMangaLocalInfo(
readerMode = ReaderMode.valueOf(settingPref.readerMode)
).also {
mangaHistoryRepository.update(it)
}
}
suspend fun fetchMangaInfoForce(pathWord: String): MangaHistoryDataModel {
val oldHistory = mangaHistoryRepository.getHistoryByMangaPathWord(pathWord)
return copyMangaApi.getMangaInfo(pathWord = pathWord).toMangaLocalInfo(
readerMode = ReaderMode.idOf(oldHistory?.readerModeId)
?: ReaderMode.valueOf(settingPref.readerMode),
isSubscribe = oldHistory?.isSubscribe ?: false
).also {
mangaHistoryRepository.update(it)
}
}
suspend fun fetchContent(
pathWord: String,
uuid: String,
): List {
val url = copyMangaApi.fetchMangaContentPicture(pathWord, uuid).results.chapter
return buildList {
url.contents.forEachIndexed { index, c ->
add(
MangaReaderPage(
url = c.url,
index = url.words[index],
uuid = url.uuid
)
)
}
}.sortedBy {
it.index
}
}
fun fetchComicWebHistory(pathWord: String) = flow {
val dataModel = copyMangaApi.comicWebHistory(pathWord)
emit(dataModel)
}
suspend fun fetchContentMayLocal(
localList: List? = null,
pathWord: String,
uuid: String,
): List = withContext(Dispatchers.Default) {
if (localList != null) {
val sortedList = localList.sortedWith { text1, text2 ->
text1.url
.split("/")
.last()
.split("_")
.last()
.split(".")
.first()
.toInt()
.compareTo(
text2.url
.split("/")
.last()
.split("_")
.last()
.split(".")
.first()
.toInt()
)
}
val newList = buildList {
for (i in sortedList.indices) {
add(sortedList[i].copy(index = i))
}
}
newList
} else {
try {
val url = copyMangaApi.fetchMangaContentPicture(pathWord, uuid).results.chapter
buildList {
url.contents.forEachIndexed { index, c ->
add(
MangaReaderPage(
url = c.url,
index = url.words[index],
uuid = url.uuid
)
)
}
}.sortedBy {
it.index
}
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
}
}
fun MangaInfoDataModel.toMangaLocalInfo(
readerMode: ReaderMode,
isSubscribe: Boolean = false,
): MangaHistoryDataModel {
return MangaHistoryDataModel(
name = results.comic.name,
time = System.currentTimeMillis(),
url = results.comic.cover,
pathWord = results.comic.pathWord,
nameChapter = results.comic.lastChapter.name,
positionChapter = 0,
positionPage = 0,
readerModeId = readerMode.id,
mangaDetail = results.comic.brief,
mangaLastUpdate = results.comic.datetimeUpdated ?: "未知",
mangaPopularNumber = results.popular.toLong().formNumberToRead(),
mangaRegion = results.comic.region.display,
mangaStatus = results.comic.status.display,
mangaStatusId = results.comic.status.value,
themeList = results.comic.theme.map { MangaSortBean(it.name, it.pathWord) },
authorList = results.comic.author,
alias = results.comic.alias,
isSubscribe = isSubscribe,
comicUUID = results.comic.uuid
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaMainPageRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.shicheeng.copymanga.data.DataBannerBean
import com.shicheeng.copymanga.data.ListBeanManga
import com.shicheeng.copymanga.data.MainPageDataModel
import com.shicheeng.copymanga.data.MainTopicDataModel
import com.shicheeng.copymanga.data.MangaRankMiniModel
import com.shicheeng.copymanga.json.MainBannerJson
import com.shicheeng.copymanga.util.authorNameReformation
import com.shicheeng.copymanga.util.formNumberToRead
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaMainPageRepository @Inject constructor(
private val mainBannerJson: MainBannerJson,
) {
private fun fetchMainBannerData(inputJsonObject: JsonObject): List {
val data = mainBannerJson.getBannerMain(inputJsonObject)
return buildList {
data.forEach {
val bannerBean = DataBannerBean()
val jsonObject1 = it.jsonObject //Banner组下面的各个jsonObject
bannerBean.bannerBrief = jsonObject1["brief"].asString
bannerBean.bannerImageUrl = jsonObject1["cover"].asString
bannerBean.uuidManga = jsonObject1["comic"]
.asJsonObject["path_word"].asString
add(bannerBean)
}
}
}
private fun fetchMainRowData(inputJsonArray: JsonArray): List {
return buildList {
inputJsonArray.forEach { jsonElement ->
//因为有第二个jsonObject,所以需要再次获取一次。
val jsonObject1 = jsonElement.asJsonObject.getAsJsonObject("comic")
val nameManga = jsonObject1["name"].asString
val urlCoverManga = jsonObject1["cover"].asString
val pathWordManga = jsonObject1["path_word"].asString
val mangaAuthor = jsonObject1["author"].asJsonArray.takeIf { it.size() != 0 }
?.authorNameReformation() ?: "未知"
val beanManga = ListBeanManga(
nameManga = nameManga,
authorManga = mangaAuthor,
urlCoverManga = urlCoverManga,
pathWordManga = pathWordManga
)
add(beanManga)
}
}
}
private fun fetchMainRowData2(inputJsonArray: JsonArray): List {
return buildList {
inputJsonArray.forEach { jsonElement ->
val jsonObject1 = jsonElement.asJsonObject
val nameManga = jsonObject1["name"].asString
val urlCoverManga = jsonObject1["cover"].asString
val pathWordManga = jsonObject1["path_word"].asString
val mangaAuthorList = jsonObject1["author"].asJsonArray.authorNameReformation()
val beanManga =
ListBeanManga(nameManga, mangaAuthorList, urlCoverManga, pathWordManga)
add(beanManga)
}
}
}
private fun transformMainRecTopic(inputJsonArray: JsonArray): List {
return inputJsonArray.map { element ->
element.asJsonObject.let {
val title = it["title"].asString
val journal = it["journal"].asString
val coverUrl = it["cover"].asString
val period = it["period"].asString
val type = it["type"].asInt
val brief = it["brief"].asString
val pathWord = it["path_word"].asString
val time = it["datetime_created"].asString
MainTopicDataModel(
name = title,
journal = journal,
coverUrl = coverUrl,
period = period,
type = type,
brief = brief,
pathWord = pathWord,
datetimeCreated = time
)
}
}
}
private fun parserJsonLeaderBoardData(array: JsonArray?): List {
return buildList {
array?.forEach { jsonElement ->
val comic = jsonElement.asJsonObject["comic"].asJsonObject
val popular = comic["popular"].asLong.formNumberToRead()
val name = comic["name"].asString
val pathWord = comic["path_word"].asString
val author = comic["author"].asJsonArray.authorNameReformation()
val cover = comic["cover"].asString
val riseNum = jsonElement.asJsonObject["rise_num"].asLong.formNumberToRead()
val data = MangaRankMiniModel(name, author, cover, popular, riseNum, pathWord)
add(data)
}
}
}
/**
* 主页数据
*/
suspend fun fetchMainData() = withContext(Dispatchers.Default) {
val mainData = mainBannerJson.fetchMainListData()
val listBanner = fetchMainBannerData(mainData)
val listRecommend = fetchMainRowData(mainBannerJson.getRecMain(mainData))
val listNewest = fetchMainRowData(mainBannerJson.getNewMain(mainData))
val listHot = fetchMainRowData(mainBannerJson.getHotMain(mainData))
val listFinished = fetchMainRowData2(mainBannerJson.getFinishMain(mainData))
val mapRankJsonArray = mainBannerJson.getDayRankMain(mainData)
val listRankWeek = parserJsonLeaderBoardData(mapRankJsonArray[1])
val listRankDay = parserJsonLeaderBoardData(mapRankJsonArray[0])
val listRankMonth = parserJsonLeaderBoardData(mapRankJsonArray[2])
val topic = transformMainRecTopic(mainBannerJson.getRecTopic(mainData))
MainPageDataModel(
listBanner = listBanner,
listRecommend = listRecommend,
listRankDay = listRankDay,
listRankWeek = listRankWeek,
listRankMonth = listRankMonth,
listHot = listHot,
listNewest = listNewest,
listFinished = listFinished,
topicList = topic
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaNewestRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.domin.CopyMangaApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaNewestRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
suspend fun fetchNewestMangas(offset: Int) = copyMangaApi.getMangaNewest(offset = offset)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaRankRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.shicheeng.copymanga.data.rank.Item
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.RankPagingSource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaRankRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun fetchMangaRank(type: String): Flow> {
return Pager(
config = PagingConfig(pageSize = 21),
pagingSourceFactory = {
RankPagingSource(copyMangaApi, type)
}
).flow
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaRecommendRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import com.shicheeng.copymanga.domin.CopyMangaApi
import javax.inject.Inject
class MangaRecommendRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
suspend fun fetchRecommendMangas(offset: Int) = copyMangaApi.getMangaRecommend(offset = offset)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaSearchRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.shicheeng.copymanga.data.search.SearchResultDataModel
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.SearchResultPagingSource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaSearchRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun fetchSearchResult(query: String): Flow> {
return Pager(
config = PagingConfig(pageSize = 21),
pagingSourceFactory = {
SearchResultPagingSource(query, copyMangaApi)
}
).flow
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaTopicDetailRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.TopicDetailListPagingSource
import com.shicheeng.copymanga.util.UIState
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MangaTopicDetailRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun load(pathWord: String) = flow {
emit(UIState.Loading)
try {
val data = copyMangaApi.getMangaTopicInfo(pathWord)
emit(UIState.Success(data))
} catch (e: Exception) {
emit(UIState.Error(e))
}
}
fun mangas(
pathWord: String,
type: Int,
) = Pager(
config = PagingConfig(pageSize = 1)
) {
TopicDetailListPagingSource(copyMangaApi, pathWord, type)
}.flow
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/WebHistoryRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.WebHistoryPagingSource
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebHistoryRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun historyOnWeb() = Pager(
config = PagingConfig(pageSize = 1)
) {
WebHistoryPagingSource(copyMangaApi)
}.flow
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/resposity/WebShelfRepository.kt
================================================
package com.shicheeng.copymanga.resposity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import com.shicheeng.copymanga.domin.CopyMangaApi
import com.shicheeng.copymanga.pagingsource.WebShelfPagingSource
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebShelfRepository @Inject constructor(
private val copyMangaApi: CopyMangaApi,
) {
fun loadWebShelf() = Pager(
pagingSourceFactory = {
WebShelfPagingSource(copyMangaApi)
},
config = PagingConfig(pageSize = 1)
).flow
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/DownloadState.kt
================================================
package com.shicheeng.copymanga.server
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
sealed interface DownloadStateChapter {
val chapterID: Int
val chapter: LocalSavableMangaModel
class WAITING(
override val chapterID: Int,
override val chapter: LocalSavableMangaModel,
) : DownloadStateChapter
class PREPARE(
override val chapterID: Int,
override val chapter: LocalSavableMangaModel,
) : DownloadStateChapter
class DOWNLOADING(
override val chapterID: Int,
override val chapter: LocalSavableMangaModel,
totalChapters: Int,
currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
val currentLocalChapter: LocalChapter,
) : DownloadStateChapter {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
}
class ERROR(
override val chapterID: Int,
override val chapter: LocalSavableMangaModel,
val error: Throwable,
) : DownloadStateChapter
class DONE(
override val chapterID: Int,
override val chapter: LocalSavableMangaModel,
) : DownloadStateChapter
class PostBeforeDone(
override val chapterID: Int,
override val chapter: LocalSavableMangaModel,
) :
DownloadStateChapter
class CANCEL(override val chapterID: Int, override val chapter: LocalSavableMangaModel) :
DownloadStateChapter
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/DownloadState.kt
================================================
package com.shicheeng.copymanga.server.download.domin
import androidx.work.Data
import com.shicheeng.copymanga.data.LocalManga
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
data class DownloadState(
val localSavableMangaModel: LocalSavableMangaModel,
val error: String? = null,
val isStopped: Boolean = false,
val isPaused: Boolean = false,
val totalChapters: Int = 0,
val currentChapter: Int = 0,
val isIndeterminate: Boolean = false,
val totalPages: Int = 0,
val currentPage: Int = 0,
val localManga: LocalManga? = null,
val downloadedChapters: Array = emptyArray(),
val timestamp: Long = System.currentTimeMillis(),
val eta: Long = -1L,
) {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE
val isParticularProgress: Boolean
get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate
val isFinalState: Boolean
get() = localManga != null || (error != null && !isPaused)
fun transformToWorkData() = Data.Builder()
.putInt(MANGA_MAX, max)
.putInt(MANGA_PROGRESS, progress)
.putString(MANGA_ERROR, error)
.putStringArray(MANGA_DOWNLOAD_CHAPTER, downloadedChapters)
.putLong(MANGA_TIME_STAMP, timestamp)
.putString(MANGA_PATH_WORD, localSavableMangaModel.mangaHistoryDataModel.pathWord)
.putBoolean(IS_INDETERMINATE, isIndeterminate)
.putBoolean(IS_STOPPED, isStopped)
.putBoolean(IS_PAUSE, isPaused)
.putLong(MANGA_ETA, eta)
.build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadState
if (localSavableMangaModel != other.localSavableMangaModel) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (timestamp != other.timestamp) return false
if (isStopped != other.isStopped) return false
if (max != other.max) return false
if (progress != other.progress) return false
if (isPaused != other.isPaused) return false
if (isIndeterminate != other.isIndeterminate) return false
return percent == other.percent
}
override fun hashCode(): Int {
var result = localSavableMangaModel.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + totalPages
result = 31 * result + percent.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
return result
}
companion object {
private const val PROGRESS_NONE = -1f
private const val MANGA_PATH_WORD = "MangaPathWord"
private const val MANGA_MAX = "MangaMax"
private const val MANGA_ETA = "MangaEta"
private const val IS_PAUSE = "IsPause"
private const val IS_STOPPED = "IsStopped"
private const val MANGA_ERROR = "MangaError"
private const val MANGA_DOWNLOAD_CHAPTER = "MangaDownloadChapter"
private const val MANGA_CURRENT = "MangaCurrent"
private const val IS_INDETERMINATE = "IsIndeterminate"
private const val MANGA_TIME_STAMP = "MangaTimeStamp"
private const val MANGA_PROGRESS = "MangaProgress"
infix fun getMangaPathWord(data: Data) = data.getString(MANGA_PATH_WORD)
infix fun getError(data: Data) = data.getString(MANGA_ERROR)
infix fun getMax(data: Data) = data.getInt(MANGA_MAX, 0)
infix fun getProgress(data: Data) = data.getInt(MANGA_PROGRESS, 0)
infix fun timeStampWhich(data: Data) = data.getLong(MANGA_TIME_STAMP, 0L)
infix fun downloadChaptersIn(data: Data): Array =
data.getStringArray(MANGA_DOWNLOAD_CHAPTER) ?: emptyArray()
infix fun indeterminateFor(data: Data): Boolean {
return data.getBoolean(IS_INDETERMINATE, false)
}
infix fun isPauseIn(data: Data): Boolean = data.getBoolean(IS_PAUSE, false)
infix fun isStoppedIn(data: Data): Boolean = data.getBoolean(IS_STOPPED, false)
infix fun timeETAIn(data: Data) = data.getLong(MANGA_ETA, 0L)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/DownloaderLocalIndex.kt
================================================
package com.shicheeng.copymanga.server.download.domin
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.shicheeng.copymanga.BuildConfig
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.MangaSortBean
import com.shicheeng.copymanga.data.info.Author
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import com.shicheeng.copymanga.util.add
import com.shicheeng.copymanga.util.toJsonArray
class DownloaderLocalIndex(source: String?) {
constructor(source: () -> String?) : this(source())
private val jsonObject = if (
!source.isNullOrEmpty()
&& source.isNotEmpty()
&& JsonParser.parseString(source).isJsonObject
) {
JsonParser.parseString(source).asJsonObject
} else {
JsonObject()
}
fun setMangaData(localSavableMangaModel: LocalSavableMangaModel, append: Boolean) {
jsonObject.apply {
addProperty("comic_id", localSavableMangaModel.mangaHistoryDataModel.comicUUID)
addProperty("name", localSavableMangaModel.mangaHistoryDataModel.name)
addProperty("cover", localSavableMangaModel.mangaHistoryDataModel.url)
addProperty("description", localSavableMangaModel.mangaHistoryDataModel.mangaDetail)
addProperty("state", localSavableMangaModel.mangaHistoryDataModel.mangaStatus)
addProperty("alias", localSavableMangaModel.mangaHistoryDataModel.alias)
addProperty("last_update", localSavableMangaModel.mangaHistoryDataModel.mangaLastUpdate)
addProperty("name_chapter", localSavableMangaModel.mangaHistoryDataModel.nameChapter)
addProperty("path_word", localSavableMangaModel.mangaHistoryDataModel.pathWord)
addProperty(
"manga_popular_num",
localSavableMangaModel.mangaHistoryDataModel.mangaPopularNumber
)
addProperty("manga_region", localSavableMangaModel.mangaHistoryDataModel.mangaRegion)
addProperty(
"manga_reader_int",
localSavableMangaModel.mangaHistoryDataModel.readerModeId
)
addProperty(
"manga_state_id",
localSavableMangaModel.mangaHistoryDataModel.mangaStatusId
)
addProperty(
"time",
localSavableMangaModel.mangaHistoryDataModel.time
)
add("tags") {
localSavableMangaModel.mangaHistoryDataModel.themeList.toJsonArray(
header = { x -> x.pathName },
values = { y -> y.pathWord },
headerProperty = "name",
valuesProperty = "path_word"
)
}
add("authors") {
localSavableMangaModel.mangaHistoryDataModel.authorList.toJsonArray(
header = { x -> x.name },
headerProperty = "name",
valuesProperty = "path_word",
values = { y -> y.pathWord }
)
}
if (!append || !jsonObject.has("chapters")) {
add("chapters", JsonObject())
}
addProperty("app_version", BuildConfig.VERSION_NAME)
addProperty("app_id", BuildConfig.APPLICATION_ID)
}
}
fun getMangaData() = if (jsonObject.isEmpty) null else runCatching {
MangaHistoryDataModel(
name = jsonObject["name"].asString,
comicUUID = jsonObject["comic_id"].asString,
readerModeId = jsonObject["manga_reader_int"].asInt,
mangaLastUpdate = jsonObject["last_update"].asString,
mangaDetail = jsonObject["description"].asString,
nameChapter = jsonObject["name_chapter"].asString,
time = jsonObject["time"].asLong,
alias = jsonObject["alias"].asString.takeIf { it.isNotBlank() && it.isNotEmpty() },
pathWord = jsonObject["path_word"].asString,
mangaPopularNumber = jsonObject["manga_popular_num"].asString,
mangaRegion = jsonObject["manga_region"].asString,
mangaStatus = jsonObject["state"].asString,
mangaStatusId = jsonObject["manga_state_id"].asInt,
themeList = jsonObject["tags"].asJsonArray.map {
MangaSortBean(
it.asJsonObject["name"].asString,
it.asJsonObject["path_word"].asString
)
},
url = jsonObject["cover"].asString,
authorList = jsonObject["authors"].asJsonArray.map {
Author(it.asJsonObject["name"].asString, it.asJsonObject["path_word"].asString)
},
isSubscribe = false,
positionChapter = 0,
positionPage = 0
)
}.getOrNull()
fun getCoverEntry(): String? = jsonObject.has("cover_entry").let {
if (it) jsonObject["cover_entry"].asString else null
}
fun setCoverEntry(name: String) {
jsonObject.addProperty("cover_entry", name)
}
fun addChapter(chapter: LocalChapter, fileName: String?) {
val jsonChapters = jsonObject["chapters"].asJsonObject
if (!jsonChapters.has(chapter.uuid)) {
jsonChapters.add(chapter.uuid) {
JsonObject().also {
it.apply {
addProperty("chapter_name", chapter.name)
addProperty("comic_path_word", chapter.comicPathWord)
addProperty("chapter_is_reading", chapter.isReadProgress)
addProperty("chapter_comic_id", chapter.comicId)
addProperty("file_name", fileName)
}
}
}
}
}
fun removeChapters(chapter: LocalChapter): Boolean {
return jsonObject["chapters"].asJsonObject.remove(chapter.uuid) != null
}
fun getChapters(
vararg uuid: String,
localSavableMangaModel: LocalSavableMangaModel,
): List {
return localSavableMangaModel.list.filter {
uuid.contains(it.uuid)
}
}
fun getChapterJson(uuid: String): JsonObject? {
return if (jsonObject.isJsonObject && jsonObject.has(uuid)) {
jsonObject[uuid].asJsonObject
} else null
}
override fun toString(): String {
return jsonObject.toString()
}
companion object {
private const val OUT_PUT_FILE = ""
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/DownloaderOutPutter.kt
================================================
package com.shicheeng.copymanga.server.download.domin
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import com.shicheeng.copymanga.fm.domain.makeDirIfNoExist
import com.shicheeng.copymanga.util.KeyWordSwap
import kotlinx.coroutines.runInterruptible
import java.io.File
class DownloaderOutPutter(
private val rootFile: File,
private val localSavableMangaModel: LocalSavableMangaModel,
) {
private val rootFileDir = rootFile.makeDirIfNoExist()
private val downloaderIndexer = DownloaderLocalIndex {
File(rootFileDir, KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON)
.also {
it.createNewFile()
}
.takeIf { x -> x.exists() && x.canRead() }
?.readText()
}
init {
downloaderIndexer.setMangaData(localSavableMangaModel, append = true)
}
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append("cover")
if (ext.isNotEmpty() && ext.length < 4) {
append(".")
append(ext)
}
}
runInterruptible {
file.copyTo(File(rootFile, name), overwrite = true)
}
downloaderIndexer.setCoverEntry(name)
completedIndex()
}
suspend fun addPager(
localChapter: LocalChapter,
file: File,
pagerNumber: Int,
ext: String,
) {
val name = buildString {
append("/")
append(localChapter.name)
append("/")
append("${localChapter.name}_")
append(pagerNumber)
if (ext.isNotEmpty() && ext.length < 4) {
append(".")
append(ext)
}
}
runInterruptible {
file.copyTo(File(rootFile, name), overwrite = true)
}
downloaderIndexer.addChapter(localChapter, name)
}
fun getDownloadChapters(array: Array) = downloaderIndexer.getChapters(
uuid = array,
localSavableMangaModel = localSavableMangaModel
)
fun createNewLocalData(uuids: Array): LocalSavableMangaModel {
return downloaderIndexer.getMangaData()?.let {
LocalSavableMangaModel(it, getDownloadChapters(uuids))
} ?: error("出错")
}
private suspend fun completedIndex() = runInterruptible {
File(rootFile, KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON).writeText(downloaderIndexer.toString())
}
suspend fun cleanUp() {
completedIndex()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/PausingHandle.kt
================================================
package com.shicheeng.copymanga.server.download.domin
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
class PausingHandle {
private val paused = MutableStateFlow(false)
@get:AnyThread
val isPaused: Boolean
get() = paused.value
@AnyThread
suspend fun awaitResumed() {
paused.filter { !it }.first()
}
@AnyThread
fun pause() {
paused.value = true
}
@AnyThread
fun resume() {
paused.value = false
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/PausingHandler.kt
================================================
package com.shicheeng.copymanga.server.download.domin
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.PatternMatcher
import androidx.core.app.PendingIntentCompat
import com.shicheeng.copymanga.util.transformToUUIDMayNullSafety
import java.util.UUID
class PausingHandler(
private val workerID: UUID,
private val pausingHandle: PausingHandle,
) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val uuid = intent?.getStringExtra(UUID_STRING).transformToUUIDMayNullSafety()
if (uuid != workerID) return
when (intent?.action) {
ACTION_PAUSE -> pausingHandle.pause()
ACTION_RESUME -> pausingHandle.resume()
}
}
companion object {
private const val UUID_STRING = "uuid"
private const val ACTION_PAUSE = "com.shihcheeng.copymanga.download.PAUSE"
private const val ACTION_RESUME = "com.shihcheeng.copymanga.download.RESUME"
private const val SCHEME = "workuid"
fun createIntentFilter(id: UUID) = IntentFilter().apply {
addAction(ACTION_PAUSE)
addAction(ACTION_RESUME)
addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
}
fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE)
.setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(UUID_STRING, id.toString())
fun getResumeIntent(context: Context, id: UUID) = Intent(ACTION_RESUME)
.setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(UUID_STRING, id.toString())
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context,
0,
getPauseIntent(context, id),
0,
false,
)
fun createResumePendingIntent(context: Context, id: UUID) =
PendingIntentCompat.getBroadcast(
context,
0,
getResumeIntent(context, id),
0,
false,
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/woker/DownloadNotificationFactory.kt
================================================
package com.shicheeng.copymanga.server.download.woker
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.work.WorkManager
import coil.ImageLoader
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.size.Scale
import com.shicheeng.copymanga.MainActivity
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import com.shicheeng.copymanga.server.download.domin.DownloadState
import com.shicheeng.copymanga.server.download.domin.PausingHandler
import com.shicheeng.copymanga.ui.screen.Router
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
private const val DOWNLOAD_CHANNEL_ID = "DOWNLOAD_CHANNEL"
class DownloadNotificationFactory @AssistedInject constructor(
@ApplicationContext private val context: Context,
private val workerManager: WorkManager,
private val coil: ImageLoader,
@Assisted uuid: UUID,
) {
private val downloadGroupID = "DOWNLOAD_GROUP"
private val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
private val covers = HashMap()
private val mutex = Mutex()
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val downloadPending = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(
Intent(
Intent.ACTION_VIEW,
Router.DOWNLOAD.deepLink.toUri(),
context,
MainActivity::class.java
)
)
getPendingIntent(0, PendingIntent.FLAG_MUTABLE)
}
private val actionCancel by lazy {
NotificationCompat.Action(
com.google.android.material.R.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
workerManager.createCancelPendingIntent(uuid),
)
}
private val actionPause by lazy {
NotificationCompat.Action(
R.drawable.baseline_pause_24,
context.getString(R.string.pause),
PausingHandler.createPausePendingIntent(context, uuid),
)
}
private val actionResume by lazy {
NotificationCompat.Action(
R.drawable.baseline_play_arrow_24,
context.getString(R.string.resume),
PausingHandler.createResumePendingIntent(context, uuid),
)
}
init {
bindNotification()
builder.setSilent(true)
builder.setDefaults(0)
builder.setGroup(downloadGroupID)
builder.setOnlyAlertOnce(true)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setGroupSummary(true)
builder.setContentTitle(context.getString(R.string.downloading))
}
suspend fun buildNotification(state: DownloadState?): Notification = mutex.withLock {
if (state == null) {
builder.setContentText(context.getString(R.string.preparing))
builder.setContentTitle(context.getString(R.string.downloading))
} else {
builder.setContentTitle(state.localSavableMangaModel.mangaHistoryDataModel.name)
builder.setContentText(context.getString(R.string.downloading))
}
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(downloadPending)
builder.setStyle(null)
builder.setLargeIcon(if (state != null) getCover(state.localSavableMangaModel)?.toBitmap() else null)
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
when {
state == null -> Unit
state.localManga != null -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.completed))
builder.setContentIntent(null)
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
}
state.isStopped -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.setSmallIcon(R.drawable.ic_stat_name)
builder.addAction(actionCancel)
}
state.isPaused -> {
builder.setProgress(state.max, state.progress, false)
val percent = if (state.percent >= 0) {
reformatPercentString(percent = state.percent)
} else {
null
}
if (state.error != null) {
builder.setContentText("$percent • ${state.error}")
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.setSmallIcon(R.drawable.baseline_pause_24)
builder.addAction(actionCancel)
builder.addAction(actionResume)
}
state.error != null -> { // error, final state
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(state.error)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error))
}
else -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText(reformatPercentString(state.percent))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(actionCancel)
builder.addAction(actionPause)
}
}
return builder.build()
}
private fun reformatPercentString(percent: Float): String {
return "%.2f%%".format((percent * 100))
}
private suspend fun getCover(localSavableMangaModel: LocalSavableMangaModel) =
covers[localSavableMangaModel] ?: run {
runCatching {
coil.execute(
ImageRequest.Builder(context)
.data(localSavableMangaModel.mangaHistoryDataModel.url)
.allowHardware(false)
.tag(localSavableMangaModel.mangaHistoryDataModel.comicUUID)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).let {
when (it) {
is ErrorResult -> throw it.throwable
is SuccessResult -> it.drawable
}
}
}.onSuccess {
covers[localSavableMangaModel] = it
}.onFailure {
it.printStackTrace()
}.getOrNull()
}
private fun bindNotification() {
val notificationManager = NotificationManagerCompat.from(context)
val name = context.getString(R.string.download_channel_name)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val mChannel = NotificationChannelCompat.Builder(DOWNLOAD_CHANNEL_ID, importance)
.setName(name)
.setVibrationEnabled(false)
.setLightsEnabled(false)
.setSound(null, null)
.build()
notificationManager.createNotificationChannel(mChannel)
}
@AssistedFactory
interface Injket {
fun create(uuid: UUID): DownloadNotificationFactory
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/download/woker/DownloadedWorker.kt
================================================
package com.shicheeng.copymanga.server.download.woker
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.asFlow
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import com.shicheeng.copymanga.data.LocalManga
import com.shicheeng.copymanga.domin.DownloadFileDetectUtil
import com.shicheeng.copymanga.fm.domain.PagerCache
import com.shicheeng.copymanga.fm.domain.makeDirIfNoExist
import com.shicheeng.copymanga.resposity.MangaHistoryRepository
import com.shicheeng.copymanga.resposity.MangaInfoRepository
import com.shicheeng.copymanga.resposity.logD
import com.shicheeng.copymanga.server.download.domin.DownloadState
import com.shicheeng.copymanga.server.download.domin.DownloaderOutPutter
import com.shicheeng.copymanga.server.download.domin.PausingHandle
import com.shicheeng.copymanga.server.download.domin.PausingHandler
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import com.shicheeng.copymanga.util.Throttler
import com.shicheeng.copymanga.util.await
import com.shicheeng.copymanga.util.messageNoNull
import com.shicheeng.copymanga.util.progress.TimeLeftEstimator
import com.shicheeng.copymanga.util.useWithContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
class DownloadedWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val mangaHistoryRepository: MangaHistoryRepository,
private val pagerCache: PagerCache,
private val mangaInfoRepository: MangaInfoRepository,
private val detectUtil: DownloadFileDetectUtil,
private val okHttpClient: OkHttpClient,
downloadNotificationFactory: DownloadNotificationFactory.Injket,
) : CoroutineWorker(appContext, params) {
private val notificationFactory = downloadNotificationFactory.create(params.id)
private val notificationManager = appContext.getSystemService(NotificationManager::class.java)
private val mutex = Mutex()
private val throttler = Throttler(400)
private val pausingHandle = PausingHandle()
private val pausingHandler = PausingHandler(params.id, pausingHandle)
private val timeLeftEstimator = TimeLeftEstimator()
@Volatile
private var _lastState: DownloadState? = null
private val lastState: DownloadState
get() = checkNotNull(_lastState)
override suspend fun doWork(): Result {
setForeground(getForegroundInfo())
val mangaPathWord = inputData.getString(MANGA_PATH_WORD)
?: return Result.failure()
val manga = mangaHistoryRepository.getMangaByPathWord(mangaPathWord)
?: return Result.failure()
val downloadedChapters = getDoneChapters()
val mangaDownloadUUIDs = inputData.getStringArray(MANGA_DOWNLOAD_UUIDS)
?.takeUnless { it.isEmpty() }
_lastState = DownloadState(
localSavableMangaModel = manga,
isIndeterminate = true
)
return try {
downloadMangaImpl(
downloadUUID = mangaDownloadUUIDs,
downloadedUUID = downloadedChapters
)
Result.success()
} catch (e: CancellationException) {
withContext(NonCancellable) {
val notification =
notificationFactory.buildNotification(lastState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification)
}
throw e
} catch (e: IOException) {
e.printStackTrace()
Result.retry()
} catch (e: Exception) {
e.printStackTrace()
Result.failure(
lastState.copy(
error = e.message,
).transformToWorkData()
)
} finally {
notificationManager.cancel(id.hashCode())
}
}
private suspend fun downloadMangaImpl(
downloadUUID: Array?,
downloadedUUID: Array,
) {
requireNotNull(downloadUUID) {
"下载的章节不可以为空"
}
val manga = lastState.localSavableMangaModel
val chapterToSkip = downloadedUUID.toMutableList()
mutex.withLock {
ContextCompat.registerReceiver(
applicationContext,
pausingHandler,
PausingHandler.createIntentFilter(id),
ContextCompat.RECEIVER_NOT_EXPORTED
)
val filePath = detectUtil.getRootFile(manga)
val tmpFile = "${manga.mangaHistoryDataModel.name}_$id.tmp"
val outPut: DownloaderOutPutter?
try {
outPut = DownloaderOutPutter(filePath, manga)
val coverFile = manga.mangaHistoryDataModel.url
downloadFile(url = coverFile, path = filePath, tmpFile = tmpFile).let {
outPut.addCover(file = it, MimeTypeMap.getFileExtensionFromUrl(coverFile))
}
val chapters = manga.list.filter {
downloadUUID.contains(it.uuid)
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chapterToSkip.remove(chapter.uuid)) {
pushState(
lastState.copy(downloadedChapters = lastState.downloadedChapters + chapter.uuid)
)
continue
}
val pagerInfo = runDownloadPausingDetect(pausingHandle) {
mangaInfoRepository.fetchContentMayLocal(
localList = null,
pathWord = chapter.comicPathWord,
uuid = chapter.uuid
)
}
for ((pagerIndex, pager) in pagerInfo.withIndex()) {
runDownloadPausingDetect(pausingHandle) {
val page = pagerCache.get(url = pager.url)
?: downloadFile(url = pager.url, path = filePath, tmpFile)
outPut.addPager(
localChapter = chapter,
file = page,
pagerNumber = pager.index,
ext = MimeTypeMap.getFileExtensionFromUrl(pager.url)
)
pushState(
lastState.copy(
totalChapters = chapters.size,
currentChapter = chapterIndex,
isIndeterminate = false,
totalPages = pagerInfo.size,
currentPage = pagerIndex
)
)
}
}
pushState(
lastState.copy(
downloadedChapters = lastState.downloadedChapters + chapter.uuid
)
)
mangaHistoryRepository.updateLocalChapter(
chapter.copy(isDownloaded = true)
)
}
pushState(
lastState.copy(isIndeterminate = true)
)
val localManga = outPut.createNewLocalData(downloadUUID)
pushState(
lastState.copy(localManga = LocalManga(localManga, filePath))
)
outPut.cleanUp()
} catch (e: Exception) {
if (e !is CancellationException) {
pushState(
lastState.copy(error = e.message)
)
}
throw e
} finally {
withContext(NonCancellable) {
applicationContext.unregisterReceiver(pausingHandler)
File(filePath, tmpFile).apply {
withContext(Dispatchers.IO) {
delete() || deleteRecursively()
}
}
}
}
}
}
private suspend fun downloadFile(
url: String,
path: File,
tmpFile: String,
): File {
val request: Request = Request.Builder().url(url).build()
val call = okHttpClient.newCall(request)
val response = call.clone().await()
val file = File(path, tmpFile).also {
withContext(Dispatchers.IO) {
it.createNewFile()
}
}
checkNotNull(response.body).use { body ->
file.sink(append = false).buffer().useWithContext(Dispatchers.IO) {
it.writeAll(body.source())
}
}
return file
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
id.hashCode(),
notificationFactory.buildNotification(_lastState),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
ForegroundInfo(
id.hashCode(),
notificationFactory.buildNotification(_lastState),
)
}
}
private suspend fun getDoneChapters(): Array {
val work = WorkManager.getInstance(applicationContext).getWorkInfoById(id).await()
?: return emptyArray()
return DownloadState downloadChaptersIn work.progress
}
private suspend fun pushState(state: DownloadState) {
val previous = lastState
_lastState = state
if (previous.isParticularProgress && state.isParticularProgress) {
timeLeftEstimator.tick(state.progress, state.max)
} else {
timeLeftEstimator.emptyTick()
throttler.reset()
}
val notification = notificationFactory.buildNotification(state)
if (state.isFinalState) {
notificationManager.notify(id.toString(), id.hashCode(), notification)
} else if (throttler.throttle()) {
notificationManager.notify(id.hashCode(), notification)
} else {
return
}
setProgress(data = state.transformToWorkData())
}
private suspend fun runDownloadPausingDetect(
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
if (pausingHandle.isPaused) {
pushState(lastState.copy(isPaused = true))
pausingHandle.awaitResumed()
pushState(lastState.copy(isPaused = false))
}
var countDown = MAX_FAILSAFE_ATTEMPTS
detect@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
pushState(lastState.copy(isPaused = true, error = e.messageNoNull))
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
pushState(lastState.copy(isPaused = false, error = null))
} else {
countDown--
delay(200L)
}
}
}
}
class Caller @Inject constructor(
@ApplicationContext private val context: Context,
private val workManager: WorkManager,
private val setting: SettingPref,
) {
suspend fun download(pathWord: String, downloadUUIDs: Array) {
if (downloadUUIDs.isEmpty()) return
val data = Data.Builder()
.putString(MANGA_PATH_WORD, pathWord)
.putStringArray(MANGA_DOWNLOAD_UUIDS, downloadUUIDs)
.build()
scheduleImpl(listOf(data))
}
fun observerWorker() = workManager.getWorkInfosByTagLiveData(TAG)
.asFlow()
suspend fun cancel(uuid: UUID) {
workManager.cancelWorkById(uuid).await()
}
private suspend fun scheduleImpl(data: Collection) {
if (data.isEmpty()) {
return
}
val constraints = createConstraints()
val requests = data.map { inputData ->
OneTimeWorkRequestBuilder()
.setConstraints(constraints)
.addTag(TAG)
.keepResultsForAtLeast(30, TimeUnit.DAYS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
workManager.enqueue(requests).await()
}
fun pause(id: UUID) {
val intent = PausingHandler.getPauseIntent(context, id)
context.sendBroadcast(intent)
}
fun resume(id: UUID) {
val intent = PausingHandler.getResumeIntent(context, id)
context.sendBroadcast(intent)
}
suspend fun updateConstraints() {
val constraints = createConstraints()
val works = workManager.getWorkInfosByTag(TAG).await()
for (work in works) {
if (work.state.isFinished) {
continue
}
val request = OneTimeWorkRequestBuilder()
.setConstraints(constraints)
.addTag(TAG)
.setId(work.id)
.build()
workManager.updateWork(request).await()
}
}
private fun createConstraints() = Constraints.Builder()
.setRequiredNetworkType(if (setting.downloadOnlyOnWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.build()
}
companion object {
private const val MANGA_PATH_WORD = "MANGA_PATH_WORD"
private const val MANGA_DOWNLOAD_UUIDS = "MANGA_DOWNLOAD_UUIDS"
const val MAX_FAILSAFE_ATTEMPTS = 2
private const val TAG = "download_worker"
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/work/DetectMangaUpdateWork.kt
================================================
package com.shicheeng.copymanga.server.work
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.net.toUri
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.shicheeng.copymanga.MainActivity
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.resposity.MangaHistoryRepository
import com.shicheeng.copymanga.resposity.MangaInfoRepository
import com.shicheeng.copymanga.ui.screen.setting.IN_BATTERY_NOT_LOW
import com.shicheeng.copymanga.ui.screen.setting.IN_CHARGING
import com.shicheeng.copymanga.ui.screen.setting.IN_WIFI
import com.shicheeng.copymanga.ui.screen.setting.SettingPref
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.io.IOException
import java.util.concurrent.TimeUnit
@HiltWorker
class DetectMangaUpdateWork @AssistedInject constructor(
@Assisted private val appContext: Context,
@Assisted params: WorkerParameters,
infoRepository: MangaInfoRepository,
historyRepository: MangaHistoryRepository,
) : CoroutineWorker(appContext, params), IDetectManga.OnMangaDetectUpdate {
private val iDetectManga = IDetectManga(historyRepository, infoRepository, this)
private val notificationManager = appContext.getSystemService(NotificationManager::class.java)
private val notification =
NotificationCompat.Builder(appContext, DETECT_UPDATE_CHANELLE).apply {
setContentTitle(appContext.getString(R.string.update_manga))
setSmallIcon(R.drawable.ic_stat_name)
setContentText(appContext.getString(R.string.preparing))
setProgress(0, 0, true)
setOngoing(true)
setDefaults(0)
setGroup(GROUP_ITEM_CHAPTERS)
setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
setGroupSummary(true)
setOnlyAlertOnce(true)
priority = NotificationCompat.PRIORITY_HIGH
}
override suspend fun doWork(): Result {
notificationManager.notify(
DETECT_UPDATE_NOTIFICATION_ID,
notification.build()
)
return try {
iDetectManga.fetchMangaUpdate()
Result.success()
} catch (e: IOException) {
e.printStackTrace()
Result.retry()
} catch (e: Exception) {
e.printStackTrace()
onError()
Result.failure()
}
}
override fun onReady() {
notificationManager.notify(
DETECT_UPDATE_NOTIFICATION_ID,
notification.build()
)
}
override fun onSubscribe(index: Int, size: Int, historyDataModel: MangaHistoryDataModel) {
notification.setProgress(size, index + 1, false)
notification.setContentText(historyDataModel.name)
notificationManager.notify(
DETECT_UPDATE_NOTIFICATION_ID,
notification.build()
)
}
override fun onError(
eIndex: Int,
historyDataModel: MangaHistoryDataModel,
exception: Throwable,
) {
exception.printStackTrace()
}
override fun onSingleSuccess(
index: Int,
historyDataModel: MangaHistoryDataModel,
newChapter: List,
) {
if (newChapter.isNotEmpty()) {
val pathWord = historyDataModel.pathWord
val link = "shicheengcmdm://detail/$pathWord"
val deepLinkIntent = Intent(
Intent.ACTION_VIEW,
link.toUri(),
appContext,
MainActivity::class.java
)
val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(appContext).run {
addNextIntentWithParentStack(deepLinkIntent)
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}
val notificationItem = NotificationCompat
.Builder(appContext, DETECT_UPDATE_CHANELLE)
.apply {
setContentTitle(historyDataModel.name)
setContentText(newChapter.joinToString { it.name })
setSmallIcon(R.drawable.ic_outline_page)
setOngoing(false)
setGroup(GROUP_ITEM_CHAPTERS)
setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
setContentIntent(deepLinkPendingIntent)
}.build()
notificationManager.notify(historyDataModel.hashCode(), notificationItem)
}
}
override fun onSuccess() {
notification.setProgress(0, 0, false)
notification.setContentText(appContext.getString(R.string.completed))
notification.setOngoing(false)
notification.setAutoCancel(true)
notificationManager.notify(
DETECT_UPDATE_NOTIFICATION_ID,
notification.build()
)
}
private fun onError() {
notification.setProgress(0, 0, false)
notification.setContentText(appContext.getString(R.string.fatal_error))
notification.setOngoing(false)
notification.setAutoCancel(true)
notificationManager.notify(
DETECT_UPDATE_NOTIFICATION_ID,
notification.build()
)
}
companion object {
const val DETECT_UPDATE_CHANELLE = "DETECT_UPDATE_CHANELLE"
private const val DETECT_UPDATE_NOTIFICATION_ID = 0x1a2f3c
private const val GROUP_ITEM_CHAPTERS = "GROUP_ITEM_CHAPTERS"
private const val Tag = "Manga Update Task"
/**
* 启动这个Worker
*/
fun readyToStart(
isEnable: Boolean,
context: Context,
settingPref: SettingPref,
takeInterval: Int? = null,
) {
if (isEnable) {
val interval = takeInterval ?: settingPref.timeInterval.value
if (interval > 0) {
val constraintsSetting = settingPref.updateConstant.value
val constraints = Constraints.Builder()
.setRequiresCharging(IN_CHARGING in constraintsSetting)
.setRequiredNetworkType(
if (IN_WIFI in constraintsSetting) NetworkType.UNMETERED else NetworkType.CONNECTED
)
.setRequiresBatteryNotLow(IN_BATTERY_NOT_LOW in constraintsSetting)
.build()
val work = PeriodicWorkRequestBuilder(
repeatInterval = interval.toLong(),
repeatIntervalTimeUnit = TimeUnit.HOURS,
flexTimeInterval = 10,
flexTimeIntervalUnit = TimeUnit.MINUTES
)
.addTag(Tag)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
Tag,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
work
)
}
} else {
WorkManager.getInstance(context).cancelAllWorkByTag(Tag)
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/server/work/IDetectManga.kt
================================================
package com.shicheeng.copymanga.server.work
import com.shicheeng.copymanga.data.MangaHistoryDataModel
import com.shicheeng.copymanga.data.local.LocalChapter
import com.shicheeng.copymanga.resposity.MangaHistoryRepository
import com.shicheeng.copymanga.resposity.MangaInfoRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class IDetectManga(
private val repository: MangaHistoryRepository,
private val infoRepository: MangaInfoRepository,
private val onMangaDetectUpdate: OnMangaDetectUpdate,
) {
private val mutex = Mutex()
suspend fun fetchMangaUpdate() {
onMangaDetectUpdate.onReady()
val totalMangas = repository.totalHistoryManga().filter { it.isSubscribe }
totalMangas.forEachIndexed { index, mangaHistoryDataModel ->
mutex.withLock {
try {
onMangaDetectUpdate.onSubscribe(
index = index,
size = totalMangas.size,
historyDataModel = mangaHistoryDataModel
)
val oldList = repository
.fetchMangaChapterByPathWord(mangaHistoryDataModel.pathWord)
val list = infoRepository
.fetchMangaChaptersForce(mangaHistoryDataModel.pathWord)
oldList?.let {
val newChapter = list.filterNot { y ->
it.any { x -> x.uuid == y.uuid }
}
onMangaDetectUpdate.onSingleSuccess(
index = index,
historyDataModel = mangaHistoryDataModel,
newChapter = newChapter
)
}
} catch (e: Exception) {
onMangaDetectUpdate.onError(index, mangaHistoryDataModel, e)
}
}
}
onMangaDetectUpdate.onSuccess()
}
interface OnMangaDetectUpdate {
fun onReady()
fun onSubscribe(index: Int, size: Int, historyDataModel: MangaHistoryDataModel)
fun onError(eIndex: Int, historyDataModel: MangaHistoryDataModel, exception: Throwable)
fun onSingleSuccess(
index: Int,
historyDataModel: MangaHistoryDataModel,
newChapter: List,
)
fun onSuccess()
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/MainNavigation.kt
================================================
package com.shicheeng.copymanga.ui.screen
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.shicheeng.copymanga.ui.screen.Router.COMMENT.toCommentScreen
import com.shicheeng.copymanga.ui.screen.Router.EXPLORE.toExplore
import com.shicheeng.copymanga.ui.screen.Router.HISTORY.toHistory
import com.shicheeng.copymanga.ui.screen.authorsmanga.AuthorsMangaScreen
import com.shicheeng.copymanga.ui.screen.comment.CommentScreen
import com.shicheeng.copymanga.ui.screen.download.DownloadScreen
import com.shicheeng.copymanga.ui.screen.downloaded.DownloadedScreen
import com.shicheeng.copymanga.ui.screen.history.HistoryScreen
import com.shicheeng.copymanga.ui.screen.list.NewestScreen
import com.shicheeng.copymanga.ui.screen.list.RecommendScreen
import com.shicheeng.copymanga.ui.screen.login.LoginScreen
import com.shicheeng.copymanga.ui.screen.login.loginlist.LoginPersonalListScreen
import com.shicheeng.copymanga.ui.screen.main.MainScreen
import com.shicheeng.copymanga.ui.screen.main.explore.ExploreScreen
import com.shicheeng.copymanga.ui.screen.main.home.search.SearchScreen
import com.shicheeng.copymanga.ui.screen.main.personal.personaldetail.PersonalDetail
import com.shicheeng.copymanga.ui.screen.main.subscribe.SubScribeScreen
import com.shicheeng.copymanga.ui.screen.manga.MangaDetailScreen
import com.shicheeng.copymanga.ui.screen.search.SearchResultScreen
import com.shicheeng.copymanga.ui.screen.setting.SettingScreen
import com.shicheeng.copymanga.ui.screen.setting.about.AboutScreen
import com.shicheeng.copymanga.ui.screen.setting.worker.WorkerScreen
import com.shicheeng.copymanga.ui.screen.topiclist.TopicListScreen
import com.shicheeng.copymanga.ui.screen.topics.TopicsScreen
import com.shicheeng.copymanga.ui.screen.webshelf.WebShelfScreen
import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
@Composable
fun MainComposeNavigation(
navController: NavHostController = rememberNavController(),
) {
val slide = rememberSlideDistance()
NavHost(
navController = navController,
startDestination = Router.MAIN.name,
enterTransition = {
materialSharedAxisXIn(
forward = true,
slideDistance = slide
)
},
exitTransition = {
materialSharedAxisXOut(
forward = true,
slideDistance = slide
)
},
popEnterTransition = {
materialSharedAxisXIn(
forward = false,
slideDistance = slide
)
},
popExitTransition = {
materialSharedAxisXOut(
forward = false,
slideDistance = slide
)
}
) {
composable(
route = Router.MAIN.name,
) {
MainScreen(
onUUid = { navController.navigate("${Router.DETAIL.name}/$it") },
onDownloadedBtnClick = {
navController.navigate(Router.DOWNLOADED.name)
},
onSearchButtonClick = {
navController.navigate(Router.SEARCH.name)
},
onSettingButtonClick = { navController.navigate(Router.SETTING.name) },
onRecommendHeaderLineClick = {
navController.navigate(Router.RECOMMEND.name)
},
onNewestHeaderLineClick = {
navController.navigate(Router.NEWEST.name)
},
onSubscribedClick = {
navController.navigate(Router.SUBSCRIBE.name)
},
onHistoryClick = {
navController.toHistory()
},
onLibraryClick = {
navController.navigate(Router.WebSHELF.name)
},
onPersonalHeaderClick = { login ->
if (login) {
navController.navigate(Router.UserShortDETAIL.name)
} else {
navController.navigate(Router.LOGIN.name)
}
},
onTopicClick = { pathWord, type ->
navController.navigate(Router.TopicDETAIL.pathWord(pathWord, type))
},
onTopicHeaderLineClick = {
navController.navigate(Router.TOPICS.name)
},
onFinishHeaderLineClick = {
navController.toExplore(
theme = null,
top = "finish",
order = null,
)
},
onLoginExpireClick = {
navController.navigate(Router.LOGIN.name)
}
) {
navController.toExplore(
theme = null,
top = null,
order = "-popular",
)
}
}
composable(
route = "${Router.EXPLORE.name}?theme={theme}&top={top}&order={order}",
arguments = listOf(
navArgument(name = "theme") { nullable = true },
navArgument(name = "top") { nullable = true },
navArgument(name = "order") { nullable = true }
)
) { backStackEntry ->
ExploreScreen(
top = backStackEntry.arguments?.getString("top"),
theme = backStackEntry.arguments?.getString("theme"),
order = backStackEntry.arguments?.getString("order"),
onNavigationIconClick = {
navController.popBackStack()
}
) {
navController.navigate("${Router.DETAIL.name}/${it.pathWord}")
}
}
composable(route = Router.RECOMMEND.name) {
RecommendScreen(
onBack = {
navController.popBackStack()
}
) {
navController.navigate("${Router.DETAIL.name}/$it")
}
}
composable(route = Router.NEWEST.name) {
NewestScreen(
onBack = {
navController.popBackStack()
}
) {
navController.navigate("${Router.DETAIL.name}/$it")
}
}
composable(route = Router.SEARCH.name) {
SearchScreen(
onSearch = {
if (it.isNotEmpty() && it.isNotBlank()) {
navController.navigate("${Router.SearchResult.name}/$it")
}
}
) {
navController.popBackStack()
}
}
composable(
route = "${Router.SearchResult.name}/{searchWord}"
) { navBackStackEntry ->
val word = navBackStackEntry.arguments?.getString("searchWord")
SearchResultScreen(
searchWord = word,
onNavigation = {
navController.popBackStack()
},
onItemClick = {
navController.navigate("${Router.DETAIL.name}/${it.pathWord}")
}
)
}
composable(
route = "${Router.DETAIL.name}/{path_word}",
deepLinks = listOf(
navDeepLink { uriPattern = Router.DETAIL.deepLink },
navDeepLink { uriPattern = Router.DETAIL.copyMangaWebURl }
)
) { backStackEntry ->
val pathWord = backStackEntry.arguments?.getString("path_word")
MangaDetailScreen(
pathWord = pathWord,
onTagsClick = {
navController.toExplore(
top = null,
order = null,
theme = it.pathWord
)
},
onAuthorClick = {
navController.navigate("${Router.AuthorsMANGA.name}/${it}")
},
onCommentClick = {
navController toCommentScreen it
}
) {
navController.popBackStack()
}
}
composable(
route = Router.SETTING.name
) {
SettingScreen(
onNavigateClick = {
navController.popBackStack()
},
onDownloadClick = {
navController.navigate(Router.DOWNLOAD.name)
},
onWorkerClick = {
navController.navigate(Router.WORKER.name)
},
onUserClick = {
navController.navigate(Router.LoginSelect.name)
}
) {
navController.navigate(Router.ABOUT.name)
}
}
composable(
route = Router.DOWNLOAD.name,
deepLinks = listOf(
navDeepLink { uriPattern = Router.DOWNLOAD.deepLink }
)
) {
DownloadScreen(
onNavigationClick = navController::popBackStack,
onCardClick = {
navController.navigate("${Router.DETAIL.name}/$it")
}
)
}
composable(
route = Router.ABOUT.name
) {
AboutScreen {
navController.popBackStack()
}
}
composable(
route = Router.WORKER.name
) {
WorkerScreen {
navController.popBackStack()
}
}
composable(route = Router.DOWNLOADED.name) {
DownloadedScreen(
onNavigate = {
navController.popBackStack()
}
) { pathWord ->
if (pathWord != null) {
navController.navigate("${Router.DETAIL.name}/$pathWord")
}
}
}
composable(route = Router.HISTORY.name) {
HistoryScreen(
navigationClick = {
navController.popBackStack()
},
onRequestLogin = {
navController.navigate(Router.LOGIN.name)
}
) { pathWord ->
navController.navigate("${Router.DETAIL.name}/$pathWord")
}
}
composable(route = Router.SUBSCRIBE.name) {
SubScribeScreen(
navClick = {
navController.popBackStack()
}
) { pathWord ->
navController.navigate("${Router.DETAIL.name}/$pathWord")
}
}
composable(
route = "${Router.TopicDETAIL.name}/{pathWord}?type={type}",
arguments = listOf(
navArgument(name = "pathWord") { type = NavType.StringType },
navArgument(name = "type") { type = NavType.IntType }
)
) {
TopicsScreen(
onBack = {
navController.popBackStack()
}
) {
navController.navigate("${Router.DETAIL.name}/${it}")
}
}
composable(
route = Router.TOPICS.name,
) {
TopicListScreen(
onBack = { navController.popBackStack() }
) {
navController.navigate(Router.TopicDETAIL.pathWord(it.pathWord, it.type))
}
}
composable(route = Router.LOGIN.name) {
LoginScreen(
onNavClick = {
navController.popBackStack()
}
) {
navController.popBackStack()
}
}
composable(route = Router.LoginSelect.name) {
LoginPersonalListScreen(
onAddClicked = {
navController.navigate(Router.LOGIN.name)
}
) {
navController.popBackStack()
}
}
composable(route = Router.WebSHELF.name) {
WebShelfScreen(
navClick = {
navController.popBackStack()
},
reLoginClick = {
navController.navigate(Router.LOGIN.name)
}
) {
navController.navigate("${Router.DETAIL.name}/${it}")
}
}
composable(route = Router.UserShortDETAIL.name) {
PersonalDetail(
onReLogin = {
navController.navigate(Router.LOGIN.name)
}
) {
navController.popBackStack()
}
}
composable(
route = Router.AuthorsMANGA.name + "/{author_path_word}",
arguments = listOf(
navArgument(name = "author_path_word") {
nullable = true
type = NavType.StringType
}
)
) {
AuthorsMangaScreen(
onNav = {
navController.popBackStack()
}
) {
navController.navigate("${Router.DETAIL.name}/${it}")
}
}
composable(route = Router.COMMENT.name + "/{uuid_comic}") {
CommentScreen(
navClick = navController::popBackStack
)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/Router.kt
================================================
package com.shicheeng.copymanga.ui.screen
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.navigation.NavHostController
import com.shicheeng.copymanga.R
/**
* 导航路由
* @param name 必传参数,名字。
* @param stringId 非必传参数,适用于导航栏的字串符资源ID。
* @param drawableRes 非必传参数,适用于导航栏的图标资源ID。
* @param onClickIcon 非必传参数,但是如果传入[drawableRes]则必传,否则报错。在导航栏按钮被按下时显示的图标。
*/
sealed class Router(
val name: String,
@StringRes val stringId: Int? = null,
@DrawableRes val drawableRes: Int? = null,
@DrawableRes val onClickIcon: Int? = null,
) {
object MAIN : Router(
name = "MAIN"
)
object HOME : Router(
name = "HOME",
stringId = R.string.home_des,
drawableRes = R.drawable.outline_home_24,
onClickIcon = R.drawable.ic_baseline_home_24
)
object LEADERBOARD : Router(
name = "LEADERBOARD",
stringId = R.string.comic_rank,
drawableRes = R.drawable.baseline_insert_chart_outlined_24,
onClickIcon = R.drawable.baseline_insert_chart_24
)
object EXPLORE : Router(
name = "EXPLORE",
stringId = R.string.explore,
drawableRes = R.drawable.ic_explore_outline,
onClickIcon = R.drawable.baseline_explore_24
) {
/**
* 转到[EXPLORE]界面。
*
* @param top 话题
* @param theme 主题
* @param order 排序
*/
fun NavHostController.toExplore(top: String?, theme: String?, order: String?) {
navigate(name + "?theme=${theme}&top=${top}&order=${order}")
}
}
object SUBSCRIBE : Router(name = "SUBSCRIBE")
object HISTORY : Router(name = "HISTORY") {
fun NavHostController.toHistory() {
navigate(name)
}
}
object PERSONAL : Router(
name = "PERSONAL",
stringId = R.string.personal,
drawableRes = R.drawable.ic_person_center,
onClickIcon = R.drawable.baseline_person_24
)
object DOWNLOADED : Router(
name = "DOWNLOADED"
)
object RECOMMEND : Router(name = "RECOMMEND")
object NEWEST : Router(name = "NEWEST")
object DETAIL : Router(name = "DETAIL") {
const val deepLink = "shicheengcmdm://detail/{path_word}"
const val copyMangaWebURl = "https://copymanga.site/h5/details/comic/{path_word}"
}
object SEARCH : Router(name = "SEARCH")
object SearchResult : Router(name = "SearchResult")
object SETTING : Router(name = "SETTING")
object WORKER : Router(name = "WORKER")
object DOWNLOAD : Router(name = "DOWNLOAD") {
const val deepLink = "shicheengcmdm://download"
}
object ABOUT : Router(name = "ABOUT")
object TOPICS : Router(name = "TOPIC")
object TopicDETAIL : Router("TOPIC_DETAIL") {
fun pathWord(pathWord: String, type: Int): String {
return this.name + "/${pathWord}?type=$type"
}
}
object LOGIN : Router("LOGIN")
object LoginSelect : Router("LoginSelect")
object WebSHELF : Router("WebSHELF")
object UserShortDETAIL : Router("UserShortDETAIL")
object AuthorsMANGA : Router("AuthorsMANGA")
object COMMENT : Router("COMMENT") {
private fun comicUUid(uuid: String): String {
return this.name + "/$uuid"
}
infix fun NavHostController.toCommentScreen(uuid: String) {
navigate(comicUUid(uuid))
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/authorsmanga/AuthorsMangaScreen.kt
================================================
package com.shicheeng.copymanga.ui.screen.authorsmanga
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.collectAsLazyPagingItems
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.ui.screen.compoents.PlainButton
import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication
import com.shicheeng.copymanga.ui.screen.list.CommonListItem
import com.shicheeng.copymanga.util.copyComposable
import com.shicheeng.copymanga.viewmodel.AuthorMangaViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthorsMangaScreen(
viewModel: AuthorMangaViewModel = hiltViewModel(),
onNav: () -> Unit,
onPathWord: (String) -> Unit,
) {
val data = viewModel.list.collectAsLazyPagingItems()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = stringResource(id = R.string.authors_manga))
},
navigationIcon = {
PlainButton(
id = R.string.back_to_up,
drawableRes = R.drawable.ic_arrow_back,
onButtonClick = onNav
)
}
)
}
) { paddingValues ->
LazyVerticalGrid(
contentPadding = paddingValues.copyComposable(
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp),
columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(data.itemCount) { index ->
data[index]?.let { mangaItem ->
CommonListItem(
url = mangaItem.cover,
title = mangaItem.name,
author = mangaItem.author.joinToString { it.name }
) {
onPathWord(mangaItem.pathWord)
}
}
}
pagingLoadingIndication(
loadState = data.loadState.append,
onTry = data::retry
)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/comment/CommentItem.kt
================================================
package com.shicheeng.copymanga.ui.screen.comment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.shicheeng.copymanga.data.mangacomment.MangaCommentListItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommentItem(
commentListItem: MangaCommentListItem,
onClick: () -> Unit,
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = {
onClick()
}
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
AsyncImage(
model = commentListItem.userAvatar,
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(32.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier.padding(bottom = 4.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = commentListItem.userName,
style = MaterialTheme.typography.titleMedium
)
Text(
text = commentListItem.createAt,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalDivider()
Text(
text = commentListItem.comment,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/comment/CommentScreen.kt
================================================
package com.shicheeng.copymanga.ui.screen.comment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.ui.screen.compoents.EmptyDataScreen
import com.shicheeng.copymanga.ui.screen.compoents.PlainButton
import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication
import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.SwipeRefresh
import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.rememberSwipeRefreshState
import com.shicheeng.copymanga.util.SendUIState
import com.shicheeng.copymanga.viewmodel.CommentViewModel
@OptIn(
ExperimentalMaterial3Api::class
)
@Composable
fun CommentScreen(
viewModel: CommentViewModel = hiltViewModel(),
navClick: () -> Unit,
) {
val list = viewModel.comments.collectAsLazyPagingItems()
val pullRefreshState = rememberSwipeRefreshState(
isRefreshing = list.loadState.refresh is LoadState.Loading,
)
val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val (sendContent, onSendContent) = rememberSaveable { mutableStateOf("") }
val commentStatus by viewModel.commentPush.collectAsState()
val isExpired by viewModel.loginIsExpired.collectAsState()
LaunchedEffect(key1 = commentStatus) {
if (commentStatus is SendUIState.Success) {
list.refresh()
}
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.ime),
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.comic_comment_title)) },
navigationIcon = {
PlainButton(
id = R.string.back_to_up,
drawableRes = R.drawable.ic_arrow_back,
onButtonClick = navClick
)
},
scrollBehavior = topAppBarScrollBehavior,
modifier = Modifier,
windowInsets = WindowInsets.statusBars
)
},
bottomBar = {
CommentSendBar(
value = sendContent,
onValueChange = onSendContent,
sendUIState = commentStatus,
modifier = Modifier,
isExpired = isExpired
) {
viewModel.sendComment(sendContent)
}
}
) { padding ->
SwipeRefresh(
state = pullRefreshState,
onRefresh = {
list.refresh()
},
indicatorPadding = padding,
) {
EmptyDataScreen(
isEmpty = list.itemSnapshotList.isEmpty(),
modifier = Modifier
) {
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
) {
items(list.itemCount) {
list[it]?.let { commentItem ->
CommentItem(commentListItem = commentItem) {
}
}
}
pagingLoadingIndication(
loadState = list.loadState.append,
onTry = list::retry
)
}
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/comment/CommentSendBar.kt
================================================
package com.shicheeng.copymanga.ui.screen.comment
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.commentpush.CommentPushDataModel
import com.shicheeng.copymanga.ui.theme.ElevationTokens
import com.shicheeng.copymanga.util.SendUIState
import soup.compose.material.motion.animation.materialFadeThroughIn
import soup.compose.material.motion.animation.materialFadeThroughOut
@Composable
fun CommentSendBar(
modifier: Modifier = Modifier,
value: String,
isExpired:Boolean,
onValueChange: (String) -> Unit,
sendUIState: SendUIState,
onSend: () -> Unit,
) {
Surface(
tonalElevation = ElevationTokens.Level4,
shadowElevation = ElevationTokens.Level2,
modifier = modifier
.zIndex(1f)
) {
Column(
modifier = Modifier
.padding(
bottom = 8.dp,
top = 8.dp,
end = 16.dp,
start = 16.dp
)
.navigationBarsPadding()
.imePadding()
.animateContentSize()
) {
Text(
text = stringResource(R.string.send_comment_bar_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
),
textStyle = MaterialTheme.typography.bodyMedium
.copy(
color = MaterialTheme.colorScheme.onSurface
)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
tonalElevation = ElevationTokens.Level0,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Box(
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 8.dp
),
contentAlignment = Alignment.CenterStart,
) {
it()
this@Row.AnimatedVisibility(
visible = value.isEmpty(),
enter = materialFadeThroughIn(),
exit = materialFadeThroughOut()
) {
Text(
text = stringResource(R.string.type_send_content),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
Spacer(modifier = Modifier.width(8.dp))
FilledTonalButton(
onClick = onSend,
enabled = sendUIState == SendUIState.Idle && !isExpired
) {
when (sendUIState) {
is SendUIState.Error -> {
Icon(
painter = painterResource(id = R.drawable.baseline_close_24),
contentDescription = null
)
}
SendUIState.Idle -> {
Icon(
painter = painterResource(id = R.drawable.baseline_send_24),
contentDescription = null
)
}
SendUIState.Loading -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
is SendUIState.Success -> {
Icon(
painter = painterResource(id = R.drawable.ic_done_all),
contentDescription = null
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringResource(R.string.send_comment))
}
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/CircleLoadingButton.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.ui.theme.ElevationTokens
@Composable
fun CircleLoadingButton(
modifier: Modifier = Modifier,
isLoading: Boolean,
onClick: () -> Unit,
tonalElevation: Dp = ElevationTokens.Level3,
) {
Surface(
onClick = onClick,
shape = CircleShape,
modifier = modifier.size(68.dp),
tonalElevation = tonalElevation
) {
AnimatedContent(
targetState = isLoading,
label = "circle loading"
) {
if (it) {
CircularProgressIndicator(
modifier = Modifier.padding(16.dp)
)
} else {
Icon(
painter = painterResource(id = R.drawable.undraw_arrow),
contentDescription = stringResource(id = R.string.login_text),
modifier = Modifier.padding(16.dp)
)
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/Components.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* 带有[PlainTooltipBox]的[IconButton]。
* @param id String的资源ID,
* @param drawableRes 图片的资源id,
* @param onButtonClick 点击事件回调。
*/
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PlainButton(
modifier: Modifier = Modifier,
@StringRes id: () -> Int,
@DrawableRes drawableRes: () -> Int,
onButtonClick: () -> Unit,
) {
TooltipBox(
tooltip = {
NormalTooltip(text = stringResource(id = id()))
},
modifier = modifier,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
state = rememberTooltipState()
) {
IconButton(
onClick = onButtonClick,
modifier = Modifier
) {
Icon(
painter = painterResource(id = drawableRes()),
contentDescription = stringResource(id = id())
)
}
}
}
/**
* 带有[PlainTooltipBox]的[IconButton]。
* @param id String的资源ID,
* @param drawableRes 图片的资源id,
* @param onButtonClick 点击事件回调。
*/
@Composable
fun PlainButton(
@StringRes id: Int,
@DrawableRes drawableRes: Int,
onButtonClick: () -> Unit,
) {
PlainButton(id = { id }, drawableRes = { drawableRes }, onButtonClick = onButtonClick)
}
@Composable
private fun NormalTooltip(
modifier: Modifier = Modifier,
text: String,
) {
Surface(
contentColor = MaterialTheme.colorScheme.tertiaryContainer,
color = MaterialTheme.colorScheme.onTertiaryContainer,
shape = MaterialTheme.shapes.extraSmall,
modifier = modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall
.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(8.dp)
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/ComposeExt.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.Dp
import com.shicheeng.copymanga.ui.theme.ElevationTokens
/**
* 从Attr中引入资源
* @param attrResId 资源ID。
*/
@Composable
@ReadOnlyComposable
internal fun dimensionAttribute(
@AttrRes attrResId: Int,
) = dimensionResource(TypedValue().apply {
LocalContext.current.theme.resolveAttribute(
attrResId,
this,
true
)
}.resourceId)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun withAppBarColor(
backgroundColor: Color = MaterialTheme.colorScheme.surface,
topAppBarState: TopAppBarState,
): Color {
val colorTransitionFraction = topAppBarState.overlappedFraction
val fraction = if (colorTransitionFraction > 0.01f) 1f else 0f
val appBarContainerColor by animateColorAsState(
targetValue = lerp(
start = backgroundColor,
stop = MaterialTheme.colorScheme.applyTonalElevation(
backgroundColor = backgroundColor,
elevation = ElevationTokens.Level2
),
fraction = FastOutLinearInEasing.transform(fraction)
),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "color"
)
return appBarContainerColor
}
fun ColorScheme.applyTonalElevation(backgroundColor: Color, elevation: Dp): Color {
return if (backgroundColor == surface) {
surfaceColorAtElevation(elevation)
} else {
backgroundColor
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/EasyCover.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
@Composable
fun CommonCover(
url: String,
contentDescription: String,
shape: Shape = MaterialTheme.shapes.medium,
) {
AsyncImage(
model = url,
contentDescription = contentDescription,
placeholder = ColorPainter(MaterialTheme.colorScheme.primary),
modifier = Modifier
.aspectRatio(2f / 3f)
.clip(shape),
contentScale = ContentScale.Crop
)
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/EmptyDataScreen.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.shicheeng.copymanga.R
@Composable
fun EmptyDataScreen(
modifier: Modifier = Modifier,
tipText: String = stringResource(id = R.string.no_content),
isEmpty: Boolean,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.fillMaxSize(),
) {
if (isEmpty) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.undraw_no_data_re_kwbl),
contentDescription = null,
)
Text(text = tipText)
}
}
} else {
content()
}
}
}
@Composable
fun EmptyDataScreen(
modifier: Modifier = Modifier,
tipText: String = stringResource(id = R.string.no_content),
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.undraw_no_data_re_kwbl),
contentDescription = null,
)
Text(text = tipText)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/LoadingScreen.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import com.shicheeng.copymanga.R
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Composable
fun ErrorScreen(
errorMessage: String,
onTry: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = errorMessage)
FilledTonalButton(onClick = onTry) {
Text(text = stringResource(id = R.string.retry))
}
}
}
}
@Composable
fun ErrorScreen(
errorMessage: String,
onTry: () -> Unit,
secondaryText: String,
onSecondaryClick: () -> Unit = { },
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = errorMessage)
Spacer(modifier = Modifier.height(4.dp))
Row {
FilledTonalButton(onClick = onTry) {
Text(text = stringResource(id = R.string.retry))
}
FilledTonalButton(
onClick = onSecondaryClick,
modifier = Modifier.padding(start = 8.dp)
) {
Text(text = secondaryText)
}
}
}
}
}
@Composable
fun ErrorScreen(
errorMessage: String,
onTry: () -> Unit,
needSecondaryText: Boolean,
secondaryText: String,
onSecondaryClick: () -> Unit = { },
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = errorMessage)
Spacer(modifier = Modifier.height(4.dp))
Row {
FilledTonalButton(onClick = onTry) {
Text(text = stringResource(id = R.string.retry))
}
if (needSecondaryText) {
FilledTonalButton(
onClick = onSecondaryClick,
modifier = Modifier.padding(start = 8.dp)
) {
Text(text = secondaryText)
}
}
}
}
}
}
fun LazyGridScope.pagingLoadingIndication(loadState: LoadState, onTry: () -> Unit) {
item(
span = {
GridItemSpan(3)
}
) {
when (loadState) {
is LoadState.Loading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
}
is LoadState.NotLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(id = R.string.all_clear),
modifier = Modifier.padding(16.dp)
)
}
}
is LoadState.Error -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.load_failure))
FilledTonalButton(onClick = onTry) {
Text(text = stringResource(id = R.string.retry))
}
}
}
}
}
}
}
fun LazyListScope.pagingLoadingIndication(loadState: LoadState, onTry: () -> Unit) {
item(
contentType = "pagingLoadingIndication",
key = "pagingLoadingIndication"
) {
when (loadState) {
is LoadState.Loading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
}
is LoadState.NotLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(id = R.string.all_clear),
modifier = Modifier.padding(16.dp)
)
}
}
is LoadState.Error -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.load_failure))
FilledTonalButton(onClick = onTry) {
Text(text = stringResource(id = R.string.retry))
}
}
}
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/MangaCover.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
enum class MangaCover(val size: Dp) {
/**
* 最小的封面,大小为65dp
*/
ExtraSmall(65.dp),
/**
* 小的封面,大小为100dp
*/
Small(100.dp),
/**
* 大的封面,大小为160dp
*/
Big(160.dp);
@Composable
operator fun invoke(
url: Any?,
shape: Shape? = MaterialTheme.shapes.extraSmall,
) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.width(size)
.aspectRatio(2f / 3f)
.then(
if (shape != null) {
Modifier.clip(shape)
} else {
Modifier
}
),
placeholder = ColorPainter(MaterialTheme.colorScheme.outline)
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/RefreshLayout.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RefreshLayout(
modifier: Modifier = Modifier,
pullRefreshState: PullRefreshState,
isRefreshing: Boolean,
topPadding: Dp,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.pullRefresh(state = pullRefreshState)
.fillMaxSize()
) {
content()
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
contentColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = topPadding)
.align(Alignment.TopCenter)
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/SaveStatePager.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerScope
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.SaveableStateHolder
import androidx.compose.ui.Modifier
import soup.compose.material.motion.animation.materialFadeThroughIn
import soup.compose.material.motion.animation.materialFadeThroughOut
private const val TabFadeDuration = 200
/**
* 可以保存状态的[HorizontalPager],实际上是一个封装。
*
* @param pageContent 内容
* @param savableStateHolder 将状态提升到主界面
* @see HorizontalPager
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SaveStatePager(
modifier: Modifier = Modifier,
pagerState: PagerState,
contentPadding: PaddingValues,
savableStateHolder: SaveableStateHolder,
keys: (() -> List)? = null,
pageContent: @Composable (PagerScope.(Int) -> Unit),
) {
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
userScrollEnabled = false
) {
savableStateHolder.SaveableStateProvider(
key = if (keys != null) keys()[it].hashCode() else it,
content = {
pageContent(it)
}
)
}
}
/**
* A content which can switchable and have animation named [materialFadeThroughIn] and [materialFadeThroughOut].
*
* @param contentPadding A [PaddingValues] that use in content.
* @param currentPager A number which pager now showing.
* @param savableStateHolder Provide a [SaveableStateHolder] that will use in this function.
* @param keys Provide a key list. It will use [currentPager] if null.
* @param pageContent Content showing on screen.
*/
@Composable
fun SaveStateContentPager(
modifier: Modifier = Modifier,
contentPadding: PaddingValues,
currentPager: Int,
savableStateHolder: SaveableStateHolder,
keys: (() -> List)? = null,
pageContent: @Composable (Int) -> Unit,
) {
AnimatedContent(
modifier = modifier.padding(contentPadding),
targetState = currentPager,
transitionSpec = {
materialFadeThroughIn(
initialScale = 1f,
durationMillis = TabFadeDuration
) togetherWith materialFadeThroughOut(
durationMillis = TabFadeDuration
)
},
label = "pager_content_move_with_material_fade"
) {
savableStateHolder.SaveableStateProvider(
key = if (keys != null) keys()[it].hashCode() else it,
content = {
pageContent(it)
}
)
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/VerticalFastScroller.kt
================================================
package com.shicheeng.copymanga.ui.screen.compoents
import android.view.ViewConfiguration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
fun VerticalFastScroller(
listState: LazyListState,
thumbColor: Color = MaterialTheme.colorScheme.primary,
topContentPadding: Dp = Dp.Hairline,
endContentPadding: Dp = Dp.Hairline,
content: @Composable () -> Unit,
) {
SubcomposeLayout { constraints ->
val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
val contentHeight = contentPlaceable.maxByOrNull { it.height }?.height ?: 0
val contentWidth = contentPlaceable.maxByOrNull { it.width }?.width ?: 0
val scrollerPlaceable = subcompose("scroller") {
val layoutInfo = listState.layoutInfo
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
if (!showScroller) return@subcompose
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
val dragInteractionSource = remember { MutableInteractionSource() }
val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
val heightPx =
contentHeight.toFloat() - thumbTopPadding - listState.layoutInfo.afterContentPadding
val thumbHeightPx = with(LocalDensity.current) { _thumbLength.toPx() }
val trackHeightPx = heightPx - thumbHeightPx
val scrolled = remember {
MutableSharedFlow(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
}
// When list scrolled
LaunchedEffect(listState.firstVisibleItemScrollOffset) {
if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
val scrollOffset = computeScrollOffset(state = listState)
val scrollRange = computeScrollRange(state = listState)
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
scrolled.tryEmit(Unit)
}
// When thumb dragged
LaunchedEffect(thumbOffsetY) {
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
val scrollItemRounded = scrollItem.roundToInt()
val scrollItemSize =
layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
listState.scrollToItem(
index = scrollItemRounded,
scrollOffset = scrollItemOffset.roundToInt()
)
scrolled.tryEmit(Unit)
}
// Thumb alpha
val alpha = remember { Animatable(0f) }
val isThumbVisible = alpha.value > 0f
LaunchedEffect(scrolled, alpha) {
scrolled.collectLatest {
alpha.snapTo(1f)
alpha.animateTo(0f, animationSpec = _fadeOutAnimationSpec)
}
}
Box(
modifier = Modifier
.offset { IntOffset(0, thumbOffsetY.roundToInt()) }
.height(_thumbLength)
.then(
// Exclude thumb from gesture area only when needed
if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
Modifier.systemGestureExclusion()
} else Modifier,
)
.padding(end = endContentPadding)
.width(_thumbThickness)
.alpha(alpha = alpha.value)
.background(color = thumbColor, shape = _thumbShape)
.then(
// Recompose opts
if (!listState.isScrollInProgress) {
Modifier.draggable(
interactionSource = dragInteractionSource,
orientation = Orientation.Vertical,
enabled = isThumbVisible,
state = rememberDraggableState { delta ->
val newOffsetY = thumbOffsetY + delta
thumbOffsetY = newOffsetY.coerceIn(
thumbTopPadding,
thumbTopPadding + trackHeightPx
)
},
)
} else Modifier,
),
)
}.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) }
val scrollerWidth = scrollerPlaceable.maxByOrNull { it.width }?.width ?: 0
layout(contentWidth, contentHeight) {
contentPlaceable.forEach {
it.placeRelative(0, 0)
}
scrollerPlaceable.forEach {
it.placeRelative(contentWidth - scrollerWidth, 0)
}
}
}
}
private fun computeScrollOffset(state: LazyListState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
val startChild = visibleItems.first()
val endChild = visibleItems.last()
val minPosition = min(startChild.index, endChild.index)
val maxPosition = max(startChild.index, endChild.index)
val itemsBefore = minPosition.coerceAtLeast(0)
val startDecoratedTop = startChild.top
val laidOutArea = abs(endChild.bottom - startDecoratedTop)
val itemRange = abs(minPosition - maxPosition) + 1
val avgSizePerRow = laidOutArea.toFloat() / itemRange
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
}
private fun computeScrollRange(state: LazyListState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
val startChild = visibleItems.first()
val endChild = visibleItems.last()
val laidOutArea = endChild.bottom - startChild.top
val laidOutRange = abs(startChild.index - endChild.index) + 1
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
}
private val _thumbLength = 52.dp
private val _thumbThickness = 8.dp
private val _thumbShape = RoundedCornerShape(_thumbThickness / 2)
private val _fadeOutAnimationSpec = tween(
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
delayMillis = 2000,
)
private val LazyListItemInfo.top: Int
get() = offset
private val LazyListItemInfo.bottom: Int
get() = offset + size
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/CircularProgressPainter.kt
================================================
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import kotlin.math.min
/**
* A private class to do all the drawing of SwipeRefreshIndicator, which includes progress spinner
* and the arrow. This class is to separate drawing from animation.
* Adapted from CircularProgressDrawable.
*/
internal class CircularProgressPainter : Painter() {
var color by mutableStateOf(Color.Unspecified)
var alpha by mutableFloatStateOf(1f)
var arcRadius by mutableStateOf(0.dp)
var strokeWidth by mutableStateOf(5.dp)
var arrowEnabled by mutableStateOf(false)
var arrowWidth by mutableStateOf(0.dp)
var arrowHeight by mutableStateOf(0.dp)
var arrowScale by mutableFloatStateOf(1f)
private val arrow: Path by lazy {
Path().apply { fillType = PathFillType.EvenOdd }
}
var startTrim by mutableFloatStateOf(0f)
var endTrim by mutableFloatStateOf(0f)
var rotation by mutableFloatStateOf(0f)
override val intrinsicSize: Size
get() = Size.Unspecified
override fun applyAlpha(alpha: Float): Boolean {
this.alpha = alpha
return true
}
override fun DrawScope.onDraw() {
rotate(degrees = rotation) {
val arcRadius = arcRadius.toPx() + strokeWidth.toPx() / 2f
val arcBounds = Rect(
size.center.x - arcRadius,
size.center.y - arcRadius,
size.center.x + arcRadius,
size.center.y + arcRadius
)
val startAngle = (startTrim + rotation) * 360
val endAngle = (endTrim + rotation) * 360
val sweepAngle = endAngle - startAngle
drawArc(
color = color,
alpha = alpha,
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = arcBounds.topLeft,
size = arcBounds.size,
style = Stroke(
width = strokeWidth.toPx(),
cap = StrokeCap.Square
)
)
if (arrowEnabled) {
drawArrow(startAngle, sweepAngle, arcBounds)
}
}
}
private fun DrawScope.drawArrow(startAngle: Float, sweepAngle: Float, bounds: Rect) {
arrow.reset()
arrow.moveTo(0f, 0f)
arrow.lineTo(
x = arrowWidth.toPx() * arrowScale,
y = 0f
)
arrow.lineTo(
x = arrowWidth.toPx() * arrowScale / 2,
y = arrowHeight.toPx() * arrowScale
)
val radius = min(bounds.width, bounds.height) / 2f
val inset = arrowWidth.toPx() * arrowScale / 2f
arrow.translate(
Offset(
x = radius + bounds.center.x - inset,
y = bounds.center.y + strokeWidth.toPx() / 2f
)
)
arrow.close()
rotate(degrees = startAngle + sweepAngle) {
drawPath(
path = arrow,
color = color,
alpha = alpha
)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/Slingshot.kt
================================================
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* A utility function that calculates various aspects of 'slingshot' behavior.
* Adapted from SwipeRefreshLayout#moveSpinner method.
*
* TODO: Investigate replacing this with a spring.
*
* @param offsetY The current y offset.
* @param maxOffsetY The max y offset.
* @param height The height of the item to slingshot.
*/
@Composable
internal fun rememberUpdatedSlingshot(
offsetY: Float,
maxOffsetY: Float,
height: Int,
): Slingshot {
val offsetPercent = min(1f, offsetY / maxOffsetY)
val adjustedPercent = max(offsetPercent - 0.4f, 0f) * 5 / 3
val extraOffset = abs(offsetY) - maxOffsetY
// Can accommodate custom start and slingshot distance here
val tensionSlingshotPercent = max(
0f, min(extraOffset, maxOffsetY * 2) / maxOffsetY
)
val tensionPercent = (
(tensionSlingshotPercent / 4) -
(tensionSlingshotPercent / 4).pow(2)
) * 2
val extraMove = maxOffsetY * tensionPercent * 2
val targetY = height + ((maxOffsetY * offsetPercent) + extraMove).toInt()
val offset = targetY - height
val strokeStart = adjustedPercent * 0.8f
val startTrim = 0f
val endTrim = strokeStart.coerceAtMost(MaxProgressArc)
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent * 2) * 0.5f
val arrowScale = min(1f, adjustedPercent)
return remember { Slingshot() }.apply {
this.offset = offset
this.startTrim = startTrim
this.endTrim = endTrim
this.rotation = rotation
this.arrowScale = arrowScale
}
}
@Stable
internal class Slingshot {
var offset: Int by mutableIntStateOf(0)
var startTrim: Float by mutableFloatStateOf(0f)
var endTrim: Float by mutableFloatStateOf(0f)
var rotation: Float by mutableFloatStateOf(0f)
var arrowScale: Float by mutableFloatStateOf(0f)
}
internal const val MaxProgressArc = 0.8f
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/SwipeRefresh.kt
================================================
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private const val DragMultiplier = 0.5f
/**
* Creates a [SwipeRefreshState] that is remembered across compositions.
*
* Changes to [isRefreshing] will result in the [SwipeRefreshState] being updated.
*
* @param isRefreshing the value for [SwipeRefreshState.isRefreshing]
*/
@Composable
fun rememberSwipeRefreshState(
isRefreshing: Boolean,
): SwipeRefreshState {
return remember {
SwipeRefreshState(
isRefreshing = isRefreshing
)
}.apply {
this.isRefreshing = isRefreshing
}
}
/**
* A state object that can be hoisted to control and observe changes for [SwipeRefresh].
*
* In most cases, this will be created via [rememberSwipeRefreshState].
*
* @param isRefreshing the initial value for [SwipeRefreshState.isRefreshing]
*/
@Stable
class SwipeRefreshState(
isRefreshing: Boolean,
) {
private val _indicatorOffset = Animatable(0f)
private val mutatorMutex = MutatorMutex()
/**
* Whether this [SwipeRefreshState] is currently refreshing or not.
*/
var isRefreshing: Boolean by mutableStateOf(isRefreshing)
/**
* Whether a swipe/drag is currently in progress.
*/
var isSwipeInProgress: Boolean by mutableStateOf(false)
internal set
/**
* The current offset for the indicator, in pixels.
*/
val indicatorOffset: Float get() = _indicatorOffset.value
internal suspend fun animateOffsetTo(offset: Float) {
mutatorMutex.mutate {
_indicatorOffset.animateTo(offset)
}
}
/**
* Dispatch scroll delta in pixels from touch events.
*/
internal suspend fun dispatchScrollDelta(delta: Float) {
mutatorMutex.mutate(MutatePriority.UserInput) {
_indicatorOffset.snapTo(_indicatorOffset.value + delta)
}
}
}
private class SwipeRefreshNestedScrollConnection(
private val state: SwipeRefreshState,
private val coroutineScope: CoroutineScope,
private val onRefresh: () -> Unit,
) : NestedScrollConnection {
var enabled: Boolean = false
var refreshTrigger: Float = 0f
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset = when {
// If swiping isn't enabled, return zero
!enabled -> Offset.Zero
// If we're refreshing, return zero
state.isRefreshing -> Offset.Zero
// If the user is swiping up, handle it
source == NestedScrollSource.Drag && available.y < 0 -> onScroll(available)
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset = when {
// If swiping isn't enabled, return zero
!enabled -> Offset.Zero
// If we're refreshing, return zero
state.isRefreshing -> Offset.Zero
// If the user is swiping down and there's y remaining, handle it
source == NestedScrollSource.Drag && available.y > 0 -> onScroll(available)
else -> Offset.Zero
}
private fun onScroll(available: Offset): Offset {
if (available.y > 0) {
state.isSwipeInProgress = true
} else if (state.indicatorOffset.roundToInt() == 0) {
state.isSwipeInProgress = false
}
val newOffset = (available.y * DragMultiplier + state.indicatorOffset).coerceAtLeast(0f)
val dragConsumed = newOffset - state.indicatorOffset
return if (dragConsumed.absoluteValue >= 0.5f) {
coroutineScope.launch {
state.dispatchScrollDelta(dragConsumed)
}
// Return the consumed Y
Offset(x = 0f, y = dragConsumed / DragMultiplier)
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
// If we're dragging, not currently refreshing and scrolled
// past the trigger point, refresh!
if (!state.isRefreshing && state.indicatorOffset >= refreshTrigger) {
onRefresh()
}
// Reset the drag in progress state
state.isSwipeInProgress = false
// Don't consume any velocity, to allow the scrolling layout to fling
return Velocity.Zero
}
}
/**
* A layout which implements the swipe-to-refresh pattern, allowing the user to refresh content via
* a vertical swipe gesture.
*
* This layout requires its content to be scrollable so that it receives vertical swipe events.
* The scrollable content does not need to be a direct descendant though. Layouts such as
* [androidx.compose.foundation.lazy.LazyColumn] are automatically scrollable, but others such as
* [androidx.compose.foundation.layout.Column] require you to provide the
* [androidx.compose.foundation.verticalScroll] modifier to that content.
*
* Apps should provide a [onRefresh] block to be notified each time a swipe to refresh gesture
* is completed. That block is responsible for updating the [state] as appropriately,
* typically by setting [SwipeRefreshState.isRefreshing] to `true` once a 'refresh' has been
* started. Once a refresh has completed, the app should then set
* [SwipeRefreshState.isRefreshing] to `false`.
*
* If an app wishes to show the progress animation outside of a swipe gesture, it can
* set [SwipeRefreshState.isRefreshing] as required.
*
* This layout does not clip any of it's contents, including the indicator. If clipping
* is required, apps can provide the [androidx.compose.ui.draw.clipToBounds] modifier.
*
* @sample com.google.accompanist.sample.swiperefresh.SwipeRefreshSample
*
* @param state the state object to be used to control or observe the [SwipeRefresh] state.
* @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
* @param modifier the modifier to apply to this layout.
* @param swipeEnabled Whether the the layout should react to swipe gestures or not.
* @param refreshTriggerDistance The minimum swipe distance which would trigger a refresh.
* @param indicatorAlignment The alignment of the indicator. Defaults to [Alignment.TopCenter].
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
* @param indicator the indicator that represents the current state. By default this
* will use a [SwipeRefreshIndicator].
* @param clipIndicatorToPadding Whether to clip the indicator to [indicatorPadding]. If false is
* provided the indicator will be clipped to the [content] bounds. Defaults to true.
* @param content The content containing a scroll composable.
*/
@Composable
fun SwipeRefresh(
state: SwipeRefreshState,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
swipeEnabled: Boolean = true,
refreshTriggerDistance: Dp = 80.dp,
indicatorAlignment: Alignment = Alignment.TopCenter,
indicatorPadding: PaddingValues = PaddingValues(0.dp),
indicator: @Composable (state: SwipeRefreshState, refreshTrigger: Dp) -> Unit = { s, trigger ->
SwipeRefreshIndicator(s, trigger)
},
clipIndicatorToPadding: Boolean = true,
content: @Composable () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val updatedOnRefresh = rememberUpdatedState(onRefresh)
// Our LaunchedEffect, which animates the indicator to its resting position
LaunchedEffect(state.isSwipeInProgress) {
if (!state.isSwipeInProgress) {
// If there's not a swipe in progress, rest the indicator at 0f
state.animateOffsetTo(0f)
}
}
val refreshTriggerPx = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
// Our nested scroll connection, which updates our state.
val nestedScrollConnection = remember(state, coroutineScope) {
SwipeRefreshNestedScrollConnection(state, coroutineScope) {
// On refresh, re-dispatch to the update onRefresh block
updatedOnRefresh.value.invoke()
}
}.apply {
this.enabled = swipeEnabled
this.refreshTrigger = refreshTriggerPx
}
Box(modifier.nestedScroll(connection = nestedScrollConnection)) {
content()
Box(
Modifier
// If we're not clipping to the padding, we use clipToBounds() before the padding()
// modifier.
.let { if (!clipIndicatorToPadding) it.clipToBounds() else it }
.padding(indicatorPadding)
.matchParentSize()
// Else, if we're are clipping to the padding, we use clipToBounds() after
// the padding() modifier.
.let { if (clipIndicatorToPadding) it.clipToBounds() else it }
) {
Box(Modifier.align(indicatorAlignment)) {
indicator(state, refreshTriggerDistance)
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/SwipeRefreshIndicator.kt
================================================
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A class to encapsulate details of different indicator sizes.
*
* @param size The overall size of the indicator.
* @param arcRadius The radius of the arc.
* @param strokeWidth The width of the arc stroke.
* @param arrowWidth The width of the arrow.
* @param arrowHeight The height of the arrow.
*/
@Immutable
private data class SwipeRefreshIndicatorSizes(
val size: Dp,
val arcRadius: Dp,
val strokeWidth: Dp,
val arrowWidth: Dp,
val arrowHeight: Dp,
)
/**
* The default/normal size values for [SwipeRefreshIndicator].
*/
private val DefaultSizes = SwipeRefreshIndicatorSizes(
size = 40.dp,
arcRadius = 7.5.dp,
strokeWidth = 2.5.dp,
arrowWidth = 10.dp,
arrowHeight = 5.dp,
)
/**
* The 'large' size values for [SwipeRefreshIndicator].
*/
private val LargeSizes = SwipeRefreshIndicatorSizes(
size = 56.dp,
arcRadius = 11.dp,
strokeWidth = 3.dp,
arrowWidth = 12.dp,
arrowHeight = 6.dp,
)
/**
* Indicator composable which is typically used in conjunction with [SwipeRefresh].
*
* @param state The [SwipeRefreshState] passed into the [SwipeRefresh] `indicator` block.
* @param modifier The modifier to apply to this layout.
* @param fade Whether the arrow should fade in/out as it is scrolled in. Defaults to true.
* @param scale Whether the indicator should scale up/down as it is scrolled in. Defaults to false.
* @param arrowEnabled Whether an arrow should be drawn on the indicator. Defaults to true.
* @param backgroundColor The color of the indicator background surface.
* @param contentColor The color for the indicator's contents.
* @param shape The shape of the indicator background surface. Defaults to [CircleShape].
* @param largeIndication Whether the indicator should be 'large' or not. Defaults to false.
* @param elevation The size of the shadow below the indicator.
*/
@Composable
fun SwipeRefreshIndicator(
state: SwipeRefreshState,
refreshTriggerDistance: Dp,
modifier: Modifier = Modifier,
fade: Boolean = true,
scale: Boolean = false,
arrowEnabled: Boolean = true,
backgroundColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = contentColorFor(backgroundColor),
shape: Shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)),
refreshingOffset: Dp = 16.dp,
largeIndication: Boolean = false,
elevation: Dp = 6.dp,
) {
val sizes = if (largeIndication) LargeSizes else DefaultSizes
val indicatorRefreshTrigger = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
val indicatorHeight = with(LocalDensity.current) { sizes.size.roundToPx() }
val refreshingOffsetPx = with(LocalDensity.current) { refreshingOffset.toPx() }
val slingshot = rememberUpdatedSlingshot(
offsetY = state.indicatorOffset,
maxOffsetY = indicatorRefreshTrigger,
height = indicatorHeight,
)
var offset by remember { mutableFloatStateOf(0f) }
if (state.isSwipeInProgress) {
// If the user is currently swiping, we use the 'slingshot' offset directly
offset = slingshot.offset.toFloat()
} else {
// If there's no swipe currently in progress, animate to the correct resting position
LaunchedEffect(state.isRefreshing) {
animate(
initialValue = offset,
targetValue = when {
state.isRefreshing -> indicatorHeight + refreshingOffsetPx
else -> 0f
}
) { value, _ ->
offset = value
}
}
}
val adjustedElevation = when {
state.isRefreshing -> elevation
offset > 0.5f -> elevation
else -> 0.dp
}
Surface(
modifier = modifier
.size(size = sizes.size)
.graphicsLayer {
// Translate the indicator according to the slingshot
translationY = offset - indicatorHeight
val scaleFraction = if (scale && !state.isRefreshing) {
val progress = offset / indicatorRefreshTrigger.coerceAtLeast(1f)
// We use LinearOutSlowInEasing to speed up the scale in
LinearOutSlowInEasing
.transform(progress)
.coerceIn(0f, 1f)
} else 1f
scaleX = scaleFraction
scaleY = scaleFraction
},
shape = shape,
color = backgroundColor,
tonalElevation = adjustedElevation,
shadowElevation = adjustedElevation
) {
val painter = remember { CircularProgressPainter() }
painter.arcRadius = sizes.arcRadius
painter.strokeWidth = sizes.strokeWidth
painter.arrowWidth = sizes.arrowWidth
painter.arrowHeight = sizes.arrowHeight
painter.arrowEnabled = arrowEnabled && !state.isRefreshing
painter.color = contentColor
val alpha = if (fade) {
(state.indicatorOffset / indicatorRefreshTrigger).coerceIn(0f, 1f)
} else {
1f
}
painter.alpha = alpha
painter.startTrim = slingshot.startTrim
painter.endTrim = slingshot.endTrim
painter.rotation = slingshot.rotation
painter.arrowScale = slingshot.arrowScale
// This shows either an Image with CircularProgressPainter or a CircularProgressIndicator,
// depending on refresh state
Crossfade(
targetState = state.isRefreshing,
animationSpec = tween(durationMillis = CrossfadeDurationMs),
label = "Cross Fade Refresh"
) { refreshing ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (refreshing) {
val circleSize = (sizes.arcRadius + sizes.strokeWidth) * 2
CircularProgressIndicator(
color = contentColor,
strokeWidth = sizes.strokeWidth,
modifier = Modifier.size(circleSize),
)
} else {
Image(
painter = painter,
contentDescription = "Refreshing"
)
}
}
}
}
}
private const val CrossfadeDurationMs = 100
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/DownloadScreen.kt
================================================
package com.shicheeng.copymanga.ui.screen.download
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.work.WorkInfo
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.ui.screen.compoents.PlainButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadScreen(
viewModel: DownloadScreenViewModel = hiltViewModel(),
onCardClick: (String) -> Unit,
onNavigationClick: () -> Unit,
) {
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val items by viewModel.items.collectAsState()
Scaffold(
topBar = {
LargeTopAppBar(
title = { Text(text = stringResource(id = R.string.download_manga)) },
scrollBehavior = topAppBarScrollBehavior,
navigationIcon = {
PlainButton(
id = R.string.back_to_up,
drawableRes = R.drawable.ic_arrow_back,
onButtonClick = onNavigationClick
)
}
)
},
modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
) { paddingValues ->
if (items != null) {
LazyColumn(contentPadding = paddingValues) {
items?.forEach { (t, u) ->
item {
Text(
text = stringResource(
id = when (t) {
WorkInfo.State.ENQUEUED -> R.string.waiting
WorkInfo.State.RUNNING -> R.string.downloading
WorkInfo.State.SUCCEEDED -> R.string.completed
WorkInfo.State.FAILED -> R.string.failure_download
WorkInfo.State.BLOCKED -> R.string.prerequisites_miss
WorkInfo.State.CANCELLED -> R.string.cancel
}
),
modifier = Modifier.padding(16.dp)
)
}
items(u) {
DownloadItem(
downloadUiDataModel = it,
onCancel = {
viewModel.cancel(it.id)
},
onCardClick = {
onCardClick(it.pathWord)
}
) {
if (it.isPause) {
viewModel.resume(it.id)
} else {
viewModel.pause(it.id)
}
}
}
}
}
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/DownloadScreenComponents.kt
================================================
package com.shicheeng.copymanga.ui.screen.download
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.work.WorkInfo
import coil.compose.AsyncImage
import com.shicheeng.copymanga.R
import com.shicheeng.copymanga.data.downloadmodel.DownloadUiDataModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadItem(
downloadUiDataModel: DownloadUiDataModel,
onCancel: () -> Unit,
onCardClick: () -> Unit,
onClick: () -> Unit,
) {
val animatedProgressState = ProgressIndicatorDefaults.ProgressAnimationSpec
val progressAnimated by remember { mutableFloatStateOf(0f) }
val progressAnimatedAsState by animateFloatAsState(
targetValue = progressAnimated,
animationSpec = animatedProgressState,
label = "progress_animated"
)
OutlinedCard(
onClick = { onCardClick() },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
AsyncImage(
model = downloadUiDataModel.localSavableMangaModel.mangaHistoryDataModel.url,
contentDescription = null,
modifier = Modifier
.height(120.dp)
.clip(MaterialTheme.shapes.medium)
.aspectRatio(2f / 3f),
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, end = 12.dp)
) {
Text(
text = downloadUiDataModel.localSavableMangaModel.mangaHistoryDataModel.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "%.2f%%".format(downloadUiDataModel.percent * 100),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (downloadUiDataModel.workerState == WorkInfo.State.RUNNING) {
Text(
text = downloadUiDataModel.getEtaString()?.toString()
?: stringResource(R.string.downloading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
StateProgressIndication(
isIndeterminate = downloadUiDataModel.isIndeterminate,
progress = progressAnimatedAsState,
modifier = Modifier.padding(top = 8.dp)
)
}
}
when (downloadUiDataModel.workerState) {
WorkInfo.State.RUNNING -> {
StateButton(
isPause = downloadUiDataModel.isPause,
modifier = Modifier
.padding(end = 16.dp)
.align(Alignment.CenterVertically),
onClick = onClick
)
}
WorkInfo.State.ENQUEUED -> {
StateButton(
id = R.drawable.baseline_close_24,
contentDescription = stringResource(R.string.cancel),
onClick = onCancel,
modifier = Modifier
.padding(end = 16.dp)
.align(Alignment.CenterVertically)
)
}
else -> {}
}
}
}
}
@Composable
private fun StateProgressIndication(
modifier: Modifier = Modifier,
isIndeterminate: Boolean,
progress: Float,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.CenterStart
) {
if (!isIndeterminate) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape),
progress = progress
)
} else {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape),
)
}
}
}
================================================
FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/DownloadScreenViewModel.kt
================================================
package com.shicheeng.copymanga.ui.screen.download
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.Data
import androidx.work.WorkInfo
import com.shicheeng.copymanga.data.downloadmodel.DownloadUiDataModel
import com.shicheeng.copymanga.data.local.LocalSavableMangaModel
import com.shicheeng.copymanga.resposity.MangaHistoryRepository
import com.shicheeng.copymanga.server.download.domin.DownloadState
import com.shicheeng.copymanga.server.download.woker.DownloadedWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class DownloadScreenViewModel @Inject constructor(
private val downloaderWorker: DownloadedWorker.Caller,
private val mangaHistoryRepository: MangaHistoryRepository,
) : ViewModel() {
private val mangaCache = LinkedHashMap()
private val mutexCache = Mutex()
@OptIn(ExperimentalCoroutinesApi::class)
private val workerData = downloaderWorker.observerWorker()
.mapLatest {
it.mapToUiDataModel()
}.stateIn(
scope = viewModelScope + Dispatchers.Default,
initialValue = null,
started = SharingStarted.Eagerly
)
val items = workerData.map { dataModels ->
dataModels?.groupBy { it.workerState }
}.stateIn(
scope = viewModelScope,
initialValue = emptyMap(),
started = SharingStarted.Eagerly
)
fun resume(id: UUID) {
val snapshot = workerData.value ?: return
for (work in snapshot) {
if (id == work.id) {
downloaderWorker.resume(id)
}
}
}
fun cancel(id: UUID) = viewModelScope.launch {
val snapshot = workerData.value ?: return@launch
for (work in snapshot) {
if (id == work.id) {
downloaderWorker.cancel(id)
}
}
}
fun pause(id: UUID) {
val snapshot = workerData.value ?: return
for (work in snapshot) {
if (id == work.id) {
downloaderWorker.pause(id)
}
}
}
private suspend fun List