mirror of
https://github.com/mihonapp/mihon.git
synced 2025-08-25 23:51:32 +02:00
Compare commits
44 Commits
c1dd69f775
...
55d12f3cab
Author | SHA1 | Date | |
---|---|---|---|
|
55d12f3cab | ||
|
6ca176b05e | ||
|
7c6fd026a3 | ||
|
86fbd20665 | ||
|
8c8d65d3c7 | ||
|
f1660beafc | ||
|
72222ad86d | ||
|
0265c16eb2 | ||
|
666d6aa117 | ||
|
6965e59a64 | ||
|
e020ae5ed5 | ||
|
05071b4205 | ||
|
da20d00481 | ||
|
8c437ceecf | ||
|
9672ea8b1b | ||
|
ba9cfd867c | ||
|
4b4e468510 | ||
|
e75488f5d9 | ||
|
3838dbcf08 | ||
|
b3ca097e5a | ||
|
70c2443e82 | ||
|
c0a888807b | ||
|
34930920a5 | ||
|
6682b5dd39 | ||
|
3c5f4a317a | ||
|
6a2bfd5e87 | ||
|
ef6cad58fe | ||
|
7e9340aa7f | ||
|
3f2c8e9ef6 | ||
|
a29870c01e | ||
|
583aa430ba | ||
|
0ea0138a73 | ||
|
59bedb33ff | ||
|
ebee275110 | ||
|
015d9b3bd0 | ||
|
f2ccfb0817 | ||
|
1b60c5f0f4 | ||
|
0a91b57f67 | ||
|
bcdf17fe27 | ||
|
a08e03f5cb | ||
|
f087135876 | ||
|
f66f52c244 | ||
|
0d6f426dbd | ||
|
edd7d0522c |
2
.github/renovate.json5
vendored
2
.github/renovate.json5
vendored
@@ -3,7 +3,7 @@
|
||||
"extends": [
|
||||
"config:base"
|
||||
],
|
||||
"schedule": ["every sunday"],
|
||||
"schedule": ["every friday"],
|
||||
"labels": ["Dependencies"],
|
||||
"packageRules": [
|
||||
{
|
||||
|
10
.github/workflows/build_pull_request.yml
vendored
10
.github/workflows/build_pull_request.yml
vendored
@@ -20,22 +20,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v2
|
||||
uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 # v2.1.2
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@5bbc3ba658137598168acb2ab73b21c432dd411b # v4.2.5
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: adopt
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0
|
||||
|
||||
- name: Build app and run unit tests
|
||||
run: ./gradlew detekt assembleStandardRelease testReleaseUnitTest
|
||||
|
12
.github/workflows/build_push.yml
vendored
12
.github/workflows/build_push.yml
vendored
@@ -17,23 +17,23 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v2
|
||||
uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 # v2.1.2
|
||||
|
||||
- name: Setup Android SDK
|
||||
run: |
|
||||
${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager "build-tools;29.0.3"
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: adopt
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0
|
||||
|
||||
- name: Build app and run unit tests
|
||||
run: ./gradlew detekt assembleStandardRelease testReleaseUnitTest
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Sign APK
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/standard/release
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 # v2.0.4
|
||||
with:
|
||||
tag_name: ${{ env.VERSION_TAG }}
|
||||
name: Mihon ${{ env.VERSION_TAG }}
|
||||
|
14
.github/workflows/issue_moderator.yml
vendored
14
.github/workflows/issue_moderator.yml
vendored
@@ -11,28 +11,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Moderate issues
|
||||
uses: tachiyomiorg/issue-moderator-action@v2.6.0
|
||||
uses: keiyoushi/issue-moderator-action@a017be83547db6e107431ce7575f53c1dfa3296a
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
duplicate-label: Duplicate
|
||||
|
||||
auto-close-rules: |
|
||||
[
|
||||
{
|
||||
"type": "body",
|
||||
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
|
||||
"message": "The acknowledgment section was not removed."
|
||||
},
|
||||
{
|
||||
"type": "body",
|
||||
"regex": ".*\\* (Tachiyomi version|Android version|Device): \\?.*",
|
||||
"message": "Requested information in the template was not filled out."
|
||||
},
|
||||
{
|
||||
"type": "both",
|
||||
"regex": "^(?!.*myanimelist.*).*(aniyomi|anime).*$",
|
||||
"ignoreCase": true,
|
||||
"message": "Tachiyomi does not support anime, and has no plans to support anime. In addition Tachiyomi is not affiliated with Aniyomi https://github.com/jmir1/aniyomi"
|
||||
"message": "Mihon does not support anime, and has no plans to support anime. In addition Mihon is not affiliated with Aniyomi https://github.com/jmir1/aniyomi"
|
||||
},
|
||||
{
|
||||
"type": "both",
|
||||
|
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: '2'
|
||||
|
@@ -22,7 +22,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "app.mihon"
|
||||
|
||||
versionCode = 6
|
||||
versionCode = 7
|
||||
versionName = "0.16.4"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
@@ -161,7 +161,6 @@ dependencies {
|
||||
debugImplementation(compose.ui.tooling)
|
||||
implementation(compose.ui.tooling.preview)
|
||||
implementation(compose.ui.util)
|
||||
implementation(compose.accompanist.webview)
|
||||
implementation(compose.accompanist.systemuicontroller)
|
||||
|
||||
implementation(androidx.paging.runtime)
|
||||
@@ -237,6 +236,9 @@ dependencies {
|
||||
implementation(libs.bundles.voyager)
|
||||
implementation(libs.compose.materialmotion)
|
||||
implementation(libs.swipe)
|
||||
implementation(libs.compose.webview)
|
||||
implementation(libs.compose.grid)
|
||||
|
||||
|
||||
// Logging
|
||||
implementation(libs.logcat)
|
||||
@@ -253,6 +255,8 @@ dependencies {
|
||||
// For detecting memory leaks; see https://square.github.io/leakcanary/
|
||||
// debugImplementation(libs.leakcanary.android)
|
||||
implementation(libs.leakcanary.plumber)
|
||||
|
||||
testImplementation(kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
|
13
app/src/main/java/eu/kanade/core/util/SourceUtil.kt
Normal file
13
app/src/main/java/eu/kanade/core/util/SourceUtil.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package eu.kanade.core.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
@Composable
|
||||
fun ifSourcesLoaded(): Boolean {
|
||||
return remember { Injekt.get<SourceManager>().isInitialized }.collectAsState().value
|
||||
}
|
@@ -4,10 +4,7 @@ import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
|
||||
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.download.interactor.DeleteDownload
|
||||
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionRepos
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionSources
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionsByType
|
||||
import eu.kanade.domain.extension.interactor.TrustExtension
|
||||
@@ -26,6 +23,16 @@ import eu.kanade.domain.track.interactor.AddTracks
|
||||
import eu.kanade.domain.track.interactor.RefreshTracks
|
||||
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
||||
import eu.kanade.domain.track.interactor.TrackChapter
|
||||
import mihon.data.repository.ExtensionRepoRepositoryImpl
|
||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
||||
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import mihon.domain.extensionrepo.service.ExtensionRepoService
|
||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||
import tachiyomi.data.category.CategoryRepositoryImpl
|
||||
import tachiyomi.data.chapter.ChapterRepositoryImpl
|
||||
import tachiyomi.data.history.HistoryRepositoryImpl
|
||||
@@ -111,6 +118,7 @@ class DomainModule : InjektModule {
|
||||
addFactory { GetMangaByUrlAndSourceId(get()) }
|
||||
addFactory { GetManga(get()) }
|
||||
addFactory { GetNextChapters(get(), get(), get()) }
|
||||
addFactory { GetUpcomingManga(get()) }
|
||||
addFactory { ResetViewerFlags(get()) }
|
||||
addFactory { SetMangaChapterFlags(get()) }
|
||||
addFactory { FetchInterval(get()) }
|
||||
@@ -173,8 +181,13 @@ class DomainModule : InjektModule {
|
||||
addFactory { ToggleSourcePin(get()) }
|
||||
addFactory { TrustExtension(get()) }
|
||||
|
||||
addFactory { CreateExtensionRepo(get()) }
|
||||
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
|
||||
addFactory { ExtensionRepoService(get(), get()) }
|
||||
addFactory { GetExtensionRepo(get()) }
|
||||
addFactory { GetExtensionRepoCount(get()) }
|
||||
addFactory { CreateExtensionRepo(get(), get()) }
|
||||
addFactory { DeleteExtensionRepo(get()) }
|
||||
addFactory { GetExtensionRepos(get()) }
|
||||
addFactory { ReplaceExtensionRepo(get()) }
|
||||
addFactory { UpdateExtensionRepo(get(), get()) }
|
||||
}
|
||||
}
|
||||
|
@@ -28,4 +28,6 @@ class BasePreferences(
|
||||
SHIZUKU(MR.strings.ext_installer_shizuku, false),
|
||||
PRIVATE(MR.strings.ext_installer_private, false),
|
||||
}
|
||||
|
||||
fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "")
|
||||
}
|
||||
|
@@ -1,25 +0,0 @@
|
||||
package eu.kanade.domain.extension.interactor
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import tachiyomi.core.common.preference.plusAssign
|
||||
|
||||
class CreateExtensionRepo(private val preferences: SourcePreferences) {
|
||||
|
||||
fun await(name: String): Result {
|
||||
// Do not allow invalid formats
|
||||
if (!name.matches(repoRegex)) {
|
||||
return Result.InvalidUrl
|
||||
}
|
||||
|
||||
preferences.extensionRepos() += name.removeSuffix("/index.min.json")
|
||||
|
||||
return Result.Success
|
||||
}
|
||||
|
||||
sealed interface Result {
|
||||
data object InvalidUrl : Result
|
||||
data object Success : Result
|
||||
}
|
||||
}
|
||||
|
||||
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
|
@@ -1,11 +0,0 @@
|
||||
package eu.kanade.domain.extension.interactor
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import tachiyomi.core.common.preference.minusAssign
|
||||
|
||||
class DeleteExtensionRepo(private val preferences: SourcePreferences) {
|
||||
|
||||
fun await(repo: String) {
|
||||
preferences.extensionRepos() -= repo
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
package eu.kanade.domain.extension.interactor
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class GetExtensionRepos(private val preferences: SourcePreferences) {
|
||||
|
||||
fun subscribe(): Flow<Set<String>> {
|
||||
return preferences.extensionRepos().changes()
|
||||
}
|
||||
}
|
@@ -1,16 +1,33 @@
|
||||
package eu.kanade.presentation.manga
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.SwapVert
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.kanade.presentation.components.AdaptiveSheet
|
||||
import eu.kanade.presentation.components.TabbedDialogPaddings
|
||||
import eu.kanade.presentation.more.settings.LocalPreferenceMinHeight
|
||||
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
@@ -18,42 +35,92 @@ fun DuplicateMangaDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onOpenManga: () -> Unit,
|
||||
onMigrate: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AlertDialog(
|
||||
val minHeight = LocalPreferenceMinHeight.current
|
||||
|
||||
AdaptiveSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.are_you_sure))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(MR.strings.confirm_add_duplicate_manga))
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
vertical = TabbedDialogPaddings.Vertical,
|
||||
horizontal = TabbedDialogPaddings.Horizontal,
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(TitlePadding),
|
||||
text = stringResource(MR.strings.are_you_sure),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(MR.strings.confirm_add_duplicate_manga),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(PaddingSize))
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_show_manga),
|
||||
icon = Icons.Outlined.Book,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onOpenManga()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_migrate_duplicate),
|
||||
icon = Icons.Outlined.SwapVert,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onMigrate()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_add_anyway),
|
||||
icon = Icons.Outlined.Add,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onConfirm()
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.sizeIn(minHeight = minHeight)
|
||||
.clickable { onDismissRequest.invoke() }
|
||||
.padding(ButtonPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onOpenManga()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_show_manga))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onConfirm()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_add))
|
||||
OutlinedButton(onClick = onDismissRequest, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp),
|
||||
text = stringResource(MR.strings.action_cancel),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PaddingSize = 16.dp
|
||||
|
||||
private val ButtonPadding = PaddingValues(top = 16.dp, bottom = 16.dp)
|
||||
private val TitlePadding = PaddingValues(bottom = 16.dp, top = 8.dp)
|
||||
|
@@ -6,6 +6,8 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -123,6 +125,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
getDataGroup(),
|
||||
getNetworkGroup(networkPreferences = networkPreferences),
|
||||
getLibraryGroup(),
|
||||
getReaderGroup(basePreferences = basePreferences),
|
||||
getExtensionsGroup(basePreferences = basePreferences),
|
||||
)
|
||||
}
|
||||
@@ -313,6 +316,34 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getReaderGroup(
|
||||
basePreferences: BasePreferences,
|
||||
): Preference.PreferenceGroup {
|
||||
val context = LocalContext.current
|
||||
val chooseColorProfile = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
basePreferences.displayProfile().set(uri.toString())
|
||||
}
|
||||
}
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_category_reader),
|
||||
preferenceItems = persistentListOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_display_profile),
|
||||
subtitle = basePreferences.displayProfile().get(),
|
||||
onClick = {
|
||||
chooseColorProfile.launch(arrayOf("*/*"))
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getExtensionsGroup(
|
||||
basePreferences: BasePreferences,
|
||||
|
@@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -13,11 +14,11 @@ import eu.kanade.presentation.more.settings.Preference
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
@@ -33,7 +34,9 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
||||
val reposCount by sourcePreferences.extensionRepos().collectAsState()
|
||||
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
|
||||
|
||||
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
|
||||
|
||||
return listOf(
|
||||
Preference.PreferenceGroup(
|
||||
@@ -45,7 +48,7 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.label_extension_repos),
|
||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
|
||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
|
||||
onClick = {
|
||||
navigator.push(ExtensionReposScreen())
|
||||
},
|
||||
|
@@ -29,6 +29,7 @@ object SettingsReaderScreen : SearchableSettings {
|
||||
@Composable
|
||||
override fun getPreferences(): List<Preference> {
|
||||
val readerPref = remember { Injekt.get<ReaderPreferences>() }
|
||||
|
||||
return listOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
pref = readerPref.defaultReadingMode(),
|
||||
@@ -56,11 +57,6 @@ object SettingsReaderScreen : SearchableSettings {
|
||||
title = stringResource(MR.strings.pref_show_navigation_mode),
|
||||
subtitle = stringResource(MR.strings.pref_show_navigation_mode_summary),
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
pref = readerPref.trueColor(),
|
||||
title = stringResource(MR.strings.pref_true_color),
|
||||
subtitle = stringResource(MR.strings.pref_true_color_summary),
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
pref = readerPref.pageTransitions(),
|
||||
title = stringResource(MR.strings.pref_page_transitions),
|
||||
|
@@ -32,12 +32,13 @@ class OpenSourceLicensesScreen : Screen() {
|
||||
.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
onLibraryClick = {
|
||||
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen(
|
||||
name = it.library.name,
|
||||
website = it.library.website,
|
||||
license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
|
||||
navigator.push(
|
||||
OpenSourceLibraryLicenseScreen(
|
||||
name = it.name,
|
||||
website = it.website,
|
||||
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
|
||||
)
|
||||
)
|
||||
navigator.push(libraryLicenseScreen)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@@ -8,11 +8,14 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog
|
||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
|
||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
|
||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
|
||||
@@ -42,17 +45,19 @@ class ExtensionReposScreen(
|
||||
ExtensionReposScreen(
|
||||
state = successState,
|
||||
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
|
||||
onOpenWebsite = { context.openInBrowser(it.website) },
|
||||
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
|
||||
onClickRefresh = { screenModel.refreshRepos() },
|
||||
navigateUp = navigator::pop,
|
||||
)
|
||||
|
||||
when (val dialog = successState.dialog) {
|
||||
null -> {}
|
||||
RepoDialog.Create -> {
|
||||
is RepoDialog.Create -> {
|
||||
ExtensionRepoCreateDialog(
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onCreate = { screenModel.createRepo(it) },
|
||||
repos = successState.repos,
|
||||
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
|
||||
)
|
||||
}
|
||||
is RepoDialog.Delete -> {
|
||||
@@ -62,6 +67,15 @@ class ExtensionReposScreen(
|
||||
repo = dialog.repo,
|
||||
)
|
||||
}
|
||||
|
||||
is RepoDialog.Conflict -> {
|
||||
ExtensionRepoConflictDialog(
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
|
||||
oldRepo = dialog.oldRepo,
|
||||
newRepo = dialog.newRepo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
@@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
|
||||
import eu.kanade.domain.extension.interactor.GetExtensionRepos
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class ExtensionReposScreenModel(
|
||||
private val getExtensionRepos: GetExtensionRepos = Injekt.get(),
|
||||
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
|
||||
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
|
||||
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
|
||||
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
|
||||
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
|
||||
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
|
||||
|
||||
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
|
||||
@@ -29,7 +34,7 @@ class ExtensionReposScreenModel(
|
||||
|
||||
init {
|
||||
screenModelScope.launchIO {
|
||||
getExtensionRepos.subscribe()
|
||||
getExtensionRepo.subscribeAll()
|
||||
.collectLatest { repos ->
|
||||
mutableState.update {
|
||||
RepoScreenState.Success(
|
||||
@@ -43,25 +48,51 @@ class ExtensionReposScreenModel(
|
||||
/**
|
||||
* Creates and adds a new repo to the database.
|
||||
*
|
||||
* @param name The name of the repo to create.
|
||||
* @param baseUrl The baseUrl of the repo to create.
|
||||
*/
|
||||
fun createRepo(name: String) {
|
||||
fun createRepo(baseUrl: String) {
|
||||
screenModelScope.launchIO {
|
||||
when (createExtensionRepo.await(name)) {
|
||||
is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
|
||||
when (val result = createExtensionRepo.await(baseUrl)) {
|
||||
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
|
||||
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
|
||||
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
|
||||
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given repo from the database.
|
||||
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
|
||||
*
|
||||
* @param repo The repo to delete.
|
||||
* @param newRepo The repo to insert
|
||||
*/
|
||||
fun deleteRepo(repo: String) {
|
||||
fun replaceRepo(newRepo: ExtensionRepo) {
|
||||
screenModelScope.launchIO {
|
||||
deleteExtensionRepo.await(repo)
|
||||
replaceExtensionRepo.await(newRepo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes information for each repository.
|
||||
*/
|
||||
fun refreshRepos() {
|
||||
val status = state.value
|
||||
|
||||
if (status is RepoScreenState.Success) {
|
||||
screenModelScope.launchIO {
|
||||
updateExtensionRepo.awaitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given repo from the database
|
||||
*/
|
||||
fun deleteRepo(baseUrl: String) {
|
||||
screenModelScope.launchIO {
|
||||
deleteExtensionRepo.await(baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +118,13 @@ class ExtensionReposScreenModel(
|
||||
sealed class RepoEvent {
|
||||
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
|
||||
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
|
||||
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
|
||||
}
|
||||
|
||||
sealed class RepoDialog {
|
||||
data object Create : RepoDialog()
|
||||
data class Delete(val repo: String) : RepoDialog()
|
||||
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
|
||||
}
|
||||
|
||||
sealed class RepoScreenState {
|
||||
@@ -101,7 +134,8 @@ sealed class RepoScreenState {
|
||||
|
||||
@Immutable
|
||||
data class Success(
|
||||
val repos: ImmutableSet<String>,
|
||||
val repos: ImmutableSet<ExtensionRepo>,
|
||||
val oldRepos: ImmutableSet<String>? = null,
|
||||
val dialog: RepoDialog? = null,
|
||||
) : RepoScreenState() {
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Label
|
||||
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun ExtensionReposContent(
|
||||
repos: ImmutableSet<String>,
|
||||
repos: ImmutableSet<ExtensionRepo>,
|
||||
lazyListState: LazyListState,
|
||||
paddingValues: PaddingValues,
|
||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||
onClickDelete: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -45,7 +48,8 @@ fun ExtensionReposContent(
|
||||
ExtensionRepoListItem(
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
repo = it,
|
||||
onDelete = { onClickDelete(it) },
|
||||
onOpenWebsite = { onOpenWebsite(it) },
|
||||
onDelete = { onClickDelete(it.baseUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +58,8 @@ fun ExtensionReposContent(
|
||||
|
||||
@Composable
|
||||
private fun ExtensionRepoListItem(
|
||||
repo: String,
|
||||
repo: ExtensionRepo,
|
||||
onOpenWebsite: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -74,16 +79,27 @@ private fun ExtensionRepoListItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
|
||||
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
|
||||
Text(
|
||||
text = repo.name,
|
||||
modifier = Modifier.padding(start = MaterialTheme.padding.medium),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = onOpenWebsite) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
|
||||
contentDescription = stringResource(MR.strings.action_open_in_browser),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val url = "$repo/index.min.json"
|
||||
val url = "${repo.baseUrl}/index.min.json"
|
||||
context.copyToClipboard(url, url)
|
||||
},
|
||||
) {
|
||||
|
@@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.coroutines.delay
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||
fun ExtensionRepoCreateDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
repos: ImmutableSet<String>,
|
||||
repoUrls: ImmutableSet<String>,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val nameAlreadyExists = remember(name) { repos.contains(name) }
|
||||
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
@@ -115,3 +116,36 @@ fun ExtensionRepoDeleteDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtensionRepoConflictDialog(
|
||||
oldRepo: ExtensionRepo,
|
||||
newRepo: ExtensionRepo,
|
||||
onDismissRequest: () -> Unit,
|
||||
onMigrate: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onMigrate()
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_replace_repo))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.action_replace_repo_title))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@@ -5,12 +5,17 @@ package eu.kanade.presentation.more.settings.screen.browse.components
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
@@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus
|
||||
fun ExtensionReposScreen(
|
||||
state: RepoScreenState.Success,
|
||||
onClickCreate: () -> Unit,
|
||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||
onClickDelete: (String) -> Unit,
|
||||
onClickRefresh: () -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
@@ -33,6 +40,14 @@ fun ExtensionReposScreen(
|
||||
navigateUp = navigateUp,
|
||||
title = stringResource(MR.strings.label_extension_repos),
|
||||
scrollBehavior = scrollBehavior,
|
||||
actions = {
|
||||
IconButton(onClick = onClickRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Refresh,
|
||||
contentDescription = stringResource(resource = MR.strings.action_webview_refresh),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -55,6 +70,7 @@ fun ExtensionReposScreen(
|
||||
lazyListState = lazyListState,
|
||||
paddingValues = paddingValues + topSmallPaddingValues +
|
||||
PaddingValues(horizontal = MaterialTheme.padding.medium),
|
||||
onOpenWebsite = onOpenWebsite,
|
||||
onClickDelete = onClickDelete,
|
||||
)
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.FlipToBack
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material.icons.outlined.SelectAll
|
||||
@@ -47,6 +48,7 @@ fun UpdateScreen(
|
||||
onClickCover: (UpdatesItem) -> Unit,
|
||||
onSelectAll: (Boolean) -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
onCalendarClicked: () -> Unit,
|
||||
onUpdateLibrary: () -> Boolean,
|
||||
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
|
||||
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
|
||||
@@ -60,6 +62,7 @@ fun UpdateScreen(
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
UpdatesAppBar(
|
||||
onCalendarClicked = { onCalendarClicked() },
|
||||
onUpdateLibrary = { onUpdateLibrary() },
|
||||
actionModeCounter = state.selected.size,
|
||||
onSelectAll = { onSelectAll(true) },
|
||||
@@ -126,6 +129,7 @@ fun UpdateScreen(
|
||||
|
||||
@Composable
|
||||
private fun UpdatesAppBar(
|
||||
onCalendarClicked: () -> Unit,
|
||||
onUpdateLibrary: () -> Unit,
|
||||
// For action mode
|
||||
actionModeCounter: Int,
|
||||
@@ -141,6 +145,11 @@ private fun UpdatesAppBar(
|
||||
actions = {
|
||||
AppBarActions(
|
||||
persistentListOf(
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.action_view_upcoming),
|
||||
icon = Icons.Outlined.CalendarMonth,
|
||||
onClick = onCalendarClicked,
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.action_update_library),
|
||||
icon = Icons.Outlined.Refresh,
|
||||
|
@@ -28,11 +28,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.web.AccompanistWebViewClient
|
||||
import com.google.accompanist.web.LoadingState
|
||||
import com.google.accompanist.web.WebView
|
||||
import com.google.accompanist.web.rememberWebViewNavigator
|
||||
import com.google.accompanist.web.rememberWebViewState
|
||||
import com.kevinnzou.web.AccompanistWebViewClient
|
||||
import com.kevinnzou.web.LoadingState
|
||||
import com.kevinnzou.web.WebView
|
||||
import com.kevinnzou.web.rememberWebViewNavigator
|
||||
import com.kevinnzou.web.rememberWebViewState
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.WarningBanner
|
||||
|
@@ -50,8 +50,12 @@ import kotlinx.coroutines.flow.onEach
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
import logcat.LogcatLogger
|
||||
import mihon.core.migration.Migrator
|
||||
import mihon.core.migration.migrations.migrations
|
||||
import org.conscrypt.Conscrypt
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.widget.WidgetManager
|
||||
@@ -131,6 +135,23 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
||||
if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) {
|
||||
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
||||
}
|
||||
|
||||
initializeMigrator()
|
||||
}
|
||||
|
||||
private fun initializeMigrator() {
|
||||
val preferenceStore = Injekt.get<PreferenceStore>()
|
||||
val preference = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
|
||||
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
|
||||
Migrator.initialize(
|
||||
old = preference.get(),
|
||||
new = BuildConfig.VERSION_CODE,
|
||||
migrations = migrations,
|
||||
onMigrationComplete = {
|
||||
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
|
||||
preference.set(BuildConfig.VERSION_CODE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context): ImageLoader {
|
||||
|
@@ -1,38 +0,0 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
|
||||
object Migrations {
|
||||
|
||||
/**
|
||||
* Performs a migration when the application is updated.
|
||||
*
|
||||
* @return true if a migration is performed, false otherwise.
|
||||
*/
|
||||
@Suppress("SameReturnValue")
|
||||
fun upgrade(
|
||||
context: Context,
|
||||
preferenceStore: PreferenceStore,
|
||||
): Boolean {
|
||||
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
|
||||
val oldVersion = lastVersionCode.get()
|
||||
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||
lastVersionCode.set(BuildConfig.VERSION_CODE)
|
||||
|
||||
// Always set up background tasks to ensure they're running
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
BackupCreateJob.setupTask(context)
|
||||
|
||||
// Fresh install
|
||||
if (oldVersion == 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
@@ -7,7 +7,6 @@ import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.allowRgb565
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
@@ -24,7 +23,7 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
|
||||
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
val bitmap = decoder.decode(rgb565 = options.allowRgb565)
|
||||
val bitmap = decoder.decode()
|
||||
decoder.recycle()
|
||||
|
||||
check(bitmap != null) { "Failed to decode image" }
|
||||
|
@@ -18,6 +18,7 @@ import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -311,18 +312,12 @@ class DownloadCache(
|
||||
}
|
||||
|
||||
// Try to wait until extensions and sources have loaded
|
||||
var sources = getSources()
|
||||
if (sources.isEmpty()) {
|
||||
withTimeoutOrNull(30.seconds) {
|
||||
while (!extensionManager.isInitialized) {
|
||||
delay(2.seconds)
|
||||
}
|
||||
var sources = emptyList<Source>()
|
||||
withTimeoutOrNull(30.seconds) {
|
||||
extensionManager.isInitialized.first { it }
|
||||
sourceManager.isInitialized.first { it }
|
||||
|
||||
while (extensionManager.availableExtensionsFlow.value.isNotEmpty() && sources.isEmpty()) {
|
||||
delay(2.seconds)
|
||||
sources = getSources()
|
||||
}
|
||||
}
|
||||
sources = getSources()
|
||||
}
|
||||
|
||||
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
||||
|
@@ -79,7 +79,7 @@ class ImageSaver(
|
||||
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
|
||||
MediaStore.Images.Media.DISPLAY_NAME to image.name,
|
||||
MediaStore.Images.Media.MIME_TYPE to type.mime,
|
||||
MediaStore.Images.Media.DATE_MODIFIED to Instant.now().toEpochMilli(),
|
||||
MediaStore.Images.Media.DATE_MODIFIED to Instant.now().epochSecond,
|
||||
)
|
||||
|
||||
val picture = findUriOrDefault(relativePath, filename) {
|
||||
|
@@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import logcat.LogPriority
|
||||
@@ -41,8 +42,8 @@ class ExtensionManager(
|
||||
private val trustExtension: TrustExtension = Injekt.get(),
|
||||
) {
|
||||
|
||||
var isInitialized = false
|
||||
private set
|
||||
private val _isInitialized = MutableStateFlow(false)
|
||||
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||
|
||||
/**
|
||||
* API where all the available extensions can be found.
|
||||
@@ -108,7 +109,7 @@ class ExtensionManager(
|
||||
.filterIsInstance<LoadResult.Untrusted>()
|
||||
.map { it.extension }
|
||||
|
||||
isInitialized = true
|
||||
_isInitialized.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.extension.api
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
@@ -10,9 +9,14 @@ import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
@@ -25,7 +29,8 @@ internal class ExtensionApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferenceStore: PreferenceStore by injectLazy()
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
||||
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
|
||||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
@@ -35,11 +40,15 @@ internal class ExtensionApi {
|
||||
|
||||
suspend fun findExtensions(): List<Extension.Available> {
|
||||
return withIOContext {
|
||||
sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
|
||||
getExtensionRepo.getAll()
|
||||
.map { async { getExtensions(it) } }
|
||||
.awaitAll()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getExtensions(repoBaseUrl: String): List<Extension.Available> {
|
||||
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
|
||||
val repoBaseUrl = extRepo.baseUrl
|
||||
return try {
|
||||
val response = networkService.client
|
||||
.newCall(GET("$repoBaseUrl/index.min.json"))
|
||||
@@ -67,6 +76,9 @@ internal class ExtensionApi {
|
||||
return null
|
||||
}
|
||||
|
||||
// Update extension repo details
|
||||
updateExtensionRepo.awaitAll()
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
extensionManager.availableExtensionsFlow.value
|
||||
} else {
|
||||
|
@@ -9,6 +9,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -28,6 +30,9 @@ class AndroidSourceManager(
|
||||
private val sourceRepository: StubSourceRepository,
|
||||
) : SourceManager {
|
||||
|
||||
private val _isInitialized = MutableStateFlow(false)
|
||||
override val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||
|
||||
private val downloadManager: DownloadManager by injectLazy()
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
@@ -60,6 +65,7 @@ class AndroidSourceManager(
|
||||
}
|
||||
}
|
||||
sourcesMapFlow.value = mutableMap
|
||||
_isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -31,6 +31,7 @@ import androidx.preference.forEach
|
||||
import androidx.preference.getOnBindEditTextListener
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.core.util.ifSourcesLoaded
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.R
|
||||
@@ -40,6 +41,7 @@ import eu.kanade.tachiyomi.source.sourcePreferences
|
||||
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
@@ -47,6 +49,11 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
if (!ifSourcesLoaded()) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
|
@@ -18,6 +18,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.core.util.ifSourcesLoaded
|
||||
import eu.kanade.presentation.browse.BrowseSourceContent
|
||||
import eu.kanade.presentation.components.SearchToolbar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
@@ -34,6 +35,7 @@ import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import tachiyomi.source.local.LocalSource
|
||||
|
||||
data class SourceSearchScreen(
|
||||
@@ -44,6 +46,11 @@ data class SourceSearchScreen(
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
if (!ifSourcesLoaded()) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -76,7 +83,7 @@ data class SourceSearchScreen(
|
||||
) { paddingValues ->
|
||||
val pagingFlow by screenModel.mangaPagerFlowFlow.collectAsState()
|
||||
val openMigrateDialog: (Manga) -> Unit = {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(it))
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(newManga = it, oldManga = oldManga))
|
||||
}
|
||||
BrowseSourceContent(
|
||||
source = screenModel.source,
|
||||
|
@@ -35,6 +35,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.core.util.ifSourcesLoaded
|
||||
import eu.kanade.presentation.browse.BrowseSourceContent
|
||||
import eu.kanade.presentation.browse.MissingSourceScreen
|
||||
import eu.kanade.presentation.browse.components.BrowseSourceToolbar
|
||||
@@ -46,6 +47,8 @@ import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
@@ -60,6 +63,7 @@ import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import tachiyomi.source.local.LocalSource
|
||||
|
||||
data class BrowseSourceScreen(
|
||||
@@ -73,6 +77,11 @@ data class BrowseSourceScreen(
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
if (!ifSourcesLoaded()) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId, listingQuery) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
@@ -245,6 +254,22 @@ data class BrowseSourceScreen(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.addFavorite(dialog.manga) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(dialog.manga, dialog.duplicate))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is BrowseSourceScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialog.oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = MigrateDialogScreenModel(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
|
||||
onPopScreen = {
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
|
||||
@@ -267,7 +292,6 @@ data class BrowseSourceScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.Migrate -> {}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
|
@@ -345,7 +345,7 @@ class BrowseSourceScreenModel(
|
||||
val manga: Manga,
|
||||
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
|
||||
) : Dialog
|
||||
data class Migrate(val newManga: Manga) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
@@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.core.util.ifSourcesLoaded
|
||||
import eu.kanade.presentation.browse.GlobalSearchScreen
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
|
||||
@@ -23,6 +24,11 @@ class GlobalSearchScreen(
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
if (!ifSourcesLoaded()) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel {
|
||||
|
@@ -50,8 +50,6 @@ import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.presentation.components.AppStateBanners
|
||||
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
|
||||
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
|
||||
@@ -61,7 +59,6 @@ import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
|
||||
import eu.kanade.presentation.util.AssistContentScreen
|
||||
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.Migrations
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
@@ -89,6 +86,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.core.migration.Migrator
|
||||
import tachiyomi.core.common.Constants
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -98,16 +96,12 @@ import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import androidx.compose.ui.graphics.Color.Companion as ComposeColor
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
private val libraryPreferences: LibraryPreferences by injectLazy()
|
||||
private val uiPreferences: UiPreferences by injectLazy()
|
||||
private val preferences: BasePreferences by injectLazy()
|
||||
|
||||
private val downloadCache: DownloadCache by injectLazy()
|
||||
@@ -130,14 +124,7 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val didMigration = if (isLaunch) {
|
||||
Migrations.upgrade(
|
||||
context = applicationContext,
|
||||
preferenceStore = Injekt.get(),
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val didMigration = Migrator.awaitAndRelease()
|
||||
|
||||
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
|
||||
if (!isTaskRoot) {
|
||||
|
@@ -22,6 +22,7 @@ import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.core.util.ifSourcesLoaded
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.presentation.category.components.ChangeCategoryDialog
|
||||
@@ -40,6 +41,8 @@ import eu.kanade.presentation.util.isTabletUi
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.isLocalOrStub
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
|
||||
@@ -73,6 +76,11 @@ class MangaScreen(
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
if (!ifSourcesLoaded()) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
@@ -185,11 +193,28 @@ class MangaScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
is MangaScreenModel.Dialog.DuplicateManga -> DuplicateMangaDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
)
|
||||
|
||||
is MangaScreenModel.Dialog.DuplicateManga -> {
|
||||
DuplicateMangaDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
screenModel.showMigrateDialog(dialog.duplicate)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is MangaScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialog.oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = MigrateDialogScreenModel(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
|
||||
onPopScreen = { navigator.replace(MangaScreen(dialog.newManga.id)) },
|
||||
)
|
||||
}
|
||||
MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
manga = successState.manga,
|
||||
|
@@ -1003,6 +1003,7 @@ class MangaScreenModel(
|
||||
) : Dialog
|
||||
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
|
||||
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
data class SetFetchInterval(val manga: Manga) : Dialog
|
||||
data object SettingsSheet : Dialog
|
||||
data object TrackSheet : Dialog
|
||||
@@ -1029,6 +1030,11 @@ class MangaScreenModel(
|
||||
updateSuccessState { it.copy(dialog = Dialog.FullCover) }
|
||||
}
|
||||
|
||||
fun showMigrateDialog(duplicate: Manga) {
|
||||
val manga = successState?.manga ?: return
|
||||
updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) }
|
||||
}
|
||||
|
||||
fun setExcludedScanlators(excludedScanlators: Set<String>) {
|
||||
screenModelScope.launchIO {
|
||||
setExcludedScanlators.await(mangaId, excludedScanlators)
|
||||
|
@@ -460,6 +460,7 @@ private data class TrackDateSelectorScreen(
|
||||
private val start: Boolean,
|
||||
) : Screen() {
|
||||
|
||||
@Transient
|
||||
private val selectableDates = object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
val dateToCheck = Instant.ofEpochMilli(utcTimeMillis)
|
||||
|
@@ -5,7 +5,6 @@ import android.app.Activity
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
@@ -39,7 +38,9 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.elevation.SurfaceColors
|
||||
import com.google.android.material.transition.platform.MaterialContainerTransform
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.chrisbanes.insetter.applyInsetter
|
||||
import eu.kanade.core.util.ifSourcesLoaded
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.presentation.reader.DisplayRefreshHost
|
||||
import eu.kanade.presentation.reader.OrientationSelectDialog
|
||||
@@ -93,6 +94,7 @@ import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class ReaderActivity : BaseActivity() {
|
||||
|
||||
@@ -344,6 +346,10 @@ class ReaderActivity : BaseActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!ifSourcesLoaded()) {
|
||||
return@setComposeContent
|
||||
}
|
||||
|
||||
val isHttpSource = viewModel.getSource() is HttpSource
|
||||
val isFullscreen by readerPreferences.fullscreen().collectAsState()
|
||||
val flashOnPageChange by readerPreferences.flashOnPageChange().collectAsState()
|
||||
@@ -814,8 +820,8 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.trueColor().changes()
|
||||
.onEach(::setTrueColor)
|
||||
preferences.displayProfile().changes()
|
||||
.onEach { setDisplayProfile(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.cutoutShort().changes()
|
||||
@@ -854,13 +860,19 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the 32-bit color mode according to [enabled].
|
||||
* Sets the display profile to [path].
|
||||
*/
|
||||
private fun setTrueColor(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888)
|
||||
} else {
|
||||
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.RGB_565)
|
||||
private fun setDisplayProfile(path: String) {
|
||||
val file = UniFile.fromUri(baseContext, path.toUri())
|
||||
if (file != null && file.exists()) {
|
||||
val inputStream = file.openInputStream()
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
inputStream.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
SubsamplingScaleImageView.setDisplayProfile(outputStream.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -264,6 +265,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
try {
|
||||
val manga = getManga.await(mangaId)
|
||||
if (manga != null) {
|
||||
sourceManager.isInitialized.first { it }
|
||||
mutableState.update { it.copy(manga = manga) }
|
||||
if (chapterId == -1L) chapterId = initialChapterId
|
||||
|
||||
|
@@ -23,9 +23,6 @@ class ReaderPreferences(
|
||||
|
||||
fun showReadingMode() = preferenceStore.getBoolean("pref_show_reading_mode", true)
|
||||
|
||||
// TODO: default this to true if reader long strip ever goes stable
|
||||
fun trueColor() = preferenceStore.getBoolean("pref_true_color_key", false)
|
||||
|
||||
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)
|
||||
|
||||
fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true)
|
||||
|
@@ -349,8 +349,9 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.target(
|
||||
onSuccess = { result ->
|
||||
setImageDrawable(result.asDrawable(context.resources))
|
||||
(result as? Animatable)?.start()
|
||||
val drawable = result.asDrawable(context.resources)
|
||||
setImageDrawable(drawable)
|
||||
(drawable as? Animatable)?.start()
|
||||
isVisible = true
|
||||
this@ReaderPageImageView.onImageLoaded()
|
||||
},
|
||||
|
@@ -22,7 +22,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc
|
||||
var doubleTapAnimDuration = 500
|
||||
var volumeKeysEnabled = false
|
||||
var volumeKeysInverted = false
|
||||
var trueColor = false
|
||||
var alwaysShowChapterTransition = true
|
||||
var navigationMode = 0
|
||||
protected set
|
||||
@@ -62,9 +61,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc
|
||||
readerPreferences.readWithVolumeKeysInverted()
|
||||
.register({ volumeKeysInverted = it })
|
||||
|
||||
readerPreferences.trueColor()
|
||||
.register({ trueColor = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.alwaysShowChapterTransition()
|
||||
.register({ alwaysShowChapterTransition = it })
|
||||
|
||||
|
@@ -36,20 +36,21 @@ class WebtoonLayoutManager(context: Context) : LinearLayoutManager(context) {
|
||||
*/
|
||||
fun findLastEndVisibleItemPosition(): Int {
|
||||
ensureLayoutState()
|
||||
@ViewBoundsCheck.ViewBounds val preferredBoundsFlag =
|
||||
(ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE)
|
||||
|
||||
val fromIndex = childCount - 1
|
||||
val toIndex = -1
|
||||
|
||||
val child = if (mOrientation == HORIZONTAL) {
|
||||
val callback = if (mOrientation == HORIZONTAL) {
|
||||
mHorizontalBoundCheck
|
||||
.findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
|
||||
} else {
|
||||
mVerticalBoundCheck
|
||||
.findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
|
||||
}.mCallback
|
||||
val start = callback.parentStart
|
||||
val end = callback.parentEnd
|
||||
for (i in childCount - 1 downTo 0) {
|
||||
val child = getChildAt(i)!!
|
||||
val childStart = callback.getChildStart(child)
|
||||
val childEnd = callback.getChildEnd(child)
|
||||
if (childEnd <= end || childStart < start) {
|
||||
return getPosition(child)
|
||||
}
|
||||
}
|
||||
|
||||
return if (child == null) NO_POSITION else getPosition(child)
|
||||
return NO_POSITION
|
||||
}
|
||||
}
|
||||
|
@@ -118,6 +118,7 @@ class WebtoonPageHolder(
|
||||
removeErrorLayout()
|
||||
frame.recycle()
|
||||
progressIndicator.setProgress(0)
|
||||
progressContainer.isVisible = true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import mihon.feature.upcoming.UpcomingScreen
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
@@ -72,6 +73,7 @@ object UpdatesTab : Tab {
|
||||
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onCalendarClicked = { navigator.push(UpcomingScreen()) },
|
||||
)
|
||||
|
||||
val onDismissDialog = { screenModel.setDialog(null) }
|
||||
|
@@ -39,6 +39,10 @@ fun Long.toLocalDate(): LocalDate {
|
||||
return LocalDate.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate {
|
||||
return LocalDate.ofInstant(this, zoneId)
|
||||
}
|
||||
|
||||
fun LocalDate.toRelativeString(
|
||||
context: Context,
|
||||
relative: Boolean = true,
|
||||
@@ -56,14 +60,12 @@ fun LocalDate.toRelativeString(
|
||||
difference.toInt().absoluteValue,
|
||||
difference.toInt().absoluteValue,
|
||||
)
|
||||
|
||||
difference < 1 -> context.stringResource(MR.strings.relative_time_today)
|
||||
difference < 7 -> context.pluralStringResource(
|
||||
MR.plurals.relative_time,
|
||||
difference.toInt(),
|
||||
difference.toInt(),
|
||||
)
|
||||
|
||||
else -> dateFormat.format(this)
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,23 @@
|
||||
package mihon.core.designsystem.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun isMediumWidthWindow(): Boolean {
|
||||
val configuration = LocalConfiguration.current
|
||||
return configuration.screenWidthDp > MediumWidthWindowSize.value
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun isExpandedWidthWindow(): Boolean {
|
||||
val configuration = LocalConfiguration.current
|
||||
return configuration.screenWidthDp > ExpandedWidthWindowSize.value
|
||||
}
|
||||
|
||||
val MediumWidthWindowSize = 600.dp
|
||||
val ExpandedWidthWindowSize = 840.dp
|
22
app/src/main/java/mihon/core/migration/Migration.kt
Normal file
22
app/src/main/java/mihon/core/migration/Migration.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package mihon.core.migration
|
||||
|
||||
interface Migration {
|
||||
val version: Float
|
||||
|
||||
suspend operator fun invoke(migrationContext: MigrationContext): Boolean
|
||||
|
||||
val isAlways: Boolean
|
||||
get() = version == ALWAYS
|
||||
|
||||
companion object {
|
||||
const val ALWAYS = -1f
|
||||
|
||||
fun of(version: Float, action: suspend (MigrationContext) -> Boolean): Migration = object : Migration {
|
||||
override val version: Float = version
|
||||
|
||||
override suspend operator fun invoke(migrationContext: MigrationContext): Boolean {
|
||||
return action(migrationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
package mihon.core.migration
|
||||
|
||||
typealias MigrationCompletedListener = () -> Unit
|
10
app/src/main/java/mihon/core/migration/MigrationContext.kt
Normal file
10
app/src/main/java/mihon/core/migration/MigrationContext.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package mihon.core.migration
|
||||
|
||||
import uy.kohesive.injekt.Injekt
|
||||
|
||||
class MigrationContext(val dryrun: Boolean) {
|
||||
|
||||
inline fun <reified T> get(): T? {
|
||||
return Injekt.getInstanceOrNull(T::class.java)
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
package mihon.core.migration
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
class MigrationJobFactory(
|
||||
private val migrationContext: MigrationContext,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
|
||||
@SuppressWarnings("MaxLineLength")
|
||||
fun create(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
|
||||
return migrations.sortedBy { it.version }
|
||||
.fold(CompletableDeferred(true)) { acc: Deferred<Boolean>, migration: Migration ->
|
||||
if (!migrationContext.dryrun) {
|
||||
logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
|
||||
async {
|
||||
val prev = acc.await()
|
||||
migration(migrationContext) || prev
|
||||
}
|
||||
} else {
|
||||
logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
|
||||
CompletableDeferred(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
55
app/src/main/java/mihon/core/migration/MigrationStrategy.kt
Normal file
55
app/src/main/java/mihon/core/migration/MigrationStrategy.kt
Normal file
@@ -0,0 +1,55 @@
|
||||
package mihon.core.migration
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface MigrationStrategy {
|
||||
operator fun invoke(migrations: List<Migration>): Deferred<Boolean>
|
||||
}
|
||||
|
||||
class DefaultMigrationStrategy(
|
||||
private val migrationJobFactory: MigrationJobFactory,
|
||||
private val migrationCompletedListener: MigrationCompletedListener,
|
||||
private val scope: CoroutineScope
|
||||
) : MigrationStrategy {
|
||||
|
||||
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
|
||||
if (migrations.isEmpty()) {
|
||||
return@with CompletableDeferred(false)
|
||||
}
|
||||
|
||||
val chain = migrationJobFactory.create(migrations)
|
||||
|
||||
launch {
|
||||
if (chain.await()) migrationCompletedListener()
|
||||
}.start()
|
||||
|
||||
chain
|
||||
}
|
||||
}
|
||||
|
||||
class InitialMigrationStrategy(private val strategy: DefaultMigrationStrategy) : MigrationStrategy {
|
||||
|
||||
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
|
||||
return strategy(migrations.filter { it.isAlways })
|
||||
}
|
||||
}
|
||||
|
||||
class NoopMigrationStrategy(val state: Boolean) : MigrationStrategy {
|
||||
|
||||
override fun invoke(migrations: List<Migration>): Deferred<Boolean> {
|
||||
return CompletableDeferred(state)
|
||||
}
|
||||
}
|
||||
|
||||
class VersionRangeMigrationStrategy(
|
||||
private val versions: IntRange,
|
||||
private val strategy: DefaultMigrationStrategy
|
||||
) : MigrationStrategy {
|
||||
|
||||
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
|
||||
return strategy(migrations.filter { it.isAlways || it.version.toInt() in versions })
|
||||
}
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
package mihon.core.migration
|
||||
|
||||
class MigrationStrategyFactory(
|
||||
private val factory: MigrationJobFactory,
|
||||
private val migrationCompletedListener: MigrationCompletedListener,
|
||||
) {
|
||||
|
||||
fun create(old: Int, new: Int): MigrationStrategy {
|
||||
val versions = (old + 1)..new
|
||||
val strategy = when {
|
||||
old == 0 -> InitialMigrationStrategy(
|
||||
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
|
||||
)
|
||||
|
||||
old >= new -> NoopMigrationStrategy(false)
|
||||
else -> VersionRangeMigrationStrategy(
|
||||
versions = versions,
|
||||
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
|
||||
)
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
}
|
41
app/src/main/java/mihon/core/migration/Migrator.kt
Normal file
41
app/src/main/java/mihon/core/migration/Migrator.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package mihon.core.migration
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
object Migrator {
|
||||
|
||||
private var result: Deferred<Boolean>? = null
|
||||
val scope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
fun initialize(
|
||||
old: Int,
|
||||
new: Int,
|
||||
migrations: List<Migration>,
|
||||
dryrun: Boolean = false,
|
||||
onMigrationComplete: () -> Unit
|
||||
) {
|
||||
val migrationContext = MigrationContext(dryrun)
|
||||
val migrationJobFactory = MigrationJobFactory(migrationContext, scope)
|
||||
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete)
|
||||
val strategy = migrationStrategyFactory.create(old, new)
|
||||
result = strategy(migrations)
|
||||
}
|
||||
|
||||
suspend fun await(): Boolean {
|
||||
val result = result ?: CompletableDeferred(false)
|
||||
return result.await()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
result = null
|
||||
}
|
||||
|
||||
fun awaitAndRelease(): Boolean = runBlocking {
|
||||
await().also { release() }
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
package mihon.core.migration.migrations
|
||||
|
||||
import mihon.core.migration.Migration
|
||||
|
||||
val migrations: List<Migration>
|
||||
get() = listOf(
|
||||
SetupBackupCreateMigration(),
|
||||
SetupLibraryUpdateMigration(),
|
||||
TrustExtensionRepositoryMigration(),
|
||||
)
|
@@ -0,0 +1,16 @@
|
||||
package mihon.core.migration.migrations
|
||||
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
||||
import mihon.core.migration.Migration
|
||||
import mihon.core.migration.MigrationContext
|
||||
|
||||
class SetupBackupCreateMigration : Migration {
|
||||
override val version: Float = Migration.ALWAYS
|
||||
|
||||
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
|
||||
val context = migrationContext.get<App>() ?: return false
|
||||
BackupCreateJob.setupTask(context)
|
||||
return true
|
||||
}
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
package mihon.core.migration.migrations
|
||||
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import mihon.core.migration.Migration
|
||||
import mihon.core.migration.MigrationContext
|
||||
|
||||
class SetupLibraryUpdateMigration : Migration {
|
||||
override val version: Float = Migration.ALWAYS
|
||||
|
||||
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
|
||||
val context = migrationContext.get<App>() ?: return false
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
return true
|
||||
}
|
||||
}
|
@@ -0,0 +1,35 @@
|
||||
package mihon.core.migration.migrations
|
||||
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import logcat.LogPriority
|
||||
import mihon.core.migration.Migration
|
||||
import mihon.core.migration.MigrationContext
|
||||
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
class TrustExtensionRepositoryMigration : Migration {
|
||||
override val version: Float = 7f
|
||||
|
||||
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
|
||||
val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false
|
||||
val extensionRepositoryRepository =
|
||||
migrationContext.get<ExtensionRepoRepository>() ?: return@withIOContext false
|
||||
for ((index, source) in sourcePreferences.extensionRepos().get().withIndex()) {
|
||||
try {
|
||||
extensionRepositoryRepository.upsertRepo(
|
||||
source,
|
||||
"Repo #${index + 1}",
|
||||
null,
|
||||
source,
|
||||
"NOFINGERPRINT-${index + 1}",
|
||||
)
|
||||
} catch (e: SaveExtensionRepoException) {
|
||||
logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" }
|
||||
}
|
||||
}
|
||||
sourcePreferences.extensionRepos().delete()
|
||||
return@withIOContext true
|
||||
}
|
||||
}
|
27
app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt
Normal file
27
app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package mihon.feature.upcoming
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
|
||||
class UpcomingScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel { UpcomingScreenModel() }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
UpcomingScreenContent(
|
||||
state = state,
|
||||
setSelectedYearMonth = screenModel::setSelectedYearMonth,
|
||||
onClickUpcoming = { navigator.push(MangaScreen(it.id)) },
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,198 @@
|
||||
package mihon.feature.upcoming
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.relativeDateText
|
||||
import eu.kanade.presentation.util.isTabletUi
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.feature.upcoming.components.UpcomingItem
|
||||
import mihon.feature.upcoming.components.calendar.Calendar
|
||||
import tachiyomi.core.common.Constants
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.ListGroupHeader
|
||||
import tachiyomi.presentation.core.components.TwoPanelBox
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
@Composable
|
||||
fun UpcomingScreenContent(
|
||||
state: UpcomingScreenModel.State,
|
||||
setSelectedYearMonth: (YearMonth) -> Unit,
|
||||
onClickUpcoming: (manga: Manga) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val onClickDay: (LocalDate, Int) -> Unit = { date, offset ->
|
||||
state.headerIndexes[date]?.let {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(it + offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
topBar = { UpcomingToolbar() },
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
if (isTabletUi()) {
|
||||
UpcomingScreenLargeImpl(
|
||||
listState = listState,
|
||||
items = state.items,
|
||||
events = state.events,
|
||||
paddingValues = paddingValues,
|
||||
selectedYearMonth = state.selectedYearMonth,
|
||||
setSelectedYearMonth = setSelectedYearMonth,
|
||||
onClickDay = { onClickDay(it, 0) },
|
||||
onClickUpcoming = onClickUpcoming,
|
||||
)
|
||||
} else {
|
||||
UpcomingScreenSmallImpl(
|
||||
listState = listState,
|
||||
items = state.items,
|
||||
events = state.events,
|
||||
paddingValues = paddingValues,
|
||||
selectedYearMonth = state.selectedYearMonth,
|
||||
setSelectedYearMonth = setSelectedYearMonth,
|
||||
onClickDay = { onClickDay(it, 1) },
|
||||
onClickUpcoming = onClickUpcoming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpcomingToolbar() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
AppBar(
|
||||
title = stringResource(MR.strings.label_upcoming),
|
||||
navigateUp = navigator::pop,
|
||||
actions = {
|
||||
IconButton(onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
|
||||
contentDescription = stringResource(MR.strings.upcoming_guide),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpcomingScreenSmallImpl(
|
||||
listState: LazyListState,
|
||||
items: ImmutableList<UpcomingUIModel>,
|
||||
events: ImmutableMap<LocalDate, Int>,
|
||||
paddingValues: PaddingValues,
|
||||
selectedYearMonth: YearMonth,
|
||||
setSelectedYearMonth: (YearMonth) -> Unit,
|
||||
onClickDay: (LocalDate) -> Unit,
|
||||
onClickUpcoming: (manga: Manga) -> Unit,
|
||||
) {
|
||||
FastScrollLazyColumn(
|
||||
contentPadding = paddingValues,
|
||||
state = listState,
|
||||
) {
|
||||
item(key = "upcoming-calendar") {
|
||||
Calendar(
|
||||
selectedYearMonth = selectedYearMonth,
|
||||
events = events,
|
||||
setSelectedYearMonth = setSelectedYearMonth,
|
||||
onClickDay = onClickDay,
|
||||
)
|
||||
}
|
||||
items(
|
||||
items = items,
|
||||
key = { "upcoming-${it.hashCode()}" },
|
||||
contentType = {
|
||||
when (it) {
|
||||
is UpcomingUIModel.Header -> "header"
|
||||
is UpcomingUIModel.Item -> "item"
|
||||
}
|
||||
},
|
||||
) { item ->
|
||||
when (item) {
|
||||
is UpcomingUIModel.Item -> {
|
||||
UpcomingItem(
|
||||
upcoming = item.manga,
|
||||
onClick = { onClickUpcoming(item.manga) },
|
||||
)
|
||||
}
|
||||
is UpcomingUIModel.Header -> {
|
||||
ListGroupHeader(text = relativeDateText(item.date))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpcomingScreenLargeImpl(
|
||||
listState: LazyListState,
|
||||
items: ImmutableList<UpcomingUIModel>,
|
||||
events: ImmutableMap<LocalDate, Int>,
|
||||
paddingValues: PaddingValues,
|
||||
selectedYearMonth: YearMonth,
|
||||
setSelectedYearMonth: (YearMonth) -> Unit,
|
||||
onClickDay: (LocalDate) -> Unit,
|
||||
onClickUpcoming: (manga: Manga) -> Unit,
|
||||
) {
|
||||
TwoPanelBox(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
startContent = {
|
||||
Calendar(
|
||||
selectedYearMonth = selectedYearMonth,
|
||||
events = events,
|
||||
setSelectedYearMonth = setSelectedYearMonth,
|
||||
onClickDay = onClickDay,
|
||||
)
|
||||
},
|
||||
endContent = {
|
||||
FastScrollLazyColumn(state = listState) {
|
||||
items(
|
||||
items = items,
|
||||
key = { "upcoming-${it.hashCode()}" },
|
||||
contentType = {
|
||||
when (it) {
|
||||
is UpcomingUIModel.Header -> "header"
|
||||
is UpcomingUIModel.Item -> "item"
|
||||
}
|
||||
},
|
||||
) { item ->
|
||||
when (item) {
|
||||
is UpcomingUIModel.Item -> {
|
||||
UpcomingItem(
|
||||
upcoming = item.manga,
|
||||
onClick = { onClickUpcoming(item.manga) },
|
||||
)
|
||||
}
|
||||
is UpcomingUIModel.Header -> {
|
||||
ListGroupHeader(text = relativeDateText(item.date))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@@ -0,0 +1,87 @@
|
||||
package mihon.feature.upcoming
|
||||
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import androidx.compose.ui.util.fastMapIndexedNotNull
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.core.util.insertSeparators
|
||||
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
class UpcomingScreenModel(
|
||||
private val getUpcomingManga: GetUpcomingManga = Injekt.get(),
|
||||
) : StateScreenModel<UpcomingScreenModel.State>(State()) {
|
||||
|
||||
init {
|
||||
screenModelScope.launch {
|
||||
getUpcomingManga.subscribe().collectLatest {
|
||||
mutableState.update { state ->
|
||||
val upcomingItems = it.toUpcomingUIModels()
|
||||
state.copy(
|
||||
items = upcomingItems,
|
||||
events = it.toEvents(),
|
||||
headerIndexes = upcomingItems.getHeaderIndexes(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Manga>.toUpcomingUIModels(): ImmutableList<UpcomingUIModel> {
|
||||
return fastMap { UpcomingUIModel.Item(it) }
|
||||
.insertSeparators { before, after ->
|
||||
val beforeDate = before?.manga?.expectedNextUpdate?.toLocalDate()
|
||||
val afterDate = after?.manga?.expectedNextUpdate?.toLocalDate()
|
||||
|
||||
if (beforeDate != afterDate && afterDate != null) {
|
||||
UpcomingUIModel.Header(afterDate)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
private fun List<Manga>.toEvents(): ImmutableMap<LocalDate, Int> {
|
||||
return groupBy { it.expectedNextUpdate?.toLocalDate() ?: LocalDate.MAX }
|
||||
.mapValues { it.value.size }
|
||||
.toImmutableMap()
|
||||
}
|
||||
|
||||
private fun List<UpcomingUIModel>.getHeaderIndexes(): ImmutableMap<LocalDate, Int> {
|
||||
return fastMapIndexedNotNull { index, upcomingUIModel ->
|
||||
if (upcomingUIModel is UpcomingUIModel.Header) {
|
||||
upcomingUIModel.date to index
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
.toImmutableMap()
|
||||
}
|
||||
|
||||
fun setSelectedYearMonth(yearMonth: YearMonth) {
|
||||
mutableState.update { it.copy(selectedYearMonth = yearMonth) }
|
||||
}
|
||||
|
||||
data class State(
|
||||
val selectedYearMonth: YearMonth = YearMonth.now(),
|
||||
val items: ImmutableList<UpcomingUIModel> = persistentListOf(),
|
||||
val events: ImmutableMap<LocalDate, Int> = persistentMapOf(),
|
||||
val headerIndexes: ImmutableMap<LocalDate, Int> = persistentMapOf(),
|
||||
)
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
package mihon.feature.upcoming
|
||||
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import java.time.LocalDate
|
||||
|
||||
sealed interface UpcomingUIModel {
|
||||
data class Header(val date: LocalDate) : UpcomingUIModel
|
||||
data class Item(val manga: Manga) : UpcomingUIModel
|
||||
}
|
@@ -0,0 +1,54 @@
|
||||
package mihon.feature.upcoming.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.manga.components.MangaCover
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.asMangaCover
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
||||
private val UpcomingItemHeight = 96.dp
|
||||
|
||||
@Composable
|
||||
fun UpcomingItem(
|
||||
upcoming: Manga,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.height(UpcomingItemHeight)
|
||||
.padding(
|
||||
horizontal = MaterialTheme.padding.medium,
|
||||
vertical = MaterialTheme.padding.small,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.large),
|
||||
) {
|
||||
MangaCover.Book(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
data = upcoming.asMangaCover(),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = upcoming.title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,112 @@
|
||||
package mihon.feature.upcoming.components.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import io.woong.compose.grid.SimpleGridCells
|
||||
import io.woong.compose.grid.VerticalGrid
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import mihon.core.designsystem.utils.isExpandedWidthWindow
|
||||
import mihon.core.designsystem.utils.isMediumWidthWindow
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
||||
private val FontSize = 16.sp
|
||||
private const val DaysOfWeek = 7
|
||||
|
||||
@Composable
|
||||
fun Calendar(
|
||||
selectedYearMonth: YearMonth,
|
||||
events: ImmutableMap<LocalDate, Int>,
|
||||
setSelectedYearMonth: (YearMonth) -> Unit,
|
||||
onClickDay: (day: LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
CalenderHeader(
|
||||
yearMonth = selectedYearMonth,
|
||||
onPreviousClick = { setSelectedYearMonth(selectedYearMonth.minusMonths(1L)) },
|
||||
onNextClick = { setSelectedYearMonth(selectedYearMonth.plusMonths(1L)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = MaterialTheme.padding.small)
|
||||
.padding(start = MaterialTheme.padding.medium)
|
||||
)
|
||||
CalendarGrid(
|
||||
selectedYearMonth = selectedYearMonth,
|
||||
events = events,
|
||||
onClickDay = onClickDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarGrid(
|
||||
selectedYearMonth: YearMonth,
|
||||
events: ImmutableMap<LocalDate, Int>,
|
||||
onClickDay: (day: LocalDate) -> Unit,
|
||||
) {
|
||||
val localeFirstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek.value
|
||||
val weekDays = remember {
|
||||
(0 until DaysOfWeek)
|
||||
.map { DayOfWeek.of((localeFirstDayOfWeek - 1 + it) % DaysOfWeek + 1) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
val emptyFieldCount = weekDays.indexOf(selectedYearMonth.atDay(1).dayOfWeek)
|
||||
val daysInMonth = selectedYearMonth.lengthOfMonth()
|
||||
|
||||
VerticalGrid(
|
||||
columns = SimpleGridCells.Fixed(DaysOfWeek),
|
||||
modifier = if (isMediumWidthWindow() && !isExpandedWidthWindow()) {
|
||||
Modifier.widthIn(max = 360.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
) {
|
||||
weekDays.fastForEach { item ->
|
||||
Text(
|
||||
text = item.getDisplayName(
|
||||
TextStyle.NARROW,
|
||||
Locale.getDefault(),
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = FontSize,
|
||||
)
|
||||
}
|
||||
repeat(emptyFieldCount) { Box { } }
|
||||
repeat(daysInMonth) { dayIndex ->
|
||||
val localDate = selectedYearMonth.atDay(dayIndex + 1)
|
||||
CalendarDay(
|
||||
date = localDate,
|
||||
onDayClick = { onClickDay(localDate) },
|
||||
events = events[localDate] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
package mihon.feature.upcoming.components.calendar
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.layout
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.time.LocalDate
|
||||
|
||||
private const val MaxEvents = 3
|
||||
|
||||
@Composable
|
||||
fun CalendarDay(
|
||||
date: LocalDate,
|
||||
events: Int,
|
||||
onDayClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val today = remember { LocalDate.now() }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (today == date) {
|
||||
Modifier.border(
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
shape = CircleShape,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = onDayClick)
|
||||
.circleLayout(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = date.dayOfMonth.toString(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp,
|
||||
color = if (date.isBefore(today)) {
|
||||
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(Modifier.offset(y = 12.dp)) {
|
||||
val size = events.coerceAtMost(MaxEvents)
|
||||
for (index in 0 until size) {
|
||||
CalendarIndicator(
|
||||
index = index,
|
||||
size = 56.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.circleLayout() = layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
|
||||
val currentHeight = placeable.height
|
||||
val currentWidth = placeable.width
|
||||
val newDiameter = maxOf(currentHeight, currentWidth)
|
||||
|
||||
layout(newDiameter, newDiameter) {
|
||||
placeable.placeRelative(
|
||||
x = (newDiameter - currentWidth) / 2,
|
||||
y = (newDiameter - currentHeight) / 2,
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,100 @@
|
||||
package mihon.feature.upcoming.components.calendar
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CalenderHeader(
|
||||
yearMonth: YearMonth,
|
||||
onPreviousClick: () -> Unit,
|
||||
onNextClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = yearMonth,
|
||||
transitionSpec = { getAnimation() },
|
||||
label = "Change Month",
|
||||
) { monthYear ->
|
||||
Text(
|
||||
text = getTitleText(monthYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
Row {
|
||||
IconButton(onClick = onPreviousClick) {
|
||||
Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev))
|
||||
}
|
||||
IconButton(onClick = onNextClick) {
|
||||
Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MonthYearChangeAnimationDuration = 200
|
||||
|
||||
private fun AnimatedContentTransitionScope<YearMonth>.getAnimation(): ContentTransform {
|
||||
val movingForward = targetState > initialState
|
||||
|
||||
val enterTransition = slideInVertically(
|
||||
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
|
||||
) { height -> if (movingForward) height else -height } + fadeIn(
|
||||
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
|
||||
)
|
||||
val exitTransition = slideOutVertically(
|
||||
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
|
||||
) { height -> if (movingForward) -height else height } + fadeOut(
|
||||
animationSpec = tween(durationMillis = MonthYearChangeAnimationDuration),
|
||||
)
|
||||
return (enterTransition togetherWith exitTransition)
|
||||
.using(SizeTransform(clip = false))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun getTitleText(monthYear: YearMonth): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault())
|
||||
return formatter.format(monthYear)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CalenderHeaderPreview() {
|
||||
CalenderHeader(
|
||||
yearMonth = YearMonth.now(),
|
||||
onNextClick = {},
|
||||
onPreviousClick = {},
|
||||
)
|
||||
}
|
@@ -0,0 +1,32 @@
|
||||
package mihon.feature.upcoming.components.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val IndicatorScale = 12
|
||||
private const val IndicatorAlphaMultiplier = 0.3f
|
||||
|
||||
@Composable
|
||||
fun CalendarIndicator(
|
||||
index: Int,
|
||||
size: Dp,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 1.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.background(color = color.copy(alpha = (index + 1) * IndicatorAlphaMultiplier))
|
||||
.size(size = size.div(IndicatorScale)),
|
||||
)
|
||||
}
|
159
app/src/test/java/mihon/core/migration/MigratorTest.kt
Normal file
159
app/src/test/java/mihon/core/migration/MigratorTest.kt
Normal file
@@ -0,0 +1,159 @@
|
||||
package mihon.core.migration
|
||||
|
||||
import io.mockk.Called
|
||||
import io.mockk.slot
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.newSingleThreadContext
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MigratorTest {
|
||||
|
||||
lateinit var migrationCompletedListener: MigrationCompletedListener
|
||||
lateinit var migrationContext: MigrationContext
|
||||
lateinit var migrationJobFactory: MigrationJobFactory
|
||||
lateinit var migrationStrategyFactory: MigrationStrategyFactory
|
||||
|
||||
@BeforeEach
|
||||
fun initilize() {
|
||||
migrationContext = MigrationContext(false)
|
||||
migrationJobFactory = spyk(MigrationJobFactory(migrationContext, CoroutineScope(Dispatchers.Main + Job())))
|
||||
migrationCompletedListener = spyk<() -> Unit>({})
|
||||
migrationStrategyFactory = spyk(MigrationStrategyFactory(migrationJobFactory, migrationCompletedListener))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialVersion() = runBlocking {
|
||||
val strategy = migrationStrategyFactory.create(0, 1)
|
||||
assertInstanceOf(InitialMigrationStrategy::class.java, strategy)
|
||||
|
||||
val migrations = slot<List<Migration>>()
|
||||
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }))
|
||||
|
||||
execute.await()
|
||||
|
||||
verify { migrationJobFactory.create(capture(migrations)) }
|
||||
assertEquals(1, migrations.captured.size)
|
||||
verify { migrationCompletedListener() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameVersion() = runBlocking {
|
||||
val strategy = migrationStrategyFactory.create(1, 1)
|
||||
assertInstanceOf(NoopMigrationStrategy::class.java, strategy)
|
||||
|
||||
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { false }))
|
||||
|
||||
val result = execute.await()
|
||||
assertFalse(result)
|
||||
|
||||
verify { migrationJobFactory.create(any()) wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMigrations() = runBlocking {
|
||||
val strategy = migrationStrategyFactory.create(1, 2)
|
||||
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
|
||||
|
||||
val execute = strategy(emptyList())
|
||||
|
||||
val result = execute.await()
|
||||
assertFalse(result)
|
||||
|
||||
verify { migrationJobFactory.create(any()) wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smallMigration() = runBlocking {
|
||||
val strategy = migrationStrategyFactory.create(1, 2)
|
||||
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
|
||||
|
||||
val migrations = slot<List<Migration>>()
|
||||
val execute = strategy(listOf(Migration.of(Migration.ALWAYS) { true }, Migration.of(2f) { true }))
|
||||
|
||||
execute.await()
|
||||
|
||||
verify { migrationJobFactory.create(capture(migrations)) }
|
||||
assertEquals(2, migrations.captured.size)
|
||||
verify { migrationCompletedListener() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeMigration() = runBlocking {
|
||||
val input = listOf(
|
||||
Migration.of(Migration.ALWAYS) { true },
|
||||
Migration.of(2f) { true },
|
||||
Migration.of(3f) { true },
|
||||
Migration.of(4f) { true },
|
||||
Migration.of(5f) { true },
|
||||
Migration.of(6f) { true },
|
||||
Migration.of(7f) { true },
|
||||
Migration.of(8f) { true },
|
||||
Migration.of(9f) { true },
|
||||
Migration.of(10f) { true },
|
||||
)
|
||||
|
||||
val strategy = migrationStrategyFactory.create(1, 10)
|
||||
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
|
||||
|
||||
val migrations = slot<List<Migration>>()
|
||||
val execute = strategy(input)
|
||||
|
||||
execute.await()
|
||||
|
||||
verify { migrationJobFactory.create(capture(migrations)) }
|
||||
assertEquals(10, migrations.captured.size)
|
||||
verify { migrationCompletedListener() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withinRangeMigration() = runBlocking {
|
||||
val strategy = migrationStrategyFactory.create(1, 2)
|
||||
assertInstanceOf(VersionRangeMigrationStrategy::class.java, strategy)
|
||||
|
||||
val migrations = slot<List<Migration>>()
|
||||
val execute = strategy(
|
||||
listOf(
|
||||
Migration.of(Migration.ALWAYS) { true },
|
||||
Migration.of(2f) { true },
|
||||
Migration.of(3f) { false }
|
||||
)
|
||||
)
|
||||
|
||||
execute.await()
|
||||
|
||||
verify { migrationJobFactory.create(capture(migrations)) }
|
||||
assertEquals(2, migrations.captured.size)
|
||||
verify { migrationCompletedListener() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val mainThreadSurrogate = newSingleThreadContext("UI thread")
|
||||
|
||||
@BeforeAll
|
||||
@JvmStatic
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainThreadSurrogate)
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
@JvmStatic
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher
|
||||
mainThreadSurrogate.close()
|
||||
}
|
||||
}
|
||||
}
|
@@ -18,5 +18,7 @@ style:
|
||||
ignoreCompanionObjectPropertyDeclaration: true
|
||||
ReturnCount:
|
||||
excludeGuardClauses: true
|
||||
SerialVersionUIDInSerializableClass:
|
||||
active: false
|
||||
UnusedPrivateMember:
|
||||
ignoreAnnotated: [ 'Preview' ]
|
||||
|
@@ -19,7 +19,7 @@ class NetworkPreferences(
|
||||
fun defaultUserAgent(): Preference<String> {
|
||||
return preferenceStore.getString(
|
||||
"default_user_agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package tachiyomi.core.common
|
||||
|
||||
object Constants {
|
||||
const val URL_HELP = "https://mihon.app/docs/guides/troubleshooting/"
|
||||
const val URL_HELP_UPCOMING = "https://mihon.app/docs/faq/updates/upcoming"
|
||||
|
||||
const val MANGA_EXTRA = "manga"
|
||||
|
||||
|
@@ -77,19 +77,20 @@ object ImageUtil {
|
||||
}
|
||||
|
||||
fun isAnimatedAndSupported(stream: InputStream): Boolean {
|
||||
try {
|
||||
return try {
|
||||
val type = getImageType(stream) ?: return false
|
||||
return when (type.format) {
|
||||
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||
when (type.format) {
|
||||
Format.Gif -> true
|
||||
// Coil supports animated WebP on Android 9.0+
|
||||
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||
// Animated WebP on Android 9+
|
||||
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
// Animated Heif on Android 11+
|
||||
Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
/* Do Nothing */
|
||||
false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
|
||||
|
@@ -0,0 +1,93 @@
|
||||
package mihon.data.repository
|
||||
|
||||
import android.database.sqlite.SQLiteException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import tachiyomi.data.DatabaseHandler
|
||||
|
||||
class ExtensionRepoRepositoryImpl(
|
||||
private val handler: DatabaseHandler,
|
||||
) : ExtensionRepoRepository {
|
||||
override fun subscribeAll(): Flow<List<ExtensionRepo>> {
|
||||
return handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) }
|
||||
}
|
||||
|
||||
override suspend fun getAll(): List<ExtensionRepo> {
|
||||
return handler.awaitList { extension_reposQueries.findAll(::mapExtensionRepo) }
|
||||
}
|
||||
|
||||
override suspend fun getRepo(baseUrl: String): ExtensionRepo? {
|
||||
return handler.awaitOneOrNull { extension_reposQueries.findOne(baseUrl, ::mapExtensionRepo) }
|
||||
}
|
||||
|
||||
override suspend fun getRepoBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? {
|
||||
return handler.awaitOneOrNull {
|
||||
extension_reposQueries.findOneBySigningKeyFingerprint(fingerprint, ::mapExtensionRepo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCount(): Flow<Int> {
|
||||
return handler.subscribeToOne { extension_reposQueries.count() }.map { it.toInt() }
|
||||
}
|
||||
|
||||
override suspend fun insertRepo(
|
||||
baseUrl: String,
|
||||
name: String,
|
||||
shortName: String?,
|
||||
website: String,
|
||||
signingKeyFingerprint: String,
|
||||
) {
|
||||
try {
|
||||
handler.await { extension_reposQueries.insert(baseUrl, name, shortName, website, signingKeyFingerprint) }
|
||||
} catch (ex: SQLiteException) {
|
||||
throw SaveExtensionRepoException(ex)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertRepo(
|
||||
baseUrl: String,
|
||||
name: String,
|
||||
shortName: String?,
|
||||
website: String,
|
||||
signingKeyFingerprint: String,
|
||||
) {
|
||||
try {
|
||||
handler.await { extension_reposQueries.upsert(baseUrl, name, shortName, website, signingKeyFingerprint) }
|
||||
} catch (ex: SQLiteException) {
|
||||
throw SaveExtensionRepoException(ex)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun replaceRepo(newRepo: ExtensionRepo) {
|
||||
handler.await {
|
||||
extension_reposQueries.replace(
|
||||
newRepo.baseUrl,
|
||||
newRepo.name,
|
||||
newRepo.shortName,
|
||||
newRepo.website,
|
||||
newRepo.signingKeyFingerprint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteRepo(baseUrl: String) {
|
||||
return handler.await { extension_reposQueries.delete(baseUrl) }
|
||||
}
|
||||
|
||||
private fun mapExtensionRepo(
|
||||
baseUrl: String,
|
||||
name: String,
|
||||
shortName: String?,
|
||||
website: String,
|
||||
signingKeyFingerprint: String,
|
||||
): ExtensionRepo = ExtensionRepo(
|
||||
baseUrl = baseUrl,
|
||||
name = name,
|
||||
shortName = shortName,
|
||||
website = website,
|
||||
signingKeyFingerprint = signingKeyFingerprint,
|
||||
)
|
||||
}
|
@@ -65,6 +65,12 @@ class MangaRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>> {
|
||||
return handler.subscribeToList {
|
||||
mangasQueries.getUpcomingManga(statuses, MangaMapper::mapManga)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resetViewerFlags(): Boolean {
|
||||
return try {
|
||||
handler.await { mangasQueries.resetViewerFlags() }
|
||||
|
57
data/src/main/sqldelight/tachiyomi/data/extension_repos.sq
Normal file
57
data/src/main/sqldelight/tachiyomi/data/extension_repos.sq
Normal file
@@ -0,0 +1,57 @@
|
||||
CREATE TABLE extension_repos (
|
||||
base_url TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
short_name TEXT,
|
||||
website TEXT NOT NULL,
|
||||
signing_key_fingerprint TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
findOne:
|
||||
SELECT *
|
||||
FROM extension_repos
|
||||
WHERE base_url = :base_url;
|
||||
|
||||
findOneBySigningKeyFingerprint:
|
||||
SELECT *
|
||||
FROM extension_repos
|
||||
WHERE signing_key_fingerprint = :fingerprint;
|
||||
|
||||
findAll:
|
||||
SELECT *
|
||||
FROM extension_repos;
|
||||
|
||||
count:
|
||||
SELECT COUNT(*)
|
||||
FROM extension_repos;
|
||||
|
||||
insert:
|
||||
INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint)
|
||||
VALUES (:base_url, :name, :short_name, :website, :fingerprint);
|
||||
|
||||
upsert:
|
||||
INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint)
|
||||
VALUES (:base_url, :name, :short_name, :website, :fingerprint)
|
||||
ON CONFLICT(base_url)
|
||||
DO UPDATE
|
||||
SET
|
||||
name = :name,
|
||||
short_name = :short_name,
|
||||
website =: website,
|
||||
signing_key_fingerprint = :fingerprint
|
||||
WHERE base_url = base_url;
|
||||
|
||||
replace:
|
||||
INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint)
|
||||
VALUES (:base_url, :name, :short_name, :website, :fingerprint)
|
||||
ON CONFLICT(signing_key_fingerprint)
|
||||
DO UPDATE
|
||||
SET
|
||||
base_url = :base_url,
|
||||
name = :name,
|
||||
short_name = :short_name,
|
||||
website =: website
|
||||
WHERE signing_key_fingerprint = signing_key_fingerprint;
|
||||
|
||||
delete:
|
||||
DELETE FROM extension_repos
|
||||
WHERE base_url = :base_url;
|
@@ -113,6 +113,14 @@ WHERE favorite = 1
|
||||
AND LOWER(title) = :title
|
||||
AND _id != :id;
|
||||
|
||||
getUpcomingManga:
|
||||
SELECT *
|
||||
FROM mangas
|
||||
WHERE next_update > 0
|
||||
AND favorite = 1
|
||||
AND status IN :statuses
|
||||
ORDER BY next_update ASC;
|
||||
|
||||
resetViewerFlags:
|
||||
UPDATE mangas
|
||||
SET viewer = 0;
|
||||
|
@@ -1 +1,8 @@
|
||||
ALTER TABLE mangas ADD COLUMN web_urls TEXT;
|
||||
-- Create ExtensionRepo table --
|
||||
CREATE TABLE extension_repos (
|
||||
base_url TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
short_name TEXT,
|
||||
website TEXT NOT NULL,
|
||||
signing_key_fingerprint TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
1
data/src/main/sqldelight/tachiyomi/migrations/4.sqm
Normal file
1
data/src/main/sqldelight/tachiyomi/migrations/4.sqm
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE mangas ADD COLUMN web_urls TEXT;
|
@@ -33,6 +33,7 @@ tasks {
|
||||
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
kotlinOptions.freeCompilerArgs += listOf(
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-Xcontext-receivers",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,10 @@
|
||||
package mihon.domain.extensionrepo.exception
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Exception to abstract over SQLiteException and SQLiteConstraintException for multiplatform.
|
||||
*
|
||||
* @param throwable the source throwable to include for tracing.
|
||||
*/
|
||||
class SaveExtensionRepoException(throwable: Throwable) : IOException("Error Saving Repository to Database", throwable)
|
@@ -0,0 +1,71 @@
|
||||
package mihon.domain.extensionrepo.interactor
|
||||
|
||||
import logcat.LogPriority
|
||||
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import mihon.domain.extensionrepo.service.ExtensionRepoService
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
class CreateExtensionRepo(
|
||||
private val repository: ExtensionRepoRepository,
|
||||
private val service: ExtensionRepoService,
|
||||
) {
|
||||
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
|
||||
|
||||
suspend fun await(repoUrl: String): Result {
|
||||
if (!repoUrl.matches(repoRegex)) {
|
||||
return Result.InvalidUrl
|
||||
}
|
||||
|
||||
val baseUrl = repoUrl.removeSuffix("/index.min.json")
|
||||
return service.fetchRepoDetails(baseUrl)?.let { insert(it) } ?: Result.InvalidUrl
|
||||
}
|
||||
|
||||
private suspend fun insert(repo: ExtensionRepo): Result {
|
||||
return try {
|
||||
repository.insertRepo(
|
||||
repo.baseUrl,
|
||||
repo.name,
|
||||
repo.shortName,
|
||||
repo.website,
|
||||
repo.signingKeyFingerprint,
|
||||
)
|
||||
Result.Success
|
||||
} catch (e: SaveExtensionRepoException) {
|
||||
logcat(LogPriority.WARN, e) { "SQL Conflict attempting to add new repository ${repo.baseUrl}" }
|
||||
return handleInsertionError(repo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Handler for insert when there are trying to create new repositories
|
||||
*
|
||||
* SaveExtensionRepoException doesn't provide constraint info in exceptions.
|
||||
* First check if the conflict was on primary key. if so return RepoAlreadyExists
|
||||
* Then check if the conflict was on fingerprint. if so Return DuplicateFingerprint
|
||||
* If neither are found, there was some other Error, and return Result.Error
|
||||
*
|
||||
* @param repo Extension Repo holder for passing to DB/Error Dialog
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun handleInsertionError(repo: ExtensionRepo): Result {
|
||||
val repoExists = repository.getRepo(repo.baseUrl)
|
||||
if (repoExists != null) {
|
||||
return Result.RepoAlreadyExists
|
||||
}
|
||||
val matchingFingerprintRepo = repository.getRepoBySigningKeyFingerprint(repo.signingKeyFingerprint)
|
||||
if (matchingFingerprintRepo != null) {
|
||||
return Result.DuplicateFingerprint(matchingFingerprintRepo, repo)
|
||||
}
|
||||
return Result.Error
|
||||
}
|
||||
|
||||
sealed interface Result {
|
||||
data class DuplicateFingerprint(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : Result
|
||||
data object InvalidUrl : Result
|
||||
data object RepoAlreadyExists : Result
|
||||
data object Success : Result
|
||||
data object Error : Result
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
package mihon.domain.extensionrepo.interactor
|
||||
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
|
||||
class DeleteExtensionRepo(
|
||||
private val repository: ExtensionRepoRepository,
|
||||
) {
|
||||
suspend fun await(baseUrl: String) {
|
||||
repository.deleteRepo(baseUrl)
|
||||
}
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
package mihon.domain.extensionrepo.interactor
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
|
||||
class GetExtensionRepo(
|
||||
private val repository: ExtensionRepoRepository,
|
||||
) {
|
||||
fun subscribeAll(): Flow<List<ExtensionRepo>> = repository.subscribeAll()
|
||||
|
||||
suspend fun getAll(): List<ExtensionRepo> = repository.getAll()
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
package mihon.domain.extensionrepo.interactor
|
||||
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
|
||||
class GetExtensionRepoCount(
|
||||
private val repository: ExtensionRepoRepository,
|
||||
) {
|
||||
fun subscribe() = repository.getCount()
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
package mihon.domain.extensionrepo.interactor
|
||||
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
|
||||
class ReplaceExtensionRepo(
|
||||
private val repository: ExtensionRepoRepository,
|
||||
) {
|
||||
suspend fun await(repo: ExtensionRepo) {
|
||||
repository.replaceRepo(repo)
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
package mihon.domain.extensionrepo.interactor
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import mihon.domain.extensionrepo.service.ExtensionRepoService
|
||||
|
||||
class UpdateExtensionRepo(
|
||||
private val repository: ExtensionRepoRepository,
|
||||
private val service: ExtensionRepoService,
|
||||
) {
|
||||
|
||||
suspend fun awaitAll() = coroutineScope {
|
||||
repository.getAll()
|
||||
.map { async { await(it) } }
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
suspend fun await(repo: ExtensionRepo) {
|
||||
val newRepo = service.fetchRepoDetails(repo.baseUrl) ?: return
|
||||
if (
|
||||
repo.signingKeyFingerprint.startsWith("NOFINGERPRINT") ||
|
||||
repo.signingKeyFingerprint == newRepo.signingKeyFingerprint
|
||||
) {
|
||||
repository.upsertRepo(newRepo)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
package mihon.domain.extensionrepo.model
|
||||
|
||||
data class ExtensionRepo(
|
||||
val baseUrl: String,
|
||||
val name: String,
|
||||
val shortName: String?,
|
||||
val website: String,
|
||||
val signingKeyFingerprint: String,
|
||||
)
|
@@ -0,0 +1,47 @@
|
||||
package mihon.domain.extensionrepo.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
|
||||
interface ExtensionRepoRepository {
|
||||
|
||||
fun subscribeAll(): Flow<List<ExtensionRepo>>
|
||||
|
||||
suspend fun getAll(): List<ExtensionRepo>
|
||||
|
||||
suspend fun getRepo(baseUrl: String): ExtensionRepo?
|
||||
|
||||
suspend fun getRepoBySigningKeyFingerprint(fingerprint: String): ExtensionRepo?
|
||||
|
||||
fun getCount(): Flow<Int>
|
||||
|
||||
suspend fun insertRepo(
|
||||
baseUrl: String,
|
||||
name: String,
|
||||
shortName: String?,
|
||||
website: String,
|
||||
signingKeyFingerprint: String,
|
||||
)
|
||||
|
||||
suspend fun upsertRepo(
|
||||
baseUrl: String,
|
||||
name: String,
|
||||
shortName: String?,
|
||||
website: String,
|
||||
signingKeyFingerprint: String,
|
||||
)
|
||||
|
||||
suspend fun upsertRepo(repo: ExtensionRepo) {
|
||||
upsertRepo(
|
||||
baseUrl = repo.baseUrl,
|
||||
name = repo.name,
|
||||
shortName = repo.shortName,
|
||||
website = repo.website,
|
||||
signingKeyFingerprint = repo.signingKeyFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun replaceRepo(newRepo: ExtensionRepo)
|
||||
|
||||
suspend fun deleteRepo(baseUrl: String)
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
package mihon.domain.extensionrepo.service
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
|
||||
@Serializable
|
||||
data class ExtensionRepoMetaDto(
|
||||
val meta: ExtensionRepoDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExtensionRepoDto(
|
||||
val name: String,
|
||||
val shortName: String?,
|
||||
val website: String,
|
||||
val signingKeyFingerprint: String,
|
||||
)
|
||||
|
||||
fun ExtensionRepoMetaDto.toExtensionRepo(baseUrl: String): ExtensionRepo {
|
||||
return ExtensionRepo(
|
||||
baseUrl = baseUrl,
|
||||
name = meta.name,
|
||||
shortName = meta.shortName,
|
||||
website = meta.website,
|
||||
signingKeyFingerprint = meta.signingKeyFingerprint,
|
||||
)
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
package mihon.domain.extensionrepo.service
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
class ExtensionRepoService(
|
||||
networkHelper: NetworkHelper,
|
||||
private val json: Json,
|
||||
) {
|
||||
val client = networkHelper.client
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
suspend fun fetchRepoDetails(
|
||||
repo: String,
|
||||
): ExtensionRepo? {
|
||||
return withIOContext {
|
||||
val url = "$repo/repo.json".toUri()
|
||||
|
||||
try {
|
||||
with(json) {
|
||||
client.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<ExtensionRepoMetaDto>()
|
||||
.toExtensionRepo(baseUrl = repo)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to fetch repo details" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
package mihon.domain.upcoming.interactor
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
|
||||
class GetUpcomingManga(
|
||||
private val mangaRepository: MangaRepository,
|
||||
) {
|
||||
|
||||
private val includedStatuses = setOf(
|
||||
SManga.ONGOING.toLong(),
|
||||
SManga.PUBLISHING_FINISHED.toLong(),
|
||||
)
|
||||
|
||||
suspend fun subscribe(): Flow<List<Manga>> {
|
||||
return mangaRepository.getUpcomingManga(includedStatuses)
|
||||
}
|
||||
}
|
@@ -25,6 +25,8 @@ interface MangaRepository {
|
||||
|
||||
suspend fun getDuplicateLibraryManga(id: Long, title: String): List<Manga>
|
||||
|
||||
suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>>
|
||||
|
||||
suspend fun resetViewerFlags(): Boolean
|
||||
|
||||
suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>)
|
||||
|
@@ -4,10 +4,13 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import tachiyomi.domain.source.model.StubSource
|
||||
|
||||
interface SourceManager {
|
||||
|
||||
val isInitialized: StateFlow<Boolean>
|
||||
|
||||
val catalogueSources: Flow<List<CatalogueSource>>
|
||||
|
||||
fun get(sourceKey: Long): Source?
|
||||
|
@@ -1,5 +1,7 @@
|
||||
package tachiyomi.domain.track.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class Track(
|
||||
val id: Long,
|
||||
val mangaId: Long,
|
||||
@@ -14,4 +16,4 @@ data class Track(
|
||||
val remoteUrl: String,
|
||||
val startDate: Long,
|
||||
val finishDate: Long,
|
||||
)
|
||||
) : Serializable
|
||||
|
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp_version = "8.3.0"
|
||||
agp_version = "8.3.1"
|
||||
lifecycle_version = "2.7.0"
|
||||
paging_version = "3.2.1"
|
||||
|
||||
|
@@ -1,9 +1,11 @@
|
||||
[versions]
|
||||
compiler = "1.5.10"
|
||||
compiler = "1.5.11"
|
||||
compose-bom = "2024.02.00-alpha02"
|
||||
accompanist = "0.35.0-alpha"
|
||||
|
||||
[libraries]
|
||||
compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compiler" }
|
||||
|
||||
activity = "androidx.activity:activity-compose:1.8.2"
|
||||
bom = { group = "dev.chrisbanes.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
@@ -21,5 +23,4 @@ material-core = { module = "androidx.compose.material:material" }
|
||||
|
||||
glance = "androidx.glance:glance-appwidget:1.0.0"
|
||||
|
||||
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
|
||||
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user