mirror of
https://github.com/mihonapp/mihon.git
synced 2025-10-23 19:48:54 +02:00
Merge branch 'master' into sync-part-final
This commit is contained in:
@@ -78,6 +78,7 @@ android {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
matchingFallbacks.add("release")
|
||||
isDebuggable = false
|
||||
isProfileable = true
|
||||
versionNameSuffix = "-benchmark"
|
||||
applicationIdSuffix = ".benchmark"
|
||||
}
|
||||
|
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -8,6 +8,7 @@
|
||||
-keep,allowoptimization class kotlin.** { public protected *; }
|
||||
-keep,allowoptimization class kotlinx.coroutines.** { public protected *; }
|
||||
-keep,allowoptimization class kotlinx.serialization.** { public protected *; }
|
||||
-keep,allowoptimization class kotlin.time.** { public protected *; }
|
||||
-keep,allowoptimization class okhttp3.** { public protected *; }
|
||||
-keep,allowoptimization class okio.** { public protected *; }
|
||||
-keep,allowoptimization class rx.** { public protected *; }
|
||||
|
@@ -44,11 +44,6 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Tachiyomi">
|
||||
|
||||
<!-- enable profiling by macrobenchmark -->
|
||||
<profileable
|
||||
android:shell="true"
|
||||
tools:targetApi="q" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
@@ -124,8 +119,8 @@
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.setting.track.AnilistLoginActivity"
|
||||
android:label="Anilist"
|
||||
android:name=".ui.setting.track.TrackLoginActivity"
|
||||
android:label="@string/track_activity_name"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -133,54 +128,12 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="anilist-auth"
|
||||
android:scheme="tachiyomi" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.setting.track.MyAnimeListLoginActivity"
|
||||
android:label="MyAnimeList"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:host="anilist-auth"/>
|
||||
<data android:host="bangumi-auth"/>
|
||||
<data android:host="myanimelist-auth"/>
|
||||
<data android:host="shikimori-auth"/>
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="myanimelist-auth"
|
||||
android:scheme="tachiyomi" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.setting.track.ShikimoriLoginActivity"
|
||||
android:label="Shikimori"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="shikimori-auth"
|
||||
android:scheme="tachiyomi" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.setting.track.BangumiLoginActivity"
|
||||
android:label="Bangumi"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="bangumi-auth"
|
||||
android:scheme="tachiyomi" />
|
||||
<data android:scheme="tachiyomi"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
@@ -224,7 +177,8 @@
|
||||
android:name=".data.updater.AppUpdateService"
|
||||
android:exported="false" />
|
||||
|
||||
<service android:name=".extension.util.ExtensionInstallService"
|
||||
<service
|
||||
android:name=".extension.util.ExtensionInstallService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -24,5 +24,6 @@ class BasePreferences(
|
||||
LEGACY(R.string.ext_installer_legacy),
|
||||
PACKAGEINSTALLER(R.string.ext_installer_packageinstaller),
|
||||
SHIZUKU(R.string.ext_installer_shizuku),
|
||||
PRIVATE(R.string.ext_installer_private),
|
||||
}
|
||||
}
|
||||
|
@@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
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.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
@@ -176,7 +174,8 @@ private fun ExtensionDetails(
|
||||
data = Uri.fromParts("package", extension.pkgName, null)
|
||||
context.startActivity(this)
|
||||
}
|
||||
},
|
||||
Unit
|
||||
}.takeIf { extension.isShared },
|
||||
onClickAgeRating = {
|
||||
showNsfwWarning = true
|
||||
},
|
||||
@@ -209,7 +208,7 @@ private fun DetailsHeader(
|
||||
extension: Extension,
|
||||
onClickAgeRating: () -> Unit,
|
||||
onClickUninstall: () -> Unit,
|
||||
onClickAppInfo: () -> Unit,
|
||||
onClickAppInfo: (() -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -293,6 +292,7 @@ private fun DetailsHeader(
|
||||
top = MaterialTheme.padding.small,
|
||||
bottom = MaterialTheme.padding.medium,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -301,16 +301,16 @@ private fun DetailsHeader(
|
||||
Text(stringResource(R.string.ext_uninstall))
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
if (onClickAppInfo != null) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,5 @@
|
||||
package eu.kanade.presentation.browse.components
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +30,7 @@ import eu.kanade.domain.source.model.icon
|
||||
import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.domain.source.model.Source
|
||||
import tachiyomi.source.local.isLocal
|
||||
@@ -127,7 +127,7 @@ private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): St
|
||||
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
|
||||
withIOContext {
|
||||
value = try {
|
||||
val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
val appResources = context.packageManager.getResourcesForApplication(appInfo)
|
||||
Result.Success(
|
||||
appResources.getDrawableForDensity(appInfo.icon, density, null)!!
|
||||
|
@@ -0,0 +1,76 @@
|
||||
package eu.kanade.presentation.reader
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
|
||||
@Composable
|
||||
fun BottomReaderBar(
|
||||
readingMode: ReadingModeType,
|
||||
onClickReadingMode: () -> Unit,
|
||||
orientationMode: OrientationType,
|
||||
onClickOrientationMode: () -> Unit,
|
||||
cropEnabled: Boolean,
|
||||
onClickCropBorder: () -> Unit,
|
||||
onClickSettings: () -> Unit,
|
||||
) {
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
val backgroundColor = MaterialTheme.colorScheme
|
||||
.surfaceColorAtElevation(3.dp)
|
||||
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onClickReadingMode) {
|
||||
Icon(
|
||||
painter = painterResource(readingMode.iconRes),
|
||||
contentDescription = stringResource(R.string.viewer),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClickCropBorder) {
|
||||
Icon(
|
||||
painter = painterResource(if (cropEnabled) R.drawable.ic_crop_24dp else R.drawable.ic_crop_off_24dp),
|
||||
contentDescription = stringResource(R.string.pref_crop_borders),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClickOrientationMode) {
|
||||
Icon(
|
||||
painter = painterResource(orientationMode.iconRes),
|
||||
contentDescription = stringResource(R.string.pref_rotation_type),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClickSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = stringResource(R.string.action_settings),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -53,6 +53,15 @@ fun ChapterNavigator(
|
||||
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
val backgroundColor = MaterialTheme.colorScheme
|
||||
.surfaceColorAtElevation(3.dp)
|
||||
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
|
||||
val buttonColor = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = backgroundColor,
|
||||
disabledContainerColor = backgroundColor,
|
||||
)
|
||||
|
||||
// We explicitly handle direction based on the reader viewer rather than the system direction
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(
|
||||
@@ -61,14 +70,6 @@ fun ChapterNavigator(
|
||||
.padding(horizontal = horizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
val backgroundColor = MaterialTheme.colorScheme
|
||||
.surfaceColorAtElevation(3.dp)
|
||||
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
|
||||
val buttonColor = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = backgroundColor,
|
||||
disabledContainerColor = backgroundColor,
|
||||
)
|
||||
FilledIconButton(
|
||||
enabled = if (isRtl) enabledNext else enabledPrevious,
|
||||
onClick = if (isRtl) onNextChapter else onPreviousChapter,
|
||||
|
@@ -0,0 +1,56 @@
|
||||
package eu.kanade.presentation.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.domain.manga.model.orientationType
|
||||
import eu.kanade.presentation.components.AdaptiveSheet
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
|
||||
import tachiyomi.presentation.core.components.SettingsChipRow
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
||||
private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it }
|
||||
|
||||
@Composable
|
||||
fun OrientationModeSelectDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
screenModel: ReaderSettingsScreenModel,
|
||||
onChange: (Int) -> Unit,
|
||||
) {
|
||||
val manga by screenModel.mangaFlow.collectAsState()
|
||||
val orientationType = remember(manga) { OrientationType.fromPreference(manga?.orientationType?.toInt()) }
|
||||
|
||||
AdaptiveSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||
) {
|
||||
SettingsChipRow(R.string.rotation_type) {
|
||||
orientationTypeOptions.map { (stringRes, it) ->
|
||||
FilterChip(
|
||||
selected = it == orientationType,
|
||||
onClick = {
|
||||
screenModel.onChangeOrientation(it)
|
||||
onChange(stringRes)
|
||||
},
|
||||
label = { Text(stringResource(stringRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
package eu.kanade.presentation.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
@@ -0,0 +1,56 @@
|
||||
package eu.kanade.presentation.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.domain.manga.model.readingModeType
|
||||
import eu.kanade.presentation.components.AdaptiveSheet
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import tachiyomi.presentation.core.components.SettingsChipRow
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
||||
private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it }
|
||||
|
||||
@Composable
|
||||
fun ReadingModeSelectDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
screenModel: ReaderSettingsScreenModel,
|
||||
onChange: (Int) -> Unit,
|
||||
) {
|
||||
val manga by screenModel.mangaFlow.collectAsState()
|
||||
val readingMode = remember(manga) { ReadingModeType.fromPreference(manga?.readingModeType?.toInt()) }
|
||||
|
||||
AdaptiveSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||
) {
|
||||
SettingsChipRow(R.string.pref_category_reading_mode) {
|
||||
readingModeOptions.map { (stringRes, it) ->
|
||||
FilterChip(
|
||||
selected = it == readingMode,
|
||||
onClick = {
|
||||
screenModel.onChangeReadingMode(it)
|
||||
onChange(stringRes)
|
||||
},
|
||||
label = { Text(stringResource(stringRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -42,11 +42,12 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
|
||||
pref = screenModel.preferences.fullscreen(),
|
||||
)
|
||||
|
||||
// TODO: hide if there's no cutout
|
||||
CheckboxItem(
|
||||
label = stringResource(R.string.pref_cutout_short),
|
||||
pref = screenModel.preferences.cutoutShort(),
|
||||
)
|
||||
if (screenModel.hasDisplayCutout) {
|
||||
CheckboxItem(
|
||||
label = stringResource(R.string.pref_cutout_short),
|
||||
pref = screenModel.preferences.cutoutShort(),
|
||||
)
|
||||
}
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(R.string.pref_keep_screen_on),
|
||||
|
@@ -3,6 +3,7 @@ package eu.kanade.presentation.util
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.network.HttpException
|
||||
import eu.kanade.tachiyomi.source.online.LicensedMangaChaptersException
|
||||
import tachiyomi.data.source.NoResultsException
|
||||
import tachiyomi.domain.source.model.SourceNotInstalledException
|
||||
|
||||
@@ -13,6 +14,7 @@ val Throwable.formattedMessage: String
|
||||
is NoResultsException -> return getString(R.string.no_results_found)
|
||||
is SourceNotInstalledException -> return getString(R.string.loader_not_implemented_error)
|
||||
is HttpException -> return "$message: ${getString(R.string.http_error_hint)}"
|
||||
is LicensedMangaChaptersException -> return getString(R.string.licensed_manga_chapters_error)
|
||||
}
|
||||
return when (val className = this::class.simpleName) {
|
||||
"Exception", "IOException" -> message ?: className
|
||||
|
@@ -106,10 +106,10 @@ class BackupManager(
|
||||
UniFile.fromUri(context, uri)
|
||||
}
|
||||
)
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
?: throw Exception(context.getString(R.string.create_backup_file_error))
|
||||
|
||||
if (!file.isFile) {
|
||||
throw IllegalStateException("Failed to get handle on file")
|
||||
throw IllegalStateException("Failed to get handle on a backup file")
|
||||
}
|
||||
|
||||
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
|
||||
|
@@ -230,8 +230,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val restrictions = libraryPreferences.libraryUpdateMangaRestriction().get()
|
||||
|
||||
val fetchWindow by lazy { setFetchInterval.getWindow(ZonedDateTime.now()) }
|
||||
val fetchWindow = setFetchInterval.getWindow(ZonedDateTime.now())
|
||||
|
||||
coroutineScope {
|
||||
mangaToUpdate.groupBy { it.manga.source }.values
|
||||
@@ -265,7 +264,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
|
||||
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_started))
|
||||
|
||||
MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate !in fetchWindow.first.rangeTo(fetchWindow.second) ->
|
||||
MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate > fetchWindow.second ->
|
||||
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_in_release_period))
|
||||
|
||||
else -> {
|
||||
|
@@ -5,7 +5,6 @@ import androidx.annotation.CallSuper
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
|
||||
import eu.kanade.domain.track.model.toDbTrack
|
||||
import eu.kanade.domain.track.model.toDomainTrack
|
||||
@@ -31,9 +30,9 @@ import tachiyomi.domain.track.model.Track as DomainTrack
|
||||
|
||||
abstract class TrackService(val id: Long) {
|
||||
|
||||
val preferences: BasePreferences by injectLazy()
|
||||
val trackPreferences: TrackPreferences by injectLazy()
|
||||
val networkService: NetworkHelper by injectLazy()
|
||||
private val insertTrack: InsertTrack by injectLazy()
|
||||
|
||||
open val client: OkHttpClient
|
||||
get() = networkService.client
|
||||
@@ -112,7 +111,7 @@ abstract class TrackService(val id: Long) {
|
||||
|
||||
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
|
||||
|
||||
Injekt.get<InsertTrack>().await(track)
|
||||
insertTrack.await(track)
|
||||
|
||||
// Update chapter progress if newer chapters marked read locally
|
||||
if (hasReadChapters) {
|
||||
@@ -120,7 +119,7 @@ abstract class TrackService(val id: Long) {
|
||||
.sortedBy { it.chapterNumber }
|
||||
.takeWhile { it.read }
|
||||
.lastOrNull()
|
||||
?.chapterNumber?.toDouble() ?: -1.0
|
||||
?.chapterNumber ?: -1.0
|
||||
|
||||
if (latestLocalReadChapterNumber > track.lastChapterRead) {
|
||||
track = track.copy(
|
||||
@@ -169,6 +168,7 @@ abstract class TrackService(val id: Long) {
|
||||
track.last_chapter_read = chapterNumber.toFloat()
|
||||
if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) {
|
||||
track.status = getCompletionStatus()
|
||||
track.finished_reading_date = System.currentTimeMillis()
|
||||
}
|
||||
withIOContext { updateRemote(track) }
|
||||
}
|
||||
@@ -193,7 +193,7 @@ abstract class TrackService(val id: Long) {
|
||||
try {
|
||||
update(track)
|
||||
track.toDomainTrack(idRequired = false)?.let {
|
||||
Injekt.get<InsertTrack>().await(it)
|
||||
insertTrack.await(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" }
|
||||
|
@@ -27,7 +27,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|
||||
@@ -35,7 +35,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|
||||
private val authClient = client.newBuilder()
|
||||
.addInterceptor(interceptor)
|
||||
.rateLimit(permits = 85, period = 1, unit = TimeUnit.MINUTES)
|
||||
.rateLimit(permits = 85, period = 1.minutes)
|
||||
.build()
|
||||
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
|
@@ -66,7 +66,10 @@ class ExtensionManager(
|
||||
fun getAppIconForSource(sourceId: Long): Drawable? {
|
||||
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
|
||||
if (pkgName != null) {
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
|
||||
ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
|
||||
.loadIcon(context.packageManager)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -333,6 +336,7 @@ class ExtensionManager(
|
||||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
unregisterExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
@@ -32,6 +32,7 @@ sealed class Extension {
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
val isShared: Boolean,
|
||||
) : Extension()
|
||||
|
||||
data class Available(
|
||||
|
@@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
@@ -27,7 +30,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,9 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(ACTION_EXTENSION_ADDED)
|
||||
addAction(ACTION_EXTENSION_REPLACED)
|
||||
addAction(ACTION_EXTENSION_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
@@ -49,7 +55,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
@@ -60,7 +66,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
@@ -70,7 +76,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
@@ -121,4 +127,30 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||
fun onExtensionUntrusted(extension: Extension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_ADDED"
|
||||
private const val ACTION_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REPLACED"
|
||||
private const val ACTION_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REMOVED"
|
||||
|
||||
fun notifyAdded(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_ADDED)
|
||||
}
|
||||
|
||||
fun notifyReplaced(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_REPLACED)
|
||||
}
|
||||
|
||||
fun notifyRemoved(context: Context, pkgName: String) {
|
||||
notify(context, pkgName, ACTION_EXTENSION_REMOVED)
|
||||
}
|
||||
|
||||
private fun notify(context: Context, pkgName: String, action: String) {
|
||||
Intent(action).apply {
|
||||
data = Uri.parse("package:$pkgName")
|
||||
`package` = context.packageName
|
||||
context.sendBroadcast(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -11,10 +11,12 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -156,6 +158,35 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
||||
val extensionManager = Injekt.get<ExtensionManager>()
|
||||
val tempFile = File(context.cacheDir, "temp_$downloadId")
|
||||
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
// Unlikely but just in case
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
|
||||
} else {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
|
||||
tempFile.delete()
|
||||
}
|
||||
else -> {
|
||||
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
@@ -178,10 +209,15 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
* @param pkgName The package name of the extension to uninstall
|
||||
*/
|
||||
fun uninstallApk(pkgName: String) {
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(intent)
|
||||
if (context.isPackageInstalled(pkgName)) {
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
ExtensionInstallReceiver.notifyRemoved(context, pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,7 +1,7 @@
|
||||
package eu.kanade.tachiyomi.extension.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
@@ -14,17 +14,28 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import eu.kanade.tachiyomi.util.system.getApplicationIcon
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
* Class that handles the loading of the extensions. Supports two kinds of extensions:
|
||||
*
|
||||
* 1. Shared extension: This extension is installed to the system with package
|
||||
* installer, so other variants of Tachiyomi and its forks can also use this extension.
|
||||
*
|
||||
* 2. Private extension: This extension is put inside private data directory of the
|
||||
* running app, so this extension can only be used by the running app and not shared
|
||||
* with other apps.
|
||||
*
|
||||
* When both kinds of extensions are installed with a same package name, shared
|
||||
* extension will be used unless the version codes are different. In that case the
|
||||
* one with higher version code will be used.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
internal object ExtensionLoader {
|
||||
|
||||
private val preferences: SourcePreferences by injectLazy()
|
||||
@@ -38,15 +49,14 @@ internal object ExtensionLoader {
|
||||
private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||
private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme"
|
||||
private const val METADATA_HAS_CHANGELOG = "tachiyomi.extension.hasChangelog"
|
||||
const val LIB_VERSION_MIN = 1.3
|
||||
const val LIB_VERSION_MIN = 1.4
|
||||
const val LIB_VERSION_MAX = 1.5
|
||||
|
||||
private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA or
|
||||
PackageManager.GET_SIGNATURES or
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
|
||||
|
||||
// inorichi's key
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||
@@ -56,8 +66,57 @@ internal object ExtensionLoader {
|
||||
*/
|
||||
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get()
|
||||
|
||||
private const val PRIVATE_EXTENSION_EXTENSION = "ext"
|
||||
|
||||
private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
|
||||
|
||||
fun installPrivateExtensionFile(context: Context, file: File): Boolean {
|
||||
val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) } ?: return false
|
||||
val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName)
|
||||
|
||||
if (currentExtension != null) {
|
||||
if (PackageInfoCompat.getLongVersionCode(extension) <
|
||||
PackageInfoCompat.getLongVersionCode(currentExtension)
|
||||
) {
|
||||
logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." }
|
||||
return false
|
||||
}
|
||||
|
||||
val extensionSignatures = getSignatures(extension)
|
||||
if (extensionSignatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.ERROR) { "Extension to be installed is not signed." }
|
||||
return false
|
||||
}
|
||||
|
||||
if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) {
|
||||
logcat(LogPriority.ERROR) { "Installed extension signature is not matched." }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
|
||||
return try {
|
||||
file.copyTo(target, overwrite = true)
|
||||
if (currentExtension != null) {
|
||||
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
|
||||
} else {
|
||||
ExtensionInstallReceiver.notifyAdded(context, extension.packageName)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to copy extension file." }
|
||||
target.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPrivateExtension(context: Context, pkgName: String) {
|
||||
File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
* Return a list of all the available extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
@@ -70,16 +129,43 @@ internal object ExtensionLoader {
|
||||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
val sharedExtPkgs = installedPkgs
|
||||
.asSequence()
|
||||
.filter { isPackageAnExtension(it) }
|
||||
.map { ExtensionInfo(packageInfo = it, isShared = true) }
|
||||
|
||||
val privateExtPkgs = getPrivateExtensionDir(context)
|
||||
.listFiles()
|
||||
?.asSequence()
|
||||
?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
|
||||
?.mapNotNull {
|
||||
val path = it.absolutePath
|
||||
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
|
||||
?.apply { applicationInfo.fixBasePaths(path) }
|
||||
}
|
||||
?.filter { isPackageAnExtension(it) }
|
||||
?.map { ExtensionInfo(packageInfo = it, isShared = false) }
|
||||
?: emptySequence()
|
||||
|
||||
val extPkgs = (sharedExtPkgs + privateExtPkgs)
|
||||
// Remove duplicates. Shared takes priority than private by default
|
||||
.distinctBy { it.packageInfo.packageName }
|
||||
// Compare version number
|
||||
.mapNotNull { sharedPkg ->
|
||||
val privatePkg = privateExtPkgs
|
||||
.singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
|
||||
selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
.toList()
|
||||
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadExtension(context, it.packageName, it) }
|
||||
async { loadExtension(context, it) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
deferred.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,37 +174,61 @@ internal object ExtensionLoader {
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||
val pkgInfo = try {
|
||||
val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
|
||||
if (extensionPackage == null) {
|
||||
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
||||
return LoadResult.Error
|
||||
}
|
||||
return loadExtension(context, extensionPackage)
|
||||
}
|
||||
|
||||
fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
||||
return getExtensionInfoFromPkgName(context, pkgName)?.packageInfo
|
||||
}
|
||||
|
||||
private fun getExtensionInfoFromPkgName(context: Context, pkgName: String): ExtensionInfo? {
|
||||
val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION")
|
||||
val privatePkg = if (privateExtensionFile.isFile) {
|
||||
context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath)
|
||||
ExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val sharedPkg = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
ExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = true,
|
||||
)
|
||||
}
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return LoadResult.Error
|
||||
null
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return LoadResult.Error
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
|
||||
return selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
* Loads an extension
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
* @param extensionInfo The extension to load.
|
||||
*/
|
||||
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult {
|
||||
private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val appInfo = try {
|
||||
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return LoadResult.Error
|
||||
}
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
val appInfo = pkgInfo.applicationInfo
|
||||
val pkgName = pkgInfo.packageName
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
@@ -139,12 +249,19 @@ internal object ExtensionLoader {
|
||||
return LoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(context, pkgInfo)
|
||||
if (signatureHash == null) {
|
||||
val signatures = getSignatures(pkgInfo)
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return LoadResult.Error
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash)
|
||||
} else if (!hasTrustedSignature(signatures)) {
|
||||
val extension = Extension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
versionName,
|
||||
versionCode,
|
||||
libVersion,
|
||||
signatures.last(),
|
||||
)
|
||||
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
||||
return LoadResult.Untrusted(extension)
|
||||
}
|
||||
@@ -204,12 +321,35 @@ internal object ExtensionLoader {
|
||||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
isUnofficial = !isOfficiallySigned(signatures),
|
||||
icon = appInfo.loadIcon(pkgManager),
|
||||
isShared = extensionInfo.isShared,
|
||||
)
|
||||
return LoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which extension package to use based on version code
|
||||
*
|
||||
* @param shared extension installed to system
|
||||
* @param private extension installed to data directory
|
||||
*/
|
||||
private fun selectExtensionPackage(shared: ExtensionInfo?, private: ExtensionInfo?): ExtensionInfo? {
|
||||
when {
|
||||
private == null && shared != null -> return shared
|
||||
shared == null && private != null -> return private
|
||||
shared == null && private == null -> return null
|
||||
}
|
||||
|
||||
return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >=
|
||||
PackageInfoCompat.getLongVersionCode(private!!.packageInfo)
|
||||
) {
|
||||
shared
|
||||
} else {
|
||||
private
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
@@ -220,12 +360,50 @@ internal object ExtensionLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
* Returns the signatures of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
* @return List SHA256 digest of the signatures
|
||||
*/
|
||||
private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? {
|
||||
val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName)
|
||||
return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) }
|
||||
private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val signingInfo = pkgInfo.signingInfo
|
||||
if (signingInfo.hasMultipleSigners()) {
|
||||
signingInfo.apkContentsSigners
|
||||
} else {
|
||||
signingInfo.signingCertificateHistory
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pkgInfo.signatures
|
||||
}
|
||||
?.map { Hash.sha256(it.toByteArray()) }
|
||||
?.toList()
|
||||
}
|
||||
|
||||
private fun hasTrustedSignature(signatures: List<String>): Boolean {
|
||||
return trustedSignatures.any { signatures.contains(it) }
|
||||
}
|
||||
|
||||
private fun isOfficiallySigned(signatures: List<String>): Boolean {
|
||||
return signatures.all { it == officialSignature }
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't
|
||||
* have sourceDir which breaks assets loading (used for getting icon here).
|
||||
*/
|
||||
private fun ApplicationInfo.fixBasePaths(apkPath: String) {
|
||||
if (sourceDir == null) {
|
||||
sourceDir = apkPath
|
||||
}
|
||||
if (publicSourceDir == null) {
|
||||
publicSourceDir = apkPath
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExtensionInfo(
|
||||
val packageInfo: PackageInfo,
|
||||
val isShared: Boolean,
|
||||
)
|
||||
}
|
||||
|
@@ -4,28 +4,36 @@ import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.track.interactor.GetTracks
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
data class MigrationFlag(
|
||||
val flag: Int,
|
||||
val isDefaultSelected: Boolean,
|
||||
val titleId: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun create(flag: Int, defaultSelectionMap: Int, titleId: Int): MigrationFlag {
|
||||
return MigrationFlag(
|
||||
flag = flag,
|
||||
isDefaultSelected = defaultSelectionMap and flag != 0,
|
||||
titleId = titleId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object MigrationFlags {
|
||||
|
||||
private const val CHAPTERS = 0b00001
|
||||
private const val CATEGORIES = 0b00010
|
||||
private const val TRACK = 0b00100
|
||||
private const val CUSTOM_COVER = 0b01000
|
||||
private const val DELETE_DOWNLOADED = 0b10000
|
||||
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
private val getTracks: GetTracks = Injekt.get()
|
||||
private val downloadCache: DownloadCache by injectLazy()
|
||||
|
||||
val flags get() = arrayOf(CHAPTERS, CATEGORIES, TRACK, CUSTOM_COVER, DELETE_DOWNLOADED)
|
||||
private var enableFlags = emptyList<Int>().toMutableList()
|
||||
|
||||
fun hasChapters(value: Int): Boolean {
|
||||
return value and CHAPTERS != 0
|
||||
}
|
||||
@@ -34,10 +42,6 @@ object MigrationFlags {
|
||||
return value and CATEGORIES != 0
|
||||
}
|
||||
|
||||
fun hasTracks(value: Int): Boolean {
|
||||
return value and TRACK != 0
|
||||
}
|
||||
|
||||
fun hasCustomCover(value: Int): Boolean {
|
||||
return value and CUSTOM_COVER != 0
|
||||
}
|
||||
@@ -46,34 +50,32 @@ object MigrationFlags {
|
||||
return value and DELETE_DOWNLOADED != 0
|
||||
}
|
||||
|
||||
fun getEnabledFlagsPositions(value: Int): List<Int> {
|
||||
return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null }
|
||||
}
|
||||
/** Returns information about applicable flags with default selections. */
|
||||
fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List<MigrationFlag> {
|
||||
val flags = mutableListOf<MigrationFlag>()
|
||||
flags += MigrationFlag.create(CHAPTERS, defaultSelectedBitMap, R.string.chapters)
|
||||
flags += MigrationFlag.create(CATEGORIES, defaultSelectedBitMap, R.string.categories)
|
||||
|
||||
fun getFlagsFromPositions(positions: Array<Int>): Int {
|
||||
val fold = positions.fold(0) { accumulated, position -> accumulated or enableFlags[position] }
|
||||
enableFlags.clear()
|
||||
return fold
|
||||
}
|
||||
|
||||
fun titles(manga: Manga?): Array<Int> {
|
||||
enableFlags.add(CHAPTERS)
|
||||
enableFlags.add(CATEGORIES)
|
||||
val titles = arrayOf(R.string.chapters, R.string.categories).toMutableList()
|
||||
if (manga != null) {
|
||||
if (runBlocking { getTracks.await(manga.id) }.isNotEmpty()) {
|
||||
titles.add(R.string.track)
|
||||
enableFlags.add(TRACK)
|
||||
}
|
||||
if (manga.hasCustomCover(coverCache)) {
|
||||
titles.add(R.string.custom_cover)
|
||||
enableFlags.add(CUSTOM_COVER)
|
||||
flags += MigrationFlag.create(CUSTOM_COVER, defaultSelectedBitMap, R.string.custom_cover)
|
||||
}
|
||||
if (downloadCache.getDownloadCount(manga) > 0) {
|
||||
titles.add(R.string.delete_downloaded)
|
||||
enableFlags.add(DELETE_DOWNLOADED)
|
||||
flags += MigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, R.string.delete_downloaded)
|
||||
}
|
||||
}
|
||||
return titles.toTypedArray()
|
||||
return flags
|
||||
}
|
||||
|
||||
/** Returns a bit map of selected flags. */
|
||||
fun getSelectedFlagsBitMap(
|
||||
selectedFlags: List<Boolean>,
|
||||
flags: List<MigrationFlag>,
|
||||
): Int {
|
||||
return selectedFlags
|
||||
.zip(flags)
|
||||
.filter { (isSelected, _) -> isSelected }
|
||||
.map { (_, flag) -> flag.flag }
|
||||
.reduceOrNull { acc, mask -> acc or mask } ?: 0
|
||||
}
|
||||
}
|
||||
|
@@ -19,15 +19,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
@@ -74,15 +73,8 @@ internal fun MigrateDialog(
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
val activeFlags = remember { MigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) }
|
||||
val items = remember {
|
||||
MigrationFlags.titles(oldManga)
|
||||
.map { context.getString(it) }
|
||||
.toList()
|
||||
}
|
||||
val selected = remember {
|
||||
mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray())
|
||||
}
|
||||
val flags = remember { MigrationFlags.getFlags(oldManga, screenModel.migrateFlags.get()) }
|
||||
val selectedFlags = remember { flags.map { it.isDefaultSelected }.toMutableStateList() }
|
||||
|
||||
if (state.isMigrating) {
|
||||
LoadingScreen(
|
||||
@@ -99,18 +91,16 @@ internal fun MigrateDialog(
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
items.forEachIndexed { index, title ->
|
||||
val onChange: () -> Unit = {
|
||||
selected[index] = !selected[index]
|
||||
}
|
||||
flags.forEachIndexed { index, flag ->
|
||||
val onChange = { selectedFlags[index] = !selectedFlags[index] }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onChange),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = selected[index], onCheckedChange = { onChange() })
|
||||
Text(text = title)
|
||||
Checkbox(checked = selectedFlags[index], onCheckedChange = { onChange() })
|
||||
Text(text = context.getString(flag.titleId))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +123,12 @@ internal fun MigrateDialog(
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launchIO {
|
||||
screenModel.migrateManga(oldManga, newManga, false)
|
||||
screenModel.migrateManga(
|
||||
oldManga,
|
||||
newManga,
|
||||
false,
|
||||
MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
|
||||
)
|
||||
withUIContext { onPopScreen() }
|
||||
}
|
||||
},
|
||||
@@ -143,12 +138,13 @@ internal fun MigrateDialog(
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launchIO {
|
||||
val selectedIndices = mutableListOf<Int>()
|
||||
selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) }
|
||||
val newValue =
|
||||
MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray())
|
||||
screenModel.migrateFlags.set(newValue)
|
||||
screenModel.migrateManga(oldManga, newManga, true)
|
||||
screenModel.migrateManga(
|
||||
oldManga,
|
||||
newManga,
|
||||
true,
|
||||
MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
|
||||
)
|
||||
|
||||
withUIContext { onPopScreen() }
|
||||
}
|
||||
},
|
||||
@@ -184,7 +180,13 @@ internal class MigrateDialogScreenModel(
|
||||
Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>()
|
||||
}
|
||||
|
||||
suspend fun migrateManga(oldManga: Manga, newManga: Manga, replace: Boolean) {
|
||||
suspend fun migrateManga(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
replace: Boolean,
|
||||
flags: Int,
|
||||
) {
|
||||
migrateFlags.set(flags)
|
||||
val source = sourceManager.get(newManga.source) ?: return
|
||||
val prevSource = sourceManager.get(oldManga.source)
|
||||
|
||||
@@ -200,6 +202,7 @@ internal class MigrateDialogScreenModel(
|
||||
newManga = newManga,
|
||||
sourceChapters = chapters,
|
||||
replace = replace,
|
||||
flags = flags,
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// Explicitly stop if an error occurred; the dialog normally gets popped at the end
|
||||
@@ -215,12 +218,10 @@ internal class MigrateDialogScreenModel(
|
||||
newManga: Manga,
|
||||
sourceChapters: List<SChapter>,
|
||||
replace: Boolean,
|
||||
flags: Int,
|
||||
) {
|
||||
val flags = migrateFlags.get()
|
||||
|
||||
val migrateChapters = MigrationFlags.hasChapters(flags)
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateTracks = MigrationFlags.hasTracks(flags)
|
||||
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
|
||||
val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags)
|
||||
|
||||
@@ -271,21 +272,20 @@ internal class MigrateDialogScreenModel(
|
||||
}
|
||||
|
||||
// Update track
|
||||
if (migrateTracks) {
|
||||
val tracks = getTracks.await(oldManga.id).mapNotNull { track ->
|
||||
val updatedTrack = track.copy(mangaId = newManga.id)
|
||||
getTracks.await(oldManga.id).mapNotNull { track ->
|
||||
val updatedTrack = track.copy(mangaId = newManga.id)
|
||||
|
||||
val service = enhancedServices
|
||||
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
|
||||
val service = enhancedServices
|
||||
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
|
||||
|
||||
if (service != null) {
|
||||
service.migrateTrack(updatedTrack, newManga, newSource)
|
||||
} else {
|
||||
updatedTrack
|
||||
}
|
||||
if (service != null) {
|
||||
service.migrateTrack(updatedTrack, newManga, newSource)
|
||||
} else {
|
||||
updatedTrack
|
||||
}
|
||||
insertTrack.awaitAll(tracks)
|
||||
}
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { insertTrack.awaitAll(it) }
|
||||
|
||||
// Delete downloaded
|
||||
if (deleteDownloaded) {
|
||||
|
@@ -25,6 +25,7 @@ import android.view.animation.AnimationUtils
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -49,9 +50,12 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.transition.platform.MaterialContainerTransform
|
||||
import dev.chrisbanes.insetter.applyInsetter
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.model.orientationType
|
||||
import eu.kanade.presentation.reader.BottomReaderBar
|
||||
import eu.kanade.presentation.reader.ChapterNavigator
|
||||
import eu.kanade.presentation.reader.OrientationModeSelectDialog
|
||||
import eu.kanade.presentation.reader.PageIndicatorText
|
||||
import eu.kanade.presentation.reader.ReaderPageActionsDialog
|
||||
import eu.kanade.presentation.reader.ReadingModeSelectDialog
|
||||
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
@@ -78,10 +82,7 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout
|
||||
import eu.kanade.tachiyomi.util.system.isNightMode
|
||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.util.view.copy
|
||||
import eu.kanade.tachiyomi.util.view.popupMenu
|
||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||
import eu.kanade.tachiyomi.util.view.setTooltip
|
||||
import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -94,12 +95,12 @@ import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.Constants
|
||||
import tachiyomi.core.preference.toggle
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.math.abs
|
||||
@@ -124,7 +125,7 @@ class ReaderActivity : BaseActivity() {
|
||||
val viewModel by viewModels<ReaderViewModel>()
|
||||
private var assistUrl: String? = null
|
||||
|
||||
val hasCutout by lazy { hasDisplayCutout() }
|
||||
private val hasCutout by lazy { hasDisplayCutout() }
|
||||
|
||||
/**
|
||||
* Configuration at reader level, like background color or forced orientation.
|
||||
@@ -381,11 +382,14 @@ class ReaderActivity : BaseActivity() {
|
||||
|
||||
binding.pageNumber.setComposeContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val showPageNumber by viewModel.readerPreferences.showPageNumber().collectAsState()
|
||||
|
||||
PageIndicatorText(
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
)
|
||||
if (!state.menuVisible && showPageNumber) {
|
||||
PageIndicatorText(
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.dialogRoot.setComposeContent {
|
||||
@@ -393,6 +397,7 @@ class ReaderActivity : BaseActivity() {
|
||||
val settingsScreenModel = remember {
|
||||
ReaderSettingsScreenModel(
|
||||
readerState = viewModel.state,
|
||||
hasDisplayCutout = hasCutout,
|
||||
onChangeReadingMode = viewModel::setMangaReadingMode,
|
||||
onChangeOrientation = viewModel::setMangaOrientationType,
|
||||
)
|
||||
@@ -423,6 +428,28 @@ class ReaderActivity : BaseActivity() {
|
||||
screenModel = settingsScreenModel,
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.ReadingModeSelect -> {
|
||||
ReadingModeSelectDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
screenModel = settingsScreenModel,
|
||||
onChange = { stringRes ->
|
||||
menuToggleToast?.cancel()
|
||||
if (!readerPreferences.showReadingMode().get()) {
|
||||
menuToggleToast = toast(stringRes)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.OrientationModeSelect -> {
|
||||
OrientationModeSelectDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
screenModel = settingsScreenModel,
|
||||
onChange = { stringRes ->
|
||||
menuToggleToast?.cancel()
|
||||
menuToggleToast = toast(stringRes)
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.PageActions -> {
|
||||
ReaderPageActionsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
@@ -435,36 +462,61 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Init listeners on bottom menu
|
||||
binding.readerNav.setComposeContent {
|
||||
binding.readerMenuBottom.setComposeContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
if (state.viewer == null) return@setComposeContent
|
||||
val isRtl = state.viewer is R2LPagerViewer
|
||||
|
||||
ChapterNavigator(
|
||||
isRtl = isRtl,
|
||||
onNextChapter = ::loadNextChapter,
|
||||
enabledNext = state.viewerChapters?.nextChapter != null,
|
||||
onPreviousChapter = ::loadPreviousChapter,
|
||||
enabledPrevious = state.viewerChapters?.prevChapter != null,
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
onSliderValueChange = {
|
||||
isScrollingThroughPages = true
|
||||
moveToPageIndex(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
val cropBorderPaged by readerPreferences.cropBorders().collectAsState()
|
||||
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon().collectAsState()
|
||||
val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
|
||||
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
|
||||
|
||||
initBottomShortcuts()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ChapterNavigator(
|
||||
isRtl = isRtl,
|
||||
onNextChapter = ::loadNextChapter,
|
||||
enabledNext = state.viewerChapters?.nextChapter != null,
|
||||
onPreviousChapter = ::loadPreviousChapter,
|
||||
enabledPrevious = state.viewerChapters?.prevChapter != null,
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
onSliderValueChange = {
|
||||
isScrollingThroughPages = true
|
||||
moveToPageIndex(it)
|
||||
},
|
||||
)
|
||||
|
||||
BottomReaderBar(
|
||||
readingMode = ReadingModeType.fromPreference(viewModel.getMangaReadingMode(resolveDefault = false)),
|
||||
onClickReadingMode = viewModel::openReadingModeSelectDialog,
|
||||
orientationMode = OrientationType.fromPreference(viewModel.getMangaOrientationType(resolveDefault = false)),
|
||||
onClickOrientationMode = viewModel::openOrientationModeSelectDialog,
|
||||
cropEnabled = cropEnabled,
|
||||
onClickCropBorder = {
|
||||
val enabled = viewModel.toggleCropBorders()
|
||||
|
||||
menuToggleToast?.cancel()
|
||||
menuToggleToast = toast(
|
||||
if (enabled) {
|
||||
R.string.on
|
||||
} else {
|
||||
R.string.off
|
||||
},
|
||||
)
|
||||
},
|
||||
onClickSettings = viewModel::openSettingsDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val toolbarBackground = (binding.toolbar.background as MaterialShapeDrawable).apply {
|
||||
elevation = resources.getDimension(R.dimen.m3_sys_elevation_level2)
|
||||
alpha = if (isNightMode()) 230 else 242 // 90% dark 95% light
|
||||
}
|
||||
binding.toolbarBottom.background = toolbarBackground.copy(this@ReaderActivity)
|
||||
|
||||
val toolbarColor = ColorUtils.setAlphaComponent(
|
||||
toolbarBackground.resolvedTintColor,
|
||||
toolbarBackground.alpha,
|
||||
@@ -478,112 +530,6 @@ class ReaderActivity : BaseActivity() {
|
||||
setMenuVisibility(viewModel.state.value.menuVisible)
|
||||
}
|
||||
|
||||
private fun initBottomShortcuts() {
|
||||
// Reading mode
|
||||
with(binding.actionReadingMode) {
|
||||
setTooltip(R.string.viewer)
|
||||
|
||||
setOnClickListener {
|
||||
popupMenu(
|
||||
items = ReadingModeType.entries.map { it.flagValue to it.stringRes },
|
||||
selectedItemId = viewModel.getMangaReadingMode(resolveDefault = false),
|
||||
) {
|
||||
val newReadingMode = ReadingModeType.fromPreference(itemId)
|
||||
|
||||
viewModel.setMangaReadingMode(newReadingMode)
|
||||
|
||||
menuToggleToast?.cancel()
|
||||
if (!readerPreferences.showReadingMode().get()) {
|
||||
menuToggleToast = toast(newReadingMode.stringRes)
|
||||
}
|
||||
|
||||
updateCropBordersShortcut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crop borders
|
||||
with(binding.actionCropBorders) {
|
||||
setTooltip(R.string.pref_crop_borders)
|
||||
|
||||
setOnClickListener {
|
||||
val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
|
||||
val enabled = if (isPagerType) {
|
||||
readerPreferences.cropBorders().toggle()
|
||||
} else {
|
||||
readerPreferences.cropBordersWebtoon().toggle()
|
||||
}
|
||||
|
||||
menuToggleToast?.cancel()
|
||||
menuToggleToast = toast(
|
||||
if (enabled) {
|
||||
R.string.on
|
||||
} else {
|
||||
R.string.off
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
updateCropBordersShortcut()
|
||||
listOf(readerPreferences.cropBorders(), readerPreferences.cropBordersWebtoon())
|
||||
.forEach { pref ->
|
||||
pref.changes()
|
||||
.onEach { updateCropBordersShortcut() }
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
// Rotation
|
||||
with(binding.actionRotation) {
|
||||
setTooltip(R.string.rotation_type)
|
||||
|
||||
setOnClickListener {
|
||||
popupMenu(
|
||||
items = OrientationType.entries.map { it.flagValue to it.stringRes },
|
||||
selectedItemId = viewModel.manga?.orientationType?.toInt()
|
||||
?: readerPreferences.defaultOrientationType().get(),
|
||||
) {
|
||||
val newOrientation = OrientationType.fromPreference(itemId)
|
||||
|
||||
viewModel.setMangaOrientationType(newOrientation)
|
||||
|
||||
menuToggleToast?.cancel()
|
||||
menuToggleToast = toast(newOrientation.stringRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings sheet
|
||||
with(binding.actionSettings) {
|
||||
setTooltip(R.string.action_settings)
|
||||
|
||||
setOnClickListener {
|
||||
viewModel.openSettingsDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateOrientationShortcut(preference: Int) {
|
||||
val orientation = OrientationType.fromPreference(preference)
|
||||
binding.actionRotation.setImageResource(orientation.iconRes)
|
||||
}
|
||||
|
||||
private fun updateCropBordersShortcut() {
|
||||
val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
|
||||
val enabled = if (isPagerType) {
|
||||
readerPreferences.cropBorders().get()
|
||||
} else {
|
||||
readerPreferences.cropBordersWebtoon().get()
|
||||
}
|
||||
|
||||
binding.actionCropBorders.setImageResource(
|
||||
if (enabled) {
|
||||
R.drawable.ic_crop_24dp
|
||||
} else {
|
||||
R.drawable.ic_crop_off_24dp
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visibility of the menu according to [visible] and with an optional parameter to
|
||||
* [animate] the views.
|
||||
@@ -611,10 +557,6 @@ class ReaderActivity : BaseActivity() {
|
||||
bottomAnimation.applySystemAnimatorScale(this)
|
||||
binding.readerMenuBottom.startAnimation(bottomAnimation)
|
||||
}
|
||||
|
||||
if (readerPreferences.showPageNumber().get()) {
|
||||
config?.setPageNumberVisibility(false)
|
||||
}
|
||||
} else {
|
||||
if (readerPreferences.fullscreen().get()) {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
@@ -637,10 +579,6 @@ class ReaderActivity : BaseActivity() {
|
||||
bottomAnimation.applySystemAnimatorScale(this)
|
||||
binding.readerMenuBottom.startAnimation(bottomAnimation)
|
||||
}
|
||||
|
||||
if (readerPreferences.showPageNumber().get()) {
|
||||
config?.setPageNumberVisibility(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,13 +588,8 @@ class ReaderActivity : BaseActivity() {
|
||||
*/
|
||||
private fun setManga(manga: Manga) {
|
||||
val prevViewer = viewModel.state.value.viewer
|
||||
|
||||
val viewerMode = ReadingModeType.fromPreference(viewModel.getMangaReadingMode(resolveDefault = false))
|
||||
binding.actionReadingMode.setImageResource(viewerMode.iconRes)
|
||||
|
||||
val newViewer = ReadingModeType.toViewer(viewModel.getMangaReadingMode(), this)
|
||||
|
||||
updateCropBordersShortcut()
|
||||
if (window.sharedElementEnterTransition is MaterialContainerTransform) {
|
||||
// Wait until transition is complete to avoid crash on API 26
|
||||
window.sharedElementEnterTransition.doOnEnd {
|
||||
@@ -698,9 +631,8 @@ class ReaderActivity : BaseActivity() {
|
||||
|
||||
private fun showReadingModeToast(mode: Int) {
|
||||
try {
|
||||
val strings = resources.getStringArray(R.array.viewers_selector)
|
||||
readingModeToast?.cancel()
|
||||
readingModeToast = toast(strings[mode])
|
||||
readingModeToast = toast(ReadingModeType.fromPreference(mode).stringRes)
|
||||
} catch (e: ArrayIndexOutOfBoundsException) {
|
||||
logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" }
|
||||
}
|
||||
@@ -891,7 +823,6 @@ class ReaderActivity : BaseActivity() {
|
||||
if (newOrientation.flag != requestedOrientation) {
|
||||
requestedOrientation = newOrientation.flag
|
||||
}
|
||||
updateOrientationShortcut(viewModel.getMangaOrientationType(resolveDefault = false))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -955,10 +886,6 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.showPageNumber().changes()
|
||||
.onEach(::setPageNumberVisibility)
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.trueColor().changes()
|
||||
.onEach(::setTrueColor)
|
||||
.launchIn(lifecycleScope)
|
||||
@@ -1008,13 +935,6 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visibility of the bottom page indicator according to [visible].
|
||||
*/
|
||||
fun setPageNumberVisibility(visible: Boolean) {
|
||||
binding.pageNumber.isVisible = visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the 32-bit color mode according to [enabled].
|
||||
*/
|
||||
|
@@ -61,7 +61,7 @@ class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet)
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (navigation == null) return
|
||||
|
||||
navigation?.regions?.forEach { region ->
|
||||
navigation?.getRegions()?.forEach { region ->
|
||||
val rect = region.rectF
|
||||
|
||||
// Scale rect from 1f,1f to screen width and height
|
||||
|
@@ -54,6 +54,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.toggle
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
@@ -78,8 +79,8 @@ import java.util.Date
|
||||
/**
|
||||
* Presenter used by the activity to perform background operations.
|
||||
*/
|
||||
class ReaderViewModel(
|
||||
private val savedState: SavedStateHandle = SavedStateHandle(),
|
||||
class ReaderViewModel @JvmOverloads constructor(
|
||||
private val savedState: SavedStateHandle,
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val downloadProvider: DownloadProvider = Injekt.get(),
|
||||
@@ -119,6 +120,15 @@ class ReaderViewModel(
|
||||
field = value
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible page index of the currently loaded chapter. Used to restore from process kill.
|
||||
*/
|
||||
private var chapterPageIndex = savedState.get<Int>("page_index") ?: -1
|
||||
set(value) {
|
||||
savedState["page_index"] = value
|
||||
field = value
|
||||
}
|
||||
|
||||
/**
|
||||
* The chapter loader for the loaded manga. It'll be null until [manga] is set.
|
||||
*/
|
||||
@@ -197,7 +207,10 @@ class ReaderViewModel(
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.onEach { currentChapter ->
|
||||
if (!currentChapter.chapter.read) {
|
||||
if (chapterPageIndex >= 0) {
|
||||
// Restore from SavedState
|
||||
currentChapter.requestedPage = chapterPageIndex
|
||||
} else if (!currentChapter.chapter.read) {
|
||||
currentChapter.requestedPage = currentChapter.chapter.last_page_read
|
||||
}
|
||||
chapterId = currentChapter.chapter.id!!
|
||||
@@ -489,6 +502,7 @@ class ReaderViewModel(
|
||||
it.copy(currentPage = pageIndex + 1)
|
||||
}
|
||||
readerChapter.requestedPage = pageIndex
|
||||
chapterPageIndex = pageIndex
|
||||
|
||||
if (!incognitoMode && page.status != Page.State.ERROR) {
|
||||
readerChapter.chapter.last_page_read = pageIndex
|
||||
@@ -661,6 +675,15 @@ class ReaderViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCropBorders(): Boolean {
|
||||
val isPagerType = ReadingModeType.isPagerType(getMangaReadingMode())
|
||||
return if (isPagerType) {
|
||||
readerPreferences.cropBorders().toggle()
|
||||
} else {
|
||||
readerPreferences.cropBordersWebtoon().toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filename for the given [manga] and [page]
|
||||
*/
|
||||
@@ -683,6 +706,14 @@ class ReaderViewModel(
|
||||
mutableState.update { it.copy(dialog = Dialog.Loading) }
|
||||
}
|
||||
|
||||
fun openReadingModeSelectDialog() {
|
||||
mutableState.update { it.copy(dialog = Dialog.ReadingModeSelect) }
|
||||
}
|
||||
|
||||
fun openOrientationModeSelectDialog() {
|
||||
mutableState.update { it.copy(dialog = Dialog.OrientationModeSelect) }
|
||||
}
|
||||
|
||||
fun openPageDialog(page: ReaderPage) {
|
||||
mutableState.update { it.copy(dialog = Dialog.PageActions(page)) }
|
||||
}
|
||||
@@ -863,6 +894,8 @@ class ReaderViewModel(
|
||||
sealed interface Dialog {
|
||||
data object Loading : Dialog
|
||||
data object Settings : Dialog
|
||||
data object ReadingModeSelect : Dialog
|
||||
data object OrientationModeSelect : Dialog
|
||||
data class PageActions(val page: ReaderPage) : Dialog
|
||||
}
|
||||
|
||||
|
@@ -5,14 +5,14 @@ import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(0, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.label_default, R.drawable.ic_screen_rotation_24dp, 0x00000000),
|
||||
FREE(1, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp, 0x00000008),
|
||||
PORTRAIT(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000010),
|
||||
LANDSCAPE(3, ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_landscape, R.drawable.ic_stay_current_landscape_24dp, 0x00000018),
|
||||
LOCKED_PORTRAIT(4, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp, 0x00000020),
|
||||
LOCKED_LANDSCAPE(5, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp, 0x00000028),
|
||||
REVERSE_PORTRAIT(6, ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030),
|
||||
enum class OrientationType(val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.label_default, R.drawable.ic_screen_rotation_24dp, 0x00000000),
|
||||
FREE(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp, 0x00000008),
|
||||
PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000010),
|
||||
LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_landscape, R.drawable.ic_stay_current_landscape_24dp, 0x00000018),
|
||||
LOCKED_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp, 0x00000020),
|
||||
LOCKED_LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp, 0x00000028),
|
||||
REVERSE_PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, R.string.rotation_reverse_portrait, R.drawable.ic_stay_current_portrait_24dp, 0x00000030),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
@@ -13,6 +13,7 @@ import uy.kohesive.injekt.api.get
|
||||
|
||||
class ReaderSettingsScreenModel(
|
||||
readerState: StateFlow<ReaderViewModel.State>,
|
||||
val hasDisplayCutout: Boolean,
|
||||
val onChangeReadingMode: (ReadingModeType) -> Unit,
|
||||
val onChangeOrientation: (OrientationType) -> Unit,
|
||||
val preferences: ReaderPreferences = Injekt.get(),
|
||||
|
@@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
|
||||
enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(0, R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000),
|
||||
LEFT_TO_RIGHT(1, R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp, 0x00000001),
|
||||
RIGHT_TO_LEFT(2, R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp, 0x00000002),
|
||||
VERTICAL(3, R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003),
|
||||
WEBTOON(4, R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004),
|
||||
CONTINUOUS_VERTICAL(5, R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005),
|
||||
enum class ReadingModeType(@StringRes val stringRes: Int, @DrawableRes val iconRes: Int, val flagValue: Int) {
|
||||
DEFAULT(R.string.label_default, R.drawable.ic_reader_default_24dp, 0x00000000),
|
||||
LEFT_TO_RIGHT(R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp, 0x00000001),
|
||||
RIGHT_TO_LEFT(R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp, 0x00000002),
|
||||
VERTICAL(R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp, 0x00000003),
|
||||
WEBTOON(R.string.webtoon_viewer, R.drawable.ic_reader_webtoon_24dp, 0x00000004),
|
||||
CONTINUOUS_VERTICAL(R.string.vertical_plus_viewer, R.drawable.ic_reader_continuous_vertical_24dp, 0x00000005),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
@@ -32,15 +32,19 @@ abstract class ViewerNavigation {
|
||||
|
||||
private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f)
|
||||
|
||||
abstract var regions: List<Region>
|
||||
|
||||
var invertMode: ReaderPreferences.TappingInvertMode = ReaderPreferences.TappingInvertMode.NONE
|
||||
|
||||
protected abstract var regionList: List<Region>
|
||||
|
||||
/** Returns regions with applied inversion. */
|
||||
fun getRegions(): List<Region> {
|
||||
return regionList.map { it.invert(invertMode) }
|
||||
}
|
||||
|
||||
fun getAction(pos: PointF): NavigationRegion {
|
||||
val x = pos.x
|
||||
val y = pos.y
|
||||
val region = regions.map { it.invert(invertMode) }
|
||||
.find { it.rectF.contains(x, y) }
|
||||
val region = getRegions().find { it.rectF.contains(x, y) }
|
||||
return when {
|
||||
region != null -> region.type
|
||||
constantMenuRegion.contains(x, y) -> NavigationRegion.MENU
|
||||
|
@@ -14,5 +14,5 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
*/
|
||||
class DisabledNavigation : ViewerNavigation() {
|
||||
|
||||
override var regions: List<Region> = emptyList()
|
||||
override var regionList: List<Region> = emptyList()
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
*/
|
||||
class EdgeNavigation : ViewerNavigation() {
|
||||
|
||||
override var regions: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rectF = RectF(0f, 0f, 0.33f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
|
@@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
*/
|
||||
class KindlishNavigation : ViewerNavigation() {
|
||||
|
||||
override var regions: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rectF = RectF(0.33f, 0.33f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
|
@@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
*/
|
||||
open class LNavigation : ViewerNavigation() {
|
||||
|
||||
override var regions: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rectF = RectF(0f, 0.33f, 0.33f, 0.66f),
|
||||
type = NavigationRegion.PREV,
|
||||
|
@@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
*/
|
||||
class RightAndLeftNavigation : ViewerNavigation() {
|
||||
|
||||
override var regions: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rectF = RectF(0f, 0f, 0.33f, 1f),
|
||||
type = NavigationRegion.LEFT,
|
||||
|
@@ -1,22 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
|
||||
class AnilistLoginActivity : BaseOAuthLoginActivity() {
|
||||
|
||||
override fun handleResult(data: Uri?) {
|
||||
val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
|
||||
val matchResult = regex.find(data?.fragment.toString())
|
||||
if (matchResult?.groups?.get(1) != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.aniList.login(matchResult.groups[1]!!.value)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.aniList.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
|
||||
class BangumiLoginActivity : BaseOAuthLoginActivity() {
|
||||
|
||||
override fun handleResult(data: Uri?) {
|
||||
val code = data?.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.bangumi.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.bangumi.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
|
||||
class MyAnimeListLoginActivity : BaseOAuthLoginActivity() {
|
||||
|
||||
override fun handleResult(data: Uri?) {
|
||||
val code = data?.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.myAnimeList.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.myAnimeList.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
|
||||
class ShikimoriLoginActivity : BaseOAuthLoginActivity() {
|
||||
|
||||
override fun handleResult(data: Uri?) {
|
||||
val code = data?.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.shikimori.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.shikimori.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,70 @@
|
||||
package eu.kanade.tachiyomi.ui.setting.track
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
|
||||
class TrackLoginActivity : BaseOAuthLoginActivity() {
|
||||
|
||||
override fun handleResult(data: Uri?) {
|
||||
when (data?.host) {
|
||||
"anilist-auth" -> handleAnilist(data)
|
||||
"bangumi-auth" -> handleBangumi(data)
|
||||
"myanimelist-auth" -> handleMyAnimeList(data)
|
||||
"shikimori-auth" -> handleShikimori(data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAnilist(data: Uri) {
|
||||
val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
|
||||
val matchResult = regex.find(data.fragment.toString())
|
||||
if (matchResult?.groups?.get(1) != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.aniList.login(matchResult.groups[1]!!.value)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.aniList.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBangumi(data: Uri) {
|
||||
val code = data.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.bangumi.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.bangumi.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMyAnimeList(data: Uri) {
|
||||
val code = data.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.myAnimeList.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.myAnimeList.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShikimori(data: Uri) {
|
||||
val code = data.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
lifecycleScope.launchIO {
|
||||
trackManager.shikimori.login(code)
|
||||
returnToSettings()
|
||||
}
|
||||
} else {
|
||||
trackManager.shikimori.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,13 +3,16 @@ package eu.kanade.tachiyomi.util.chapter
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.source.local.isLocal
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* Returns a copy of the list with not downloaded chapters removed
|
||||
* Returns a copy of the list with not downloaded chapters removed.
|
||||
*/
|
||||
fun List<Chapter>.filterDownloaded(manga: Manga): List<Chapter> {
|
||||
if (manga.isLocal()) return this
|
||||
|
||||
val downloadCache: DownloadCache = Injekt.get()
|
||||
|
||||
return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source, false) }
|
||||
|
@@ -7,20 +7,13 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.core.net.toUri
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
@@ -35,7 +28,6 @@ import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Copies a string to clipboard
|
||||
@@ -69,25 +61,6 @@ fun Context.copyToClipboard(label: String, content: String) {
|
||||
*/
|
||||
fun Context.hasPermission(permission: String) = PermissionChecker.checkSelfPermission(this, permission) == PermissionChecker.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Returns the color for the given attribute.
|
||||
*
|
||||
* @param resource the attribute.
|
||||
* @param alphaFactor the alpha number [0,1].
|
||||
*/
|
||||
@ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int {
|
||||
val typedArray = obtainStyledAttributes(intArrayOf(resource))
|
||||
val color = typedArray.getColor(0, 0)
|
||||
typedArray.recycle()
|
||||
|
||||
if (alphaFactor < 1f) {
|
||||
val alpha = (color.alpha * alphaFactor).roundToInt()
|
||||
return Color.argb(alpha, color.red, color.green, color.blue)
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
|
@@ -2,11 +2,8 @@
|
||||
|
||||
package eu.kanade.tachiyomi.util.view
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -14,11 +11,7 @@ import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.view.menu.MenuBuilder
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -27,11 +20,8 @@ import androidx.compose.runtime.CompositionContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.core.view.forEach
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
|
||||
inline fun ComponentActivity.setComposeContent(
|
||||
parent: CompositionContext? = null,
|
||||
@@ -65,24 +55,6 @@ fun ComposeView.setComposeContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tooltip shown on long press.
|
||||
*
|
||||
* @param stringRes String resource for tooltip.
|
||||
*/
|
||||
inline fun View.setTooltip(@StringRes stringRes: Int) {
|
||||
setTooltip(context.getString(stringRes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tooltip shown on long press.
|
||||
*
|
||||
* @param text Text for tooltip.
|
||||
*/
|
||||
inline fun View.setTooltip(text: String) {
|
||||
TooltipCompat.setTooltipText(this, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a popup menu on top of this view.
|
||||
*
|
||||
@@ -110,57 +82,6 @@ inline fun View.popupMenu(
|
||||
return popup
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a popup menu on top of this view.
|
||||
*
|
||||
* @param items menu item names to inflate the menu with. List of itemId to stringRes pairs.
|
||||
* @param selectedItemId optionally show a checkmark beside an item with this itemId.
|
||||
* @param onMenuItemClick function to execute when a menu item is clicked.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
inline fun View.popupMenu(
|
||||
items: List<Pair<Int, Int>>,
|
||||
selectedItemId: Int? = null,
|
||||
noinline onMenuItemClick: MenuItem.() -> Unit,
|
||||
): PopupMenu {
|
||||
val popup = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0)
|
||||
items.forEach { (id, stringRes) ->
|
||||
popup.menu.add(0, id, 0, stringRes)
|
||||
}
|
||||
|
||||
if (selectedItemId != null) {
|
||||
(popup.menu as? MenuBuilder)?.setOptionalIconsVisible(true)
|
||||
val emptyIcon = AppCompatResources.getDrawable(context, R.drawable.ic_blank_24dp)
|
||||
popup.menu.forEach { item ->
|
||||
item.icon = when (item.itemId) {
|
||||
selectedItemId -> AppCompatResources.getDrawable(context, R.drawable.ic_check_24dp)?.mutate()?.apply {
|
||||
setTint(context.getResourceColor(android.R.attr.textColorPrimary))
|
||||
}
|
||||
else -> emptyIcon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popup.setOnMenuItemClickListener {
|
||||
it.onMenuItemClick()
|
||||
true
|
||||
}
|
||||
|
||||
popup.show()
|
||||
return popup
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of the provided [Drawable]
|
||||
*/
|
||||
inline fun <reified T : Drawable> T.copy(context: Context): T? {
|
||||
return (constantState?.newDrawable()?.mutate() as? T).apply {
|
||||
if (this is MaterialShapeDrawable) {
|
||||
initializeElevationOverlay(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun View?.isVisibleOnScreen(): Boolean {
|
||||
if (this == null) {
|
||||
return false
|
||||
|
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
|
||||
</vector>
|
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
|
||||
</vector>
|
@@ -1,5 +1,4 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
@@ -57,83 +56,11 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="?attr/actionBarSize" />
|
||||
|
||||
<LinearLayout
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/reader_menu_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/reader_nav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layoutDirection="ltr" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/toolbar_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_gravity="bottom"
|
||||
android:clickable="true"
|
||||
tools:ignore="KeyboardInaccessibleWidget">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/action_reading_mode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/viewer"
|
||||
android:padding="@dimen/screen_edge_margin"
|
||||
app:layout_constraintEnd_toStartOf="@id/action_crop_borders"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_reader_default_24dp"
|
||||
app:tint="?attr/colorOnSurface" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/action_crop_borders"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/pref_crop_borders"
|
||||
android:padding="@dimen/screen_edge_margin"
|
||||
app:layout_constraintEnd_toStartOf="@id/action_rotation"
|
||||
app:layout_constraintStart_toEndOf="@+id/action_reading_mode"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_crop_24dp"
|
||||
app:tint="?attr/colorOnSurface" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/action_rotation"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/pref_rotation_type"
|
||||
android:padding="@dimen/screen_edge_margin"
|
||||
app:layout_constraintEnd_toStartOf="@id/action_settings"
|
||||
app:layout_constraintStart_toEndOf="@+id/action_crop_borders"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_screen_rotation_24dp"
|
||||
app:tint="?attr/colorOnSurface" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/action_settings"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_settings"
|
||||
android:padding="@dimen/screen_edge_margin"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/action_rotation"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_settings_24dp"
|
||||
app:tint="?attr/colorOnSurface" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</LinearLayout>
|
||||
android:layout_gravity="bottom" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
|
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="viewers_selector">
|
||||
<item>@string/label_default</item>
|
||||
<item>@string/left_to_right_viewer</item>
|
||||
<item>@string/right_to_left_viewer</item>
|
||||
<item>@string/vertical_viewer</item>
|
||||
<item>@string/webtoon_viewer</item>
|
||||
<item>@string/vertical_plus_viewer</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="rotation_type">
|
||||
<item>@string/label_default</item>
|
||||
<item>@string/rotation_free</item>
|
||||
<item>@string/rotation_portrait</item>
|
||||
<item>@string/rotation_landscape</item>
|
||||
<item>@string/rotation_force_portrait</item>
|
||||
<item>@string/rotation_force_landscape</item>
|
||||
<item>@string/rotation_reverse_portrait</item>
|
||||
</string-array>
|
||||
</resources>
|
Reference in New Issue
Block a user