Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c81e8749b6 | ||
|
|
5fa260a0c7 | ||
|
|
e0ba4e2686 | ||
|
|
f188d1c0f3 | ||
|
|
6de55afa27 | ||
|
|
21dcb5b754 | ||
|
|
9b3ea57db1 | ||
|
|
032a8607ba | ||
|
|
f7303c5957 | ||
|
|
d696606ef9 | ||
|
|
0a6e106a1d | ||
|
|
de1a7f0ca8 | ||
|
|
9d31e76cc7 | ||
|
|
20910ffb5d | ||
|
|
7497ee6364 | ||
|
|
0f2ed50e18 | ||
|
|
ba066b577b | ||
|
|
4496fe876f | ||
|
|
a9f5abebf0 | ||
|
|
bebee2ef27 | ||
|
|
4ec2b0c8fe | ||
|
|
4a7be70898 | ||
|
|
2bcba1eb21 | ||
|
|
feca7ba3fc | ||
|
|
745b349e5e | ||
|
|
13946783a5 | ||
|
|
84e5400522 | ||
|
|
02c9a933d2 | ||
|
|
92af851d3b | ||
|
|
009eb9fe44 | ||
|
|
fc8a5ccd9f | ||
|
|
91f46de547 | ||
|
|
d548993e14 | ||
|
|
4f32664b33 | ||
|
|
71b14a3aa8 | ||
|
|
183a61272e |
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -61,4 +61,6 @@ body:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
required: true
|
||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
required: true
|
||||
|
||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -1,3 +1,4 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
|
||||
@@ -15,8 +15,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 33
|
||||
versionCode 554
|
||||
versionName '5.2.2'
|
||||
versionCode 559
|
||||
versionName '5.3.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -79,7 +79,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:86a82970fc') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:92bfc7e9fa') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -113,9 +113,10 @@ dependencies {
|
||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||
}
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.5.1'
|
||||
implementation 'androidx.room:room-ktx:2.5.1'
|
||||
kapt 'androidx.room:room-compiler:2.5.1'
|
||||
implementation 'androidx.room:room-runtime:2.5.2'
|
||||
implementation 'androidx.room:room-ktx:2.5.2'
|
||||
//noinspection KaptUsageInsteadOfKsp
|
||||
kapt 'androidx.room:room-compiler:2.5.2'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
||||
@@ -135,13 +136,13 @@ dependencies {
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
implementation 'ch.acra:acra-http:5.9.7'
|
||||
implementation 'ch.acra:acra-dialog:5.9.7'
|
||||
implementation 'ch.acra:acra-http:5.10.1'
|
||||
implementation 'ch.acra:acra-dialog:5.10.1'
|
||||
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20230227'
|
||||
testImplementation 'org.json:json:20230618'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
@@ -151,7 +152,7 @@ dependencies {
|
||||
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
|
||||
|
||||
androidTestImplementation 'androidx.room:room-testing:2.5.1'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.5.2'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
@@ -32,6 +48,7 @@
|
||||
android:largeHeap="true"
|
||||
android:localeConfig="@xml/locales"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kotatsu"
|
||||
@@ -98,6 +115,9 @@
|
||||
<data android:host="sync-settings" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
|
||||
android:label="@string/local_manga_directories" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
@@ -188,8 +208,7 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
|
||||
android:exported="false"
|
||||
android:label="@string/favourites"
|
||||
android:process=":sync">
|
||||
android:label="@string/favourites">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
@@ -200,8 +219,7 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
|
||||
android:exported="false"
|
||||
android:label="@string/history"
|
||||
android:process=":sync">
|
||||
android:label="@string/history">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@@ -34,8 +36,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -49,10 +50,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
val url = intent?.dataString.orEmpty()
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
|
||||
@@ -33,11 +33,21 @@ abstract class ErrorObserver(
|
||||
return resolver != null && ExceptionResolver.canResolve(error)
|
||||
}
|
||||
|
||||
private fun isAlive(): Boolean {
|
||||
return when {
|
||||
fragment != null -> fragment.view != null
|
||||
activity != null -> !activity.isDestroyed
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
protected fun resolve(error: Throwable) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
if (isAlive()) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
|
||||
class ToastErrorObserver(
|
||||
host: View,
|
||||
fragment: Fragment?,
|
||||
) : ErrorObserver(host, fragment, null, null) {
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
|
||||
toast.show()
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,14 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
|
||||
@JvmName("mangaIds")
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
|
||||
@JvmName("chaptersIds")
|
||||
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
@@ -10,11 +9,11 @@ import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import java.net.IDN
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -39,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
||||
val idn = IDN.toASCII(repository.domain)
|
||||
@@ -62,26 +61,4 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
|
||||
override fun request(): Request = request
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val userAgentFallback
|
||||
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
|
||||
BuildConfig.VERSION_NAME,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
Build.BRAND,
|
||||
Build.DEVICE,
|
||||
Locale.getDefault().language,
|
||||
)
|
||||
|
||||
val userAgentChrome
|
||||
get() = (
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
).format(
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.webkit.CookieManager
|
||||
import androidx.annotation.WorkerThread
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.newBuilder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -30,6 +31,21 @@ class AndroidCookieJar : MutableCookieJar {
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeCookies(url: HttpUrl) {
|
||||
val cookies = loadForRequest(url)
|
||||
if (cookies.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val urlString = url.toString()
|
||||
for (c in cookies) {
|
||||
val nc = c.newBuilder()
|
||||
.expiresAt(System.currentTimeMillis() - 100000)
|
||||
.build()
|
||||
cookieManager.setCookie(urlString, nc.toString())
|
||||
}
|
||||
check(loadForRequest(url).isEmpty())
|
||||
}
|
||||
|
||||
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
|
||||
cookieManager.removeAllCookies(continuation::resume)
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@ interface MutableCookieJar : CookieJar {
|
||||
@WorkerThread
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
|
||||
|
||||
@WorkerThread
|
||||
fun removeCookies(url: HttpUrl)
|
||||
|
||||
suspend fun clear(): Boolean
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class PreferencesCookieJar(
|
||||
private var isLoaded = false
|
||||
|
||||
@WorkerThread
|
||||
@Synchronized
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
loadPersistent()
|
||||
val expired = HashSet<String>()
|
||||
@@ -40,6 +41,7 @@ class PreferencesCookieJar(
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@Synchronized
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val wrapped = cookies.map { CookieWrapper(it) }
|
||||
prefs.edit(commit = true) {
|
||||
@@ -53,6 +55,22 @@ class PreferencesCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@WorkerThread
|
||||
override fun removeCookies(url: HttpUrl) {
|
||||
loadPersistent()
|
||||
val toRemove = HashSet<String>()
|
||||
for ((key, cookie) in cache) {
|
||||
if (cookie.isExpired() || cookie.cookie.matches(url)) {
|
||||
toRemove += key
|
||||
}
|
||||
}
|
||||
if (toRemove.isNotEmpty()) {
|
||||
cache.removeAll(toRemove)
|
||||
removePersistent(toRemove)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(): Boolean {
|
||||
cache.clear()
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -9,6 +9,6 @@ fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaPa
|
||||
return if (source == MangaSource.DUMMY) {
|
||||
DummyParser(loaderContext)
|
||||
} else {
|
||||
source.newParser(loaderContext)
|
||||
loaderContext.newParserInstance(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ import org.koitharu.kotatsu.core.util.ext.filterToSet
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
|
||||
import java.io.File
|
||||
@@ -234,14 +236,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
||||
|
||||
var userSpecifiedMangaDirectories: Set<File>
|
||||
get() {
|
||||
val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
|
||||
return set.mapNotNullToSet { File(it).takeIfReadable() }
|
||||
}
|
||||
set(value) {
|
||||
val set = value.mapToSet { it.absolutePath }
|
||||
prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
|
||||
}
|
||||
|
||||
var mangaStorageDir: File?
|
||||
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
}?.takeIf { it.exists() }
|
||||
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
|
||||
set(value) = prefs.edit {
|
||||
if (value == null) {
|
||||
remove(KEY_LOCAL_STORAGE)
|
||||
} else {
|
||||
val userDirs = userSpecifiedMangaDirectories
|
||||
if (value !in userDirs) {
|
||||
userSpecifiedMangaDirectories = userDirs + value
|
||||
}
|
||||
putString(KEY_LOCAL_STORAGE, value.path)
|
||||
}
|
||||
}
|
||||
@@ -461,6 +477,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.ActionBarContextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.ViewCompat
|
||||
@@ -103,8 +104,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
// ActivityCompat.recreate(this)
|
||||
error("Test")
|
||||
ActivityCompat.recreate(this)
|
||||
return true
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import java.io.File
|
||||
|
||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||
DialogInterface by delegate {
|
||||
|
||||
fun show() = delegate.show()
|
||||
|
||||
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
|
||||
|
||||
private val adapter = VolumesAdapter(storageManager)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
|
||||
init {
|
||||
if (adapter.isEmpty) {
|
||||
delegate.setMessage(R.string.cannot_find_available_storage)
|
||||
} else {
|
||||
val defaultValue = runBlocking {
|
||||
storageManager.getDefaultWriteableDir()
|
||||
}
|
||||
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
|
||||
it.first.canonicalPath == defaultValue?.canonicalPath
|
||||
}
|
||||
delegate.setAdapter(adapter) { d, i ->
|
||||
listener.onStorageSelected(adapter.getItem(i).first)
|
||||
d.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(@StringRes titleResId: Int): Builder {
|
||||
delegate.setTitle(titleResId)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setTitle(title: CharSequence): Builder {
|
||||
delegate.setTitle(title)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setNegativeButton(@StringRes textId: Int): Builder {
|
||||
delegate.setNegativeButton(textId, null)
|
||||
return this
|
||||
}
|
||||
|
||||
fun create() = StorageSelectDialog(delegate.create())
|
||||
}
|
||||
|
||||
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
|
||||
|
||||
var selectedItemPosition: Int = -1
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
|
||||
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||
view.tag = it
|
||||
}
|
||||
val item = volumes[position]
|
||||
binding.imageViewIndicator.isChecked = selectedItemPosition == position
|
||||
binding.textViewTitle.text = item.second
|
||||
binding.textViewSubtitle.text = item.first.path
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Pair<File, String> = volumes[position]
|
||||
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getCount() = volumes.size
|
||||
|
||||
override fun hasStableIds() = true
|
||||
|
||||
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
|
||||
return runBlocking {
|
||||
storageManager.getWriteableDirs().map {
|
||||
it to storageManager.getStorageDisplayName(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnStorageSelectListener {
|
||||
|
||||
fun onStorageSelected(file: File)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@@ -35,6 +36,18 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
|
||||
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
var title: CharSequence?
|
||||
get() = binding.title.text
|
||||
set(value) {
|
||||
binding.title.text = value
|
||||
}
|
||||
|
||||
var subtitle: CharSequence?
|
||||
get() = binding.subtitle.textAndVisible
|
||||
set(value) {
|
||||
binding.subtitle.textAndVisible = value
|
||||
}
|
||||
|
||||
init {
|
||||
var textColors: ColorStateList? = null
|
||||
context.withStyledAttributes(
|
||||
@@ -76,8 +89,7 @@ class TwoLinesItemView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun setIconResource(@DrawableRes resId: Int) {
|
||||
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
|
||||
binding.icon.setImageDrawable(icon)
|
||||
binding.icon.setImageResource(resId)
|
||||
}
|
||||
|
||||
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
fun interface BufferedObserver<T> {
|
||||
|
||||
fun onChanged(t: T, previous: T?)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.app.ActivityManager.MemoryInfo
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Context.ACTIVITY_SERVICE
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.ContextWrapper
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
@@ -17,6 +18,7 @@ import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
@@ -51,6 +53,9 @@ import kotlin.math.roundToLong
|
||||
val Context.activityManager: ActivityManager?
|
||||
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
|
||||
|
||||
val Context.powerManager: PowerManager?
|
||||
get() = getSystemService(POWER_SERVICE) as? PowerManager
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||
@@ -141,6 +146,10 @@ fun Context.isLowRamDevice(): Boolean {
|
||||
return activityManager?.isLowRamDevice ?: false
|
||||
}
|
||||
|
||||
fun Context.isPowerSaveMode(): Boolean {
|
||||
return powerManager?.isPowerSaveMode == true
|
||||
}
|
||||
|
||||
val Context.ramAvailable: Long
|
||||
get() {
|
||||
val result = MemoryInfo()
|
||||
|
||||
@@ -9,8 +9,8 @@ import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
|
||||
import java.io.Serializable
|
||||
|
||||
// https://issuetracker.google.com/issues/240585930
|
||||
@@ -36,11 +36,11 @@ inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String):
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? {
|
||||
return readParcelable(ParcelableMangaTags::class.java.classLoader) as T?
|
||||
return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Parcel.readSerializableCompat(): T? {
|
||||
return readSerializable() as T?
|
||||
return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
|
||||
@@ -49,12 +49,6 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
|
||||
return checkNotNull(getParcelableCompat(key)) {
|
||||
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SavedStateHandle.require(key: String): T {
|
||||
return checkNotNull(get(key)) {
|
||||
"Value $key not found in SavedStateHandle or has a wrong type"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import org.koitharu.kotatsu.parsers.util.removeSuffix
|
||||
import java.io.File
|
||||
import java.lang.reflect.Array as ArrayReflect
|
||||
|
||||
private const val PRIMARY_VOLUME_NAME = "primary"
|
||||
|
||||
fun Uri.resolveFile(context: Context): File? {
|
||||
val volumeId = getVolumeIdFromTreeUri(this) ?: return null
|
||||
val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null
|
||||
val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null
|
||||
|
||||
return File(
|
||||
if (documentPath.isNotEmpty()) {
|
||||
if (documentPath.startsWith(File.separator)) {
|
||||
volumePath + documentPath
|
||||
} else {
|
||||
volumePath + File.separator + documentPath
|
||||
}
|
||||
} else {
|
||||
volumePath
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVolumePath(volumeId: String, context: Context): String? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getVolumePathForAndroid11AndAbove(volumeId, context)
|
||||
} else {
|
||||
getVolumePathBeforeAndroid11(volumeId, context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching {
|
||||
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
|
||||
val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList")
|
||||
val getUuid = storageVolumeClazz.getMethod("getUuid")
|
||||
val getPath = storageVolumeClazz.getMethod("getPath")
|
||||
val isPrimary = storageVolumeClazz.getMethod("isPrimary")
|
||||
val result = getVolumeList.invoke(mStorageManager)
|
||||
val length = ArrayReflect.getLength(checkNotNull(result))
|
||||
(0 until length).firstNotNullOfOrNull { i ->
|
||||
val storageVolumeElement = ArrayReflect.get(result, i)
|
||||
val uuid = getUuid.invoke(storageVolumeElement) as String
|
||||
val primary = isPrimary.invoke(storageVolumeElement) as Boolean
|
||||
when {
|
||||
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String
|
||||
uuid == volumeId -> getPath.invoke(storageVolumeElement) as String
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.R)
|
||||
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
|
||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->
|
||||
if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) {
|
||||
volume.directory?.path
|
||||
} else {
|
||||
val uuid = volume.uuid
|
||||
if (uuid != null && uuid == volumeId) volume.directory?.path else null
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private fun getVolumeIdFromTreeUri(treeUri: Uri): String? {
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val split = docId.split(":".toRegex())
|
||||
return split.firstOrNull()?.takeUnless { it.isEmpty() }
|
||||
}
|
||||
|
||||
private fun getDocumentPathFromTreeUri(treeUri: Uri): String? {
|
||||
val docId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
return if (split.size >= 2 && split[1] != null) split[1] else File.separator
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.os.SystemClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
@@ -62,3 +63,23 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
flow: Flow<T1>,
|
||||
flow2: Flow<T2>,
|
||||
flow3: Flow<T3>,
|
||||
flow4: Flow<T4>,
|
||||
flow5: Flow<T5>,
|
||||
flow6: Flow<T6>,
|
||||
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
|
||||
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
|
||||
transform(
|
||||
args[0] as T1,
|
||||
args[1] as T2,
|
||||
args[2] as T3,
|
||||
args[3] as T4,
|
||||
args[4] as T5,
|
||||
args[5] as T6,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -38,3 +39,23 @@ fun Response.ensureSuccess() = apply {
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.name(name)
|
||||
c.value(value)
|
||||
if (persistent) {
|
||||
c.expiresAt(expiresAt)
|
||||
}
|
||||
if (hostOnly) {
|
||||
c.hostOnlyDomain(domain)
|
||||
} else {
|
||||
c.domain(domain)
|
||||
}
|
||||
c.path(path)
|
||||
if (secure) {
|
||||
c.secure()
|
||||
}
|
||||
if (httpOnly) {
|
||||
c.httpOnly()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
|
||||
fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) {
|
||||
try {
|
||||
setNavigationIcon(iconRes)
|
||||
} catch (e: IllegalStateException) {
|
||||
if (retry) {
|
||||
post { setNavigationIconSafe(iconRes, retry = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,13 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -115,6 +116,9 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return false
|
||||
}
|
||||
if (context.isPowerSaveMode()) {
|
||||
return false
|
||||
}
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
|
||||
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class ChaptersBottomSheetMediator(
|
||||
}
|
||||
|
||||
override fun onActionModeStarted(mode: ActionMode) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
lock()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
@@ -66,6 +67,9 @@ class ChaptersFragment :
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
}
|
||||
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
|
||||
selectionController?.onItemLongClick(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
@@ -211,7 +212,7 @@ class DetailsActivity :
|
||||
}
|
||||
if (isExpanded) {
|
||||
toolbar.addMenuProvider(chaptersMenuProvider)
|
||||
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
|
||||
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
|
||||
} else {
|
||||
toolbar.removeMenuProvider(chaptersMenuProvider)
|
||||
toolbar.navigationIcon = null
|
||||
|
||||
@@ -16,12 +16,11 @@ import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||
|
||||
@@ -30,7 +29,7 @@ class DetailsMenuProvider(
|
||||
private val viewModel: DetailsViewModel,
|
||||
private val snackbarHost: View,
|
||||
private val appShortcutManager: AppShortcutManager,
|
||||
) : MenuProvider {
|
||||
) : MenuProvider, OnListItemClickListener<DownloadOption> {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_details, menu)
|
||||
@@ -44,7 +43,7 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
menu.findItem(R.id.action_favourite).setIcon(
|
||||
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,15 +79,7 @@ class DetailsMenuProvider(
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
viewModel.manga.value?.let {
|
||||
val chaptersCount = it.chapters?.size ?: 0
|
||||
val branches = viewModel.branches.value.orEmpty()
|
||||
if (chaptersCount > 5 || branches.size > 1) {
|
||||
showSaveConfirmation(it, chaptersCount, branches)
|
||||
} else {
|
||||
viewModel.download(null)
|
||||
}
|
||||
}
|
||||
DownloadDialogHelper(snackbarHost, viewModel).show(this)
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
@@ -125,35 +116,16 @@ class DetailsMenuProvider(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
if (branches.size > 1) {
|
||||
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
|
||||
val currentBranch = branches.indexOfFirst { it.isSelected }
|
||||
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
|
||||
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
|
||||
checkedIndices[i] = checked
|
||||
}.setPositiveButton(R.string.save) { _, _ ->
|
||||
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
|
||||
if (checkedIndices[i]) b.name else null
|
||||
}
|
||||
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
|
||||
if (c.branch in selectedBranches) c.id else null
|
||||
}
|
||||
viewModel.download(chaptersIds)
|
||||
}
|
||||
} else {
|
||||
dialogBuilder.setMessage(
|
||||
activity.getString(
|
||||
R.string.large_manga_save_confirm,
|
||||
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
|
||||
),
|
||||
).setPositiveButton(R.string.save) { _, _ ->
|
||||
viewModel.download(null)
|
||||
override fun onItemClick(item: DownloadOption, view: View) {
|
||||
val chaptersIds: Set<Long>? = when (item) {
|
||||
is DownloadOption.WholeManga -> null
|
||||
is DownloadOption.SelectionHint -> {
|
||||
viewModel.startChaptersSelection()
|
||||
return
|
||||
}
|
||||
|
||||
else -> item.chaptersIds
|
||||
}
|
||||
dialogBuilder.show()
|
||||
viewModel.download(chaptersIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||
@@ -72,6 +74,7 @@ class DetailsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
|
||||
networkState: NetworkState,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
@@ -81,6 +84,7 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
val onShowToast = MutableEventFlow<Int>()
|
||||
val onShowTip = MutableEventFlow<Unit>()
|
||||
val onSelectChapter = MutableEventFlow<Long>()
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
|
||||
val manga = doubleManga.map { it?.any }
|
||||
@@ -176,8 +180,9 @@ class DetailsViewModel @Inject constructor(
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
) { manga, history, branch, news, bookmarks ->
|
||||
mapChapters(manga?.remote, manga?.local, history, news, branch, bookmarks)
|
||||
networkState,
|
||||
) { manga, history, branch, news, bookmarks, isOnline ->
|
||||
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
|
||||
},
|
||||
isChaptersReversed,
|
||||
chaptersQuery,
|
||||
@@ -286,6 +291,14 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun startChaptersSelection() {
|
||||
val chapters = chapters.value
|
||||
val chapter = chapters.find {
|
||||
it.isUnread && !it.isDownloaded
|
||||
} ?: chapters.firstOrNull() ?: return
|
||||
onSelectChapter.call(chapter.chapter.id)
|
||||
}
|
||||
|
||||
fun onButtonTipClosed() {
|
||||
settings.closeTip(DetailsActivity.TIP_BUTTON)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
|
||||
|
||||
class DownloadDialogHelper(
|
||||
private val host: View,
|
||||
private val viewModel: DetailsViewModel,
|
||||
) {
|
||||
|
||||
fun show(callback: OnListItemClickListener<DownloadOption>) {
|
||||
val branch = viewModel.selectedBranchValue
|
||||
val allChapters = viewModel.manga.value?.chapters ?: return
|
||||
val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty()
|
||||
val history = viewModel.history.value
|
||||
|
||||
val options = buildList {
|
||||
add(DownloadOption.WholeManga(allChapters.ids()))
|
||||
if (branch != null && branchChapters.isNotEmpty()) {
|
||||
add(DownloadOption.AllChapters(branch, branchChapters.ids()))
|
||||
}
|
||||
|
||||
if (history != null) {
|
||||
val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId }
|
||||
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
|
||||
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
|
||||
if (unreadChapters.size > 5) {
|
||||
add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids()))
|
||||
if (unreadChapters.size > 10) {
|
||||
add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids()))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (branchChapters.size > 5) {
|
||||
add(DownloadOption.FirstChapters(branchChapters.take(5).ids()))
|
||||
if (branchChapters.size > 10) {
|
||||
add(DownloadOption.FirstChapters(branchChapters.take(10).ids()))
|
||||
}
|
||||
}
|
||||
}
|
||||
add(DownloadOption.SelectionHint())
|
||||
}
|
||||
var dialog: DialogInterface? = null
|
||||
val listener = OnListItemClickListener<DownloadOption> { item, _ ->
|
||||
callback.onItemClick(item, host)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context)
|
||||
.addAdapterDelegate(downloadOptionAD(listener))
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.download)
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setItems(options)
|
||||
.create()
|
||||
.also { it.show() }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
|
||||
|
||||
override fun getItemCount() = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> DetailsFragment()
|
||||
1 -> ChaptersFragment()
|
||||
else -> throw IndexOutOfBoundsException("No fragment for position $position")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
sealed interface DownloadOption {
|
||||
|
||||
val chaptersIds: Set<Long>
|
||||
|
||||
@get:DrawableRes
|
||||
val iconResId: Int
|
||||
|
||||
val chaptersCount: Int
|
||||
get() = chaptersIds.size
|
||||
|
||||
fun getLabel(resources: Resources): CharSequence
|
||||
|
||||
class AllChapters(
|
||||
val branch: String,
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_select_group
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_all_chapters, branch)
|
||||
}
|
||||
}
|
||||
|
||||
class WholeManga(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_whole_manga)
|
||||
}
|
||||
}
|
||||
|
||||
class FirstChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_start
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(
|
||||
R.string.download_option_first_n_chapters,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
.lowercase(Locale.getDefault()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AllUnreadChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
val branch: String?,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_end
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return if (branch == null) {
|
||||
resources.getString(R.string.download_option_all_unread)
|
||||
} else {
|
||||
resources.getString(R.string.download_option_all_unread_b, branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NextUnreadChapters(
|
||||
override val chaptersIds: Set<Long>,
|
||||
) : DownloadOption {
|
||||
|
||||
override val iconResId = R.drawable.ic_list_next
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(
|
||||
R.string.download_option_next_unread_n_chapters,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
.lowercase(Locale.getDefault()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SelectionHint : DownloadOption {
|
||||
|
||||
override val chaptersIds: Set<Long> = emptySet()
|
||||
override val iconResId = R.drawable.ic_tap
|
||||
|
||||
override fun getLabel(resources: Resources): CharSequence {
|
||||
return resources.getString(R.string.download_option_manual_selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.download.ui.dialog
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding
|
||||
|
||||
fun downloadOptionAD(
|
||||
onClickListener: OnListItemClickListener<DownloadOption>,
|
||||
) = adapterDelegateViewBinding<DownloadOption, DownloadOption, ItemDownloadOptionBinding>(
|
||||
{ layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
with(binding.root) {
|
||||
title = item.getLabel(resources)
|
||||
subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
item.chaptersCount,
|
||||
item.chaptersCount,
|
||||
)
|
||||
setIconResource(item.iconResId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
@@ -35,7 +36,6 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.text.Collator
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
@@ -49,7 +49,7 @@ class FilterCoordinator @Inject constructor(
|
||||
dataRepository: MangaDataRepository,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
) : FilterOwner {
|
||||
) : MangaFilter {
|
||||
|
||||
private val coroutineScope = lifecycle.lifecycleScope
|
||||
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
|
||||
|
||||
@@ -19,9 +19,8 @@ import com.google.android.material.R as materialR
|
||||
|
||||
class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsView.OnChipClickListener {
|
||||
|
||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
||||
FilterOwner.from(requireActivity())
|
||||
}
|
||||
private val filter: MangaFilter
|
||||
get() = (requireActivity() as FilterOwner).filter
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding {
|
||||
return FragmentFilterHeaderBinding.inflate(inflater, container, false)
|
||||
@@ -30,7 +29,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.chipsTags.onChipClickListener = this
|
||||
owner.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
filter.header.observe(viewLifecycleOwner, ::onDataChanged)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
@@ -40,7 +39,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
if (tag == null) {
|
||||
FilterSheetFragment.show(parentFragmentManager)
|
||||
} else {
|
||||
owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
|
||||
filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.values
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
interface FilterOwner {
|
||||
|
||||
interface FilterOwner : OnFilterChangedListener {
|
||||
|
||||
val filterItems: StateFlow<List<ListModel>>
|
||||
|
||||
val header: StateFlow<FilterHeaderModel>
|
||||
|
||||
fun applyFilter(tags: Set<MangaTag>)
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(activity: FragmentActivity): FilterOwner {
|
||||
for (f in activity.supportFragmentManager.fragments) {
|
||||
return find(f) ?: continue
|
||||
}
|
||||
error("Cannot find FilterOwner")
|
||||
}
|
||||
|
||||
fun find(fragment: Fragment): FilterOwner? {
|
||||
return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner }
|
||||
}
|
||||
}
|
||||
val filter: MangaFilter
|
||||
}
|
||||
|
||||
@@ -21,20 +21,17 @@ class FilterSheetFragment :
|
||||
AdaptiveSheetCallback,
|
||||
AsyncListDiffer.ListListener<ListModel> {
|
||||
|
||||
private val owner by lazy(LazyThreadSafetyMode.NONE) {
|
||||
FilterOwner.from(requireActivity())
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val filter = (requireActivity() as FilterOwner).filter
|
||||
addSheetCallback(this)
|
||||
val adapter = FilterAdapter(owner, this)
|
||||
val adapter = FilterAdapter(filter, this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
owner.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
filter.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
|
||||
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
binding.recyclerView.scrollIndicators = 0
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.filter.ui
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
interface MangaFilter : OnFilterChangedListener {
|
||||
|
||||
val filterItems: StateFlow<List<ListModel>>
|
||||
|
||||
val header: StateFlow<FilterHeaderModel>
|
||||
|
||||
fun applyFilter(tags: Set<MangaTag>)
|
||||
}
|
||||
@@ -45,6 +45,13 @@ class HistoryRepository @Inject constructor(
|
||||
return entity.manga.toManga(entity.tags.toMangaTags())
|
||||
}
|
||||
|
||||
fun observeLast(): Flow<Manga?> {
|
||||
return db.historyDao.observeAll(1).map {
|
||||
val first = it.firstOrNull()
|
||||
first?.manga?.toManga(first.tags.toMangaTags())
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAll(): Flow<List<Manga>> {
|
||||
return db.historyDao.observeAll().mapItems {
|
||||
it.manga.toManga(it.tags.toMangaTags())
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package org.koitharu.kotatsu.history.domain
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
class HistoryUpdateUseCase @Inject constructor(
|
||||
@@ -30,7 +32,9 @@ class HistoryUpdateUseCase @Inject constructor(
|
||||
percent: Float
|
||||
) = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
runCatchingCancellable {
|
||||
invoke(manga, readerState, percent)
|
||||
withContext(NonCancellable) {
|
||||
invoke(manga, readerState, percent)
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FilenameFilter
|
||||
import java.util.EnumSet
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -192,7 +193,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||
files.map { file ->
|
||||
async(dispatcher) {
|
||||
runCatchingCancellable { LocalMangaInput.of(file).getManga() }.getOrNull()
|
||||
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
|
||||
}
|
||||
}.awaitAll()
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
|
||||
@@ -2,8 +2,11 @@ package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.net.toFile
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -13,11 +16,14 @@ import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
private const val NOMEDIA = ".nomedia"
|
||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||
@@ -74,14 +80,42 @@ class LocalStorageManager @Inject constructor(
|
||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
||||
}
|
||||
|
||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
||||
suspend fun getApplicationStorageDirs(): Set<File> = runInterruptible(Dispatchers.IO) {
|
||||
getAvailableStorageDirs()
|
||||
}
|
||||
|
||||
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
|
||||
if (uri.scheme == "file") {
|
||||
uri.toFile()
|
||||
} else {
|
||||
uri.resolveFile(context)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) {
|
||||
File(dir, NOMEDIA).createNewFile()
|
||||
}
|
||||
|
||||
fun takePermissions(uri: Uri) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
contentResolver.takePersistableUriPermission(uri, flags)
|
||||
}
|
||||
|
||||
suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) {
|
||||
val packageName = context.packageName
|
||||
if (dir.absolutePath.contains(packageName)) {
|
||||
dir.getStorageName(context)
|
||||
} else if (isFullPath) {
|
||||
dir.path
|
||||
} else {
|
||||
dir.name
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||
val set = getAvailableStorageDirs()
|
||||
settings.mangaStorageDir?.let {
|
||||
set.add(it)
|
||||
}
|
||||
set.addAll(settings.userSpecifiedMangaDirectories)
|
||||
return set
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
@@ -30,6 +31,12 @@ sealed class LocalMangaInput(
|
||||
else -> LocalMangaZipInput(file)
|
||||
}
|
||||
|
||||
fun ofOrNull(file: File): LocalMangaInput? = when {
|
||||
file.isDirectory -> LocalMangaDirInput(file)
|
||||
CbzFilter.isFileSupported(file.name) -> LocalMangaZipInput(file)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun zipUri(file: File, entryName: String): String =
|
||||
Uri.fromParts("cbz", file.path, entryName).toString()
|
||||
|
||||
@@ -35,18 +35,6 @@ class LocalMangaUtil(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun writeIndex(index: MangaIndex) {
|
||||
newOutput().use { output ->
|
||||
when (output) {
|
||||
is LocalMangaDirOutput -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
is LocalMangaZipOutput -> TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
|
||||
val file = manga.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
|
||||
@@ -85,7 +85,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
.setOngoing(true)
|
||||
.setOngoing(false)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.core.net.toUri
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
@@ -20,21 +19,21 @@ import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
|
||||
class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||
|
||||
override val viewModel by viewModels<LocalListViewModel>()
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = viewModel
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
|
||||
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
|
||||
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
||||
}
|
||||
|
||||
@@ -46,7 +45,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||
FilterSheetFragment.show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
override fun onScrolledToEnd() = viewModel.loadNextPage()
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_local, menu)
|
||||
@@ -71,24 +70,6 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
||||
}
|
||||
}
|
||||
|
||||
override val filterItems: StateFlow<List<ListModel>>
|
||||
get() = viewModel.filterItems
|
||||
|
||||
override val header: StateFlow<FilterHeaderModel>
|
||||
get() = viewModel.header
|
||||
|
||||
override fun applyFilter(tags: Set<MangaTag>) {
|
||||
viewModel.applyFilter(tags)
|
||||
}
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
viewModel.onSortItemClick(item)
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
viewModel.onTagItemClick(item)
|
||||
}
|
||||
|
||||
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setTitle(R.string.delete_manga)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||
|
||||
class LocalListMenuProvider(
|
||||
private val context: Context,
|
||||
private val onImportClick: Function0<Unit>,
|
||||
) : MenuProvider {
|
||||
|
||||
@@ -20,6 +23,12 @@ class LocalListMenuProvider(
|
||||
onImportClick()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_settings -> {
|
||||
context.startActivity(MangaDirectoriesActivity.newIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -24,7 +25,7 @@ class LocalListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
filter: FilterCoordinator,
|
||||
settings: AppSettings,
|
||||
private val settings: AppSettings,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
listExtraProvider: ListExtraProvider,
|
||||
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
@@ -36,7 +37,7 @@ class LocalListViewModel @Inject constructor(
|
||||
settings,
|
||||
listExtraProvider,
|
||||
downloadScheduler,
|
||||
) {
|
||||
), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
val onMangaRemoved = MutableEventFlow<Unit>()
|
||||
|
||||
@@ -47,6 +48,18 @@ class LocalListViewModel @Inject constructor(
|
||||
loadList(filter.snapshot(), append = false).join()
|
||||
}
|
||||
}
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
settings.unsubscribe(this)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(ids: Set<Long>) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.main.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
|
||||
class ReadingResumeEnabledUseCase @Inject constructor(
|
||||
private val networkState: NetworkState,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
operator fun invoke(): Flow<Boolean> = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
|
||||
isIncognitoModeEnabled
|
||||
}.flatMapLatest { incognito ->
|
||||
if (incognito) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
combine(networkState, historyRepository.observeLast()) { isOnline, last ->
|
||||
last != null && (isOnline || last.source == MangaSource.LOCAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
|
||||
setPrimaryFragment(
|
||||
return setPrimaryFragment(
|
||||
when (itemId) {
|
||||
R.id.nav_shelf -> ShelfFragment.newInstance()
|
||||
R.id.nav_explore -> ExploreFragment.newInstance()
|
||||
@@ -106,7 +106,6 @@ class MainNavigationDelegate(
|
||||
else -> return false
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getItemId(fragment: Fragment) = when (fragment) {
|
||||
@@ -117,13 +116,17 @@ class MainNavigationDelegate(
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun setPrimaryFragment(fragment: Fragment) {
|
||||
private fun setPrimaryFragment(fragment: Fragment): Boolean {
|
||||
if (fragmentManager.isStateSaved) {
|
||||
return false
|
||||
}
|
||||
fragmentManager.beginTransaction()
|
||||
.setReorderingAllowed(true)
|
||||
.replace(R.id.container, fragment, TAG_PRIMARY)
|
||||
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
.commit()
|
||||
onFragmentChanged(fragment, fromUser = true)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
|
||||
|
||||
@@ -13,12 +13,12 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
@@ -29,16 +29,12 @@ class MainViewModel @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
trackingRepository: TrackingRepository,
|
||||
settings: AppSettings,
|
||||
readingResumeEnabledUseCase: ReadingResumeEnabledUseCase,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onOpenReader = MutableEventFlow<Manga>()
|
||||
|
||||
val isResumeEnabled = combine(
|
||||
historyRepository.observeHasItems(),
|
||||
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
|
||||
) { hasItems, incognito ->
|
||||
hasItems && !incognito
|
||||
}.stateIn(
|
||||
val isResumeEnabled = readingResumeEnabledUseCase().stateIn(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = false,
|
||||
|
||||
@@ -34,6 +34,8 @@ import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.ramAvailable
|
||||
import org.koitharu.kotatsu.core.util.ext.withProgress
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
|
||||
@@ -42,7 +44,6 @@ import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -83,7 +84,10 @@ class PageLoader @Inject constructor(
|
||||
}
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled && !isLowRam()
|
||||
return repository is RemoteMangaRepository
|
||||
&& settings.isPagesPreloadEnabled
|
||||
&& !context.isPowerSaveMode()
|
||||
&& !isLowRam()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
@@ -18,13 +17,10 @@ import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
@@ -33,6 +29,9 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||
|
||||
override val viewModel by viewModels<RemoteListViewModel>()
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = viewModel
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(RemoteListMenuProvider())
|
||||
@@ -55,24 +54,6 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
|
||||
viewModel.resetFilter()
|
||||
}
|
||||
|
||||
override val filterItems: StateFlow<List<ListModel>>
|
||||
get() = viewModel.filterItems
|
||||
|
||||
override val header: StateFlow<FilterHeaderModel>
|
||||
get() = viewModel.header
|
||||
|
||||
override fun applyFilter(tags: Set<MangaTag>) {
|
||||
viewModel.applyFilter(tags)
|
||||
}
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
viewModel.onSortItemClick(item)
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
viewModel.onTagItemClick(item)
|
||||
}
|
||||
|
||||
private inner class RemoteListMenuProvider :
|
||||
MenuProvider,
|
||||
SearchView.OnQueryTextListener,
|
||||
|
||||
@@ -20,10 +20,11 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterState
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
@@ -36,7 +37,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val FILTER_MIN_INTERVAL = 250L
|
||||
@@ -49,7 +49,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
settings: AppSettings,
|
||||
listExtraProvider: ListExtraProvider,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler), FilterOwner by filter {
|
||||
) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter {
|
||||
|
||||
val source = savedStateHandle.require<MangaSource>(RemoteListFragment.ARG_SOURCE)
|
||||
private val repository = mangaRepositoryFactory.create(source)
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.databinding.ActivityMangaListBinding
|
||||
import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment
|
||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||
import org.koitharu.kotatsu.local.ui.LocalListFragment
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -35,11 +36,16 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
@AndroidEntryPoint
|
||||
class MangaListActivity :
|
||||
BaseActivity<ActivityMangaListBinding>(),
|
||||
AppBarOwner, View.OnClickListener {
|
||||
AppBarOwner, View.OnClickListener, FilterOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override val filter: MangaFilter
|
||||
get() = checkNotNull(findFilterOwner()) {
|
||||
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||
@@ -109,13 +115,14 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = filterOwner.filter
|
||||
val chipSort = viewBinding.chipSort
|
||||
if (chipSort != null) {
|
||||
filterOwner.header.observe(this) {
|
||||
filter.header.observe(this) {
|
||||
chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0)
|
||||
}
|
||||
} else {
|
||||
filterOwner.header.map {
|
||||
filter.header.map {
|
||||
it.textSummary
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.observe(this) {
|
||||
@@ -124,13 +131,17 @@ class MangaListActivity :
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFilterOwner(): FilterOwner? {
|
||||
return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner
|
||||
}
|
||||
|
||||
private class ApplyFilterRunnable(
|
||||
private val filterOwner: FilterOwner,
|
||||
private val tags: Set<MangaTag>,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
filterOwner.applyFilter(tags)
|
||||
filterOwner.filter.applyFilter(tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,18 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.StorageSelectDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadsSettingsFragment :
|
||||
BasePreferenceFragment(R.string.downloads),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
StorageSelectDialog.OnStorageSelectListener {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var storageManager: LocalStorageManager
|
||||
@@ -39,6 +37,7 @@ class DownloadsSettingsFragment :
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount()
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
@@ -53,6 +52,10 @@ class DownloadsSettingsFragment :
|
||||
findPreference<Preference>(key)?.bindStorageName()
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||
findPreference<Preference>(key)?.bindDirectoriesCount()
|
||||
}
|
||||
|
||||
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
||||
updateDownloadsConstraints()
|
||||
}
|
||||
@@ -62,12 +65,12 @@ class DownloadsSettingsFragment :
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
val ctx = context ?: return false
|
||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||
.setTitle(preference.title ?: "")
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.create()
|
||||
.show()
|
||||
MangaDirectorySelectDialog.show(childFragmentManager)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOCAL_MANGA_DIRS -> {
|
||||
startActivity(MangaDirectoriesActivity.newIntent(preference.context))
|
||||
true
|
||||
}
|
||||
|
||||
@@ -75,14 +78,21 @@ class DownloadsSettingsFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStorageSelected(file: File) {
|
||||
settings.mangaStorageDir = file
|
||||
}
|
||||
|
||||
private fun Preference.bindStorageName() {
|
||||
viewLifecycleScope.launch {
|
||||
val storage = storageManager.getDefaultWriteableDir()
|
||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||
summary = if (storage != null) {
|
||||
storageManager.getDirectoryDisplayName(storage, isFullPath = true)
|
||||
} else {
|
||||
getString(R.string.not_available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Preference.bindDirectoriesCount() {
|
||||
viewLifecycleScope.launch {
|
||||
val dirs = storageManager.getReadableDirs().size
|
||||
summary = resources.getQuantityString(R.plurals.items, dirs, dirs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
@@ -49,10 +48,18 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
getString(R.string.logged_in_as, it)
|
||||
}
|
||||
}
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
|
||||
viewModel.onError.observeEvent(
|
||||
viewLifecycleOwner,
|
||||
SnackbarErrorObserver(
|
||||
listView,
|
||||
this,
|
||||
exceptionResolver,
|
||||
) { viewModel.onResume() },
|
||||
)
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||
findPreference<Preference>(KEY_AUTH)?.isEnabled = !isLoading
|
||||
}
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
@@ -61,32 +68,15 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source))
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
viewModel.clearCookies()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(error: Throwable) {
|
||||
val snackbar = Snackbar.make(
|
||||
listView ?: return,
|
||||
error.getDisplayMessage(resources),
|
||||
Snackbar.LENGTH_INDEFINITE,
|
||||
)
|
||||
if (ExceptionResolver.canResolve(error)) {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) }
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
private fun resolveError(error: Throwable) {
|
||||
view ?: return
|
||||
viewLifecycleScope.launch {
|
||||
if (exceptionResolver.resolve(error)) {
|
||||
viewModel.onResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_AUTH = "auth"
|
||||
|
||||
@@ -5,9 +5,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -17,11 +23,13 @@ import javax.inject.Inject
|
||||
class SourceSettingsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val source = savedStateHandle.require<MangaSource>(SourceSettingsFragment.EXTRA_SOURCE)
|
||||
val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val username = MutableStateFlow<String?>(null)
|
||||
private var usernameLoadJob: Job? = null
|
||||
|
||||
@@ -35,6 +43,18 @@ class SourceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCookies() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host(repository.domain)
|
||||
.build()
|
||||
cookieJar.removeCookies(url)
|
||||
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
|
||||
loadUsername()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUsername() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
@@ -27,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -66,8 +68,11 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
|
||||
fun directoryAD(
|
||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageBinding>(
|
||||
{ layoutInflater, parent -> ItemStorageBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||
binding.imageViewIndicator.isChecked = item.isChecked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil.ItemCallback
|
||||
|
||||
class DirectoryDiffCallback : ItemCallback<DirectoryModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
|
||||
return oldItem.file == newItem.file
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: DirectoryModel, newItem: DirectoryModel): Any? {
|
||||
return if (oldItem.isChecked != newItem.isChecked) {
|
||||
Unit
|
||||
} else {
|
||||
super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.io.File
|
||||
|
||||
class DirectoryModel(
|
||||
val title: String?,
|
||||
@StringRes val titleRes: Int,
|
||||
val file: File?,
|
||||
val isChecked: Boolean,
|
||||
val isAvailable: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DirectoryModel
|
||||
|
||||
if (title != other.title) return false
|
||||
if (titleRes != other.titleRes) return false
|
||||
if (file != other.file) return false
|
||||
if (isChecked != other.isChecked) return false
|
||||
return isAvailable == other.isAvailable
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = title?.hashCode() ?: 0
|
||||
result = 31 * result + titleRes
|
||||
result = 31 * result + (file?.hashCode() ?: 0)
|
||||
result = 31 * result + isChecked.hashCode()
|
||||
result = 31 * result + isAvailable.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.databinding.DialogDirectorySelectBinding
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaDirectorySelectDialog : AlertDialogFragment<DialogDirectorySelectBinding>(),
|
||||
OnListItemClickListener<DirectoryModel> {
|
||||
|
||||
private val viewModel: MangaDirectorySelectViewModel by viewModels()
|
||||
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||
if (it != null) viewModel.onCustomDirectoryPicked(it)
|
||||
}
|
||||
private val permissionRequestLauncher = registerForActivityResult(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
RequestStorageManagerPermissionContract()
|
||||
} else {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
},
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.refresh()
|
||||
pickFileTreeLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding {
|
||||
return DialogDirectorySelectBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogDirectorySelectBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryAD(this))
|
||||
binding.root.adapter = adapter
|
||||
viewModel.items.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
viewModel.onDismissDialog.observeEvent(viewLifecycleOwner) { dismiss() }
|
||||
viewModel.onPickDirectory.observeEvent(viewLifecycleOwner) { pickCustomDirectory() }
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, ToastErrorObserver(binding.root, this))
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.manga_save_location)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||
viewModel.onItemClick(item)
|
||||
}
|
||||
|
||||
private fun pickCustomDirectory() {
|
||||
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "MangaDirectorySelectDialog"
|
||||
|
||||
fun show(fm: FragmentManager) = MangaDirectorySelectDialog()
|
||||
.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaDirectorySelectViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||
val onDismissDialog = MutableEventFlow<Unit>()
|
||||
val onPickDirectory = MutableEventFlow<Unit>()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun onItemClick(item: DirectoryModel) {
|
||||
if (item.file != null) {
|
||||
settings.mangaStorageDir = item.file
|
||||
onDismissDialog.call(Unit)
|
||||
} else {
|
||||
onPickDirectory.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = requireNotNull(storageManager.resolveUri(uri)) {
|
||||
"Cannot resolve file name of \"$uri\""
|
||||
}
|
||||
if (!dir.canWrite()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||
settings.mangaStorageDir = dir
|
||||
storageManager.setDirIsNoMedia(dir)
|
||||
}
|
||||
onDismissDialog.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val defaultValue = storageManager.getDefaultWriteableDir()
|
||||
val available = storageManager.getWriteableDirs()
|
||||
items.value = buildList(available.size + 1) {
|
||||
available.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = dir == defaultValue,
|
||||
isAvailable = true,
|
||||
)
|
||||
}
|
||||
this += DirectoryModel(
|
||||
title = null,
|
||||
titleRes = R.string.pick_custom_directory,
|
||||
file = null,
|
||||
isChecked = false,
|
||||
isAvailable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.koitharu.kotatsu.settings.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class RequestStorageManagerPermissionContract : ActivityResultContract<String, Boolean>() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.addCategory("android.intent.category.DEFAULT")
|
||||
intent.data = "package:${context.packageName}".toUri()
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return Environment.isExternalStorageManager()
|
||||
}
|
||||
|
||||
override fun getSynchronousResult(context: Context, input: String): SynchronousResult<Boolean>? {
|
||||
return if (Environment.isExternalStorageManager()) {
|
||||
SynchronousResult(true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
|
||||
fun directoryConfigAD(
|
||||
clickListener: OnListItemClickListener<DirectoryModel>,
|
||||
) = adapterDelegateViewBinding<DirectoryModel, DirectoryModel, ItemStorageConfigBinding>(
|
||||
{ layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.imageViewRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) }
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.title ?: getString(item.titleRes)
|
||||
binding.textViewSubtitle.textAndVisible = item.file?.absolutePath
|
||||
binding.imageViewRemove.isVisible = item.isChecked
|
||||
binding.textViewTitle.drawableStart = if (item.isAvailable) {
|
||||
null
|
||||
} else {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityMangaDirectoriesBinding
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaDirectoriesActivity : BaseActivity<ActivityMangaDirectoriesBinding>(),
|
||||
OnListItemClickListener<DirectoryModel>, View.OnClickListener {
|
||||
|
||||
private val viewModel: MangaDirectoriesViewModel by viewModels()
|
||||
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||
if (it != null) viewModel.onCustomDirectoryPicked(it)
|
||||
}
|
||||
private val permissionRequestLauncher = registerForActivityResult(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
RequestStorageManagerPermissionContract()
|
||||
} else {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
},
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.updateList()
|
||||
pickFileTreeLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this))
|
||||
viewBinding.recyclerView.adapter = adapter
|
||||
viewBinding.fabAdd.setOnClickListener(this)
|
||||
viewModel.items.observe(this) { adapter.items = it }
|
||||
viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it }
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) {
|
||||
if (it) viewModel.updateList()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DirectoryModel, view: View) {
|
||||
viewModel.onRemoveClick(item.file ?: return)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
rightMargin = topMargin + insets.right
|
||||
leftMargin = topMargin + insets.left
|
||||
bottomMargin = topMargin + insets.bottom
|
||||
}
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.settings.storage.directories
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okio.FileNotFoundException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaDirectoriesViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableStateFlow(emptyList<DirectoryModel>())
|
||||
private var loadingJob: Job? = null
|
||||
|
||||
init {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun updateList() {
|
||||
loadList()
|
||||
}
|
||||
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
loadingJob?.cancelAndJoin()
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = requireNotNull(storageManager.resolveUri(uri)) {
|
||||
"Cannot resolve file name of \"$uri\""
|
||||
}
|
||||
if (!dir.canWrite()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
if (dir !in storageManager.getApplicationStorageDirs()) {
|
||||
settings.userSpecifiedMangaDirectories += dir
|
||||
loadList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onRemoveClick(directory: File) {
|
||||
settings.userSpecifiedMangaDirectories -= directory
|
||||
if (settings.mangaStorageDir == directory) {
|
||||
settings.mangaStorageDir = null
|
||||
}
|
||||
loadList()
|
||||
}
|
||||
|
||||
private fun loadList() {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val applicationDirs = storageManager.getApplicationStorageDirs()
|
||||
val customDirs = settings.userSpecifiedMangaDirectories
|
||||
items.value = buildList(applicationDirs.size + customDirs.size) {
|
||||
applicationDirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = false,
|
||||
isAvailable = dir.canRead() && dir.canWrite(),
|
||||
)
|
||||
}
|
||||
customDirs.mapTo(this) { dir ->
|
||||
DirectoryModel(
|
||||
title = storageManager.getDirectoryDisplayName(dir, isFullPath = false),
|
||||
titleRes = 0,
|
||||
file = dir,
|
||||
isChecked = true,
|
||||
isAvailable = dir.canRead() && dir.canWrite(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
||||
@@ -155,7 +156,7 @@ class TrackerSettingsFragment :
|
||||
return
|
||||
}
|
||||
val packageName = context.packageName
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val powerManager = context.powerManager ?: return
|
||||
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||
try {
|
||||
val intent = Intent(
|
||||
|
||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -11,6 +12,8 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.data.toMangaList
|
||||
@@ -32,6 +35,7 @@ class ShelfContentObserveUseCase @Inject constructor(
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val suggestionRepository: SuggestionRepository,
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) {
|
||||
|
||||
@@ -46,7 +50,10 @@ class ShelfContentObserveUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
private fun observeLocalManga(sortOrder: SortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return localStorageChanges
|
||||
return combine<LocalManga?, String, Any?>(
|
||||
localStorageChanges,
|
||||
settings.observe().filter { it == AppSettings.KEY_LOCAL_MANGA_DIRS }
|
||||
) { _, _ -> Any() }
|
||||
.onStart { emit(null) }
|
||||
.mapLatest {
|
||||
localMangaRepository.getList(0, null, sortOrder).take(limit)
|
||||
|
||||
@@ -109,7 +109,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setDefaults(0)
|
||||
.setOngoing(true)
|
||||
.setOngoing(false)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
|
||||
@@ -43,10 +43,12 @@ class SyncController @Inject constructor(
|
||||
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
requestSync(
|
||||
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
|
||||
history = TABLE_HISTORY in tables,
|
||||
)
|
||||
val favourites = (TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables)
|
||||
&& !isSyncActiveOrPending(authorityFavourites)
|
||||
val history = TABLE_HISTORY in tables && !isSyncActiveOrPending(authorityHistory)
|
||||
if (favourites || history) {
|
||||
requestSync(favourites, history)
|
||||
}
|
||||
}
|
||||
|
||||
fun isEnabled(account: Account): Boolean {
|
||||
@@ -126,6 +128,11 @@ class SyncController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSyncActiveOrPending(authority: String): Boolean {
|
||||
val account = peekAccount() ?: return false
|
||||
return ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -11,6 +11,10 @@ import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.contentValuesOf
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -23,9 +27,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA
|
||||
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
|
||||
import org.koitharu.kotatsu.core.db.TABLE_TAGS
|
||||
import org.koitharu.kotatsu.core.logs.LoggersModule
|
||||
import org.koitharu.kotatsu.core.network.GZipInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.logs.SyncLogger
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.toContentValues
|
||||
import org.koitharu.kotatsu.core.util.ext.toJson
|
||||
@@ -39,23 +43,20 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val FIELD_TIMESTAMP = "timestamp"
|
||||
|
||||
/**
|
||||
* Warning! This class may be used in another process
|
||||
*/
|
||||
@WorkerThread
|
||||
class SyncHelper(
|
||||
context: Context,
|
||||
private val account: Account,
|
||||
private val provider: ContentProviderClient,
|
||||
class SyncHelper @AssistedInject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
@BaseHttpClient baseHttpClient: OkHttpClient,
|
||||
@Assisted private val account: Account,
|
||||
@Assisted private val provider: ContentProviderClient,
|
||||
private val settings: SyncSettings,
|
||||
@SyncLogger private val logger: FileLogger,
|
||||
) {
|
||||
|
||||
private val authorityHistory = context.getString(R.string.sync_authority_history)
|
||||
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
|
||||
private val settings = SyncSettings(context, account)
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
private val httpClient = baseHttpClient.newBuilder()
|
||||
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
|
||||
.addInterceptor(SyncInterceptor(context, account))
|
||||
.addInterceptor(GZipInterceptor())
|
||||
.build()
|
||||
private val baseUrl: String by lazy {
|
||||
val host = settings.host
|
||||
@@ -64,8 +65,8 @@ class SyncHelper(
|
||||
}
|
||||
private val defaultGcPeriod: Long // gc period if sync enabled
|
||||
get() = TimeUnit.DAYS.toMillis(4)
|
||||
private val logger = LoggersModule.provideSyncLogger(context, AppSettings(context))
|
||||
|
||||
@WorkerThread
|
||||
fun syncFavourites(syncResult: SyncResult) {
|
||||
val data = JSONObject()
|
||||
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
|
||||
@@ -89,6 +90,7 @@ class SyncHelper(
|
||||
gcFavourites()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun syncHistory(syncResult: SyncResult) {
|
||||
val data = JSONObject()
|
||||
data.put(TABLE_HISTORY, getHistory())
|
||||
@@ -321,4 +323,13 @@ class SyncHelper(
|
||||
logger.log("$code ${request.url}")
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
account: Account,
|
||||
contentProviderClient: ContentProviderClient,
|
||||
): SyncHelper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.sync.ui
|
||||
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncAdapterEntryPoint {
|
||||
val syncHelperFactory: SyncHelper.Factory
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.onError
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||
import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint
|
||||
|
||||
class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
|
||||
|
||||
@@ -24,7 +25,8 @@ class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(cont
|
||||
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
|
||||
return
|
||||
}
|
||||
val syncHelper = SyncHelper(context, account, provider)
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
|
||||
val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
|
||||
runCatchingCancellable {
|
||||
syncHelper.syncFavourites(syncResult)
|
||||
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
||||
|
||||
@@ -6,11 +6,12 @@ import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.onError
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||
import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint
|
||||
|
||||
class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
|
||||
|
||||
@@ -24,7 +25,8 @@ class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context
|
||||
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
|
||||
return
|
||||
}
|
||||
val syncHelper = SyncHelper(context, account, provider)
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
|
||||
val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
|
||||
runCatchingCancellable {
|
||||
syncHelper.syncHistory(syncResult)
|
||||
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
|
||||
|
||||
@@ -13,9 +13,9 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
class TrackEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@@ -27,4 +27,4 @@ class TrackEntity(
|
||||
@ColumnInfo(name = "last_check") val lastCheck: Long,
|
||||
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
|
||||
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
|
||||
)
|
||||
)
|
||||
|
||||
@@ -43,6 +43,9 @@ abstract class TracksDao {
|
||||
@Query("UPDATE tracks SET chapters_new = 0")
|
||||
abstract suspend fun clearCounters()
|
||||
|
||||
@Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearCounter(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun delete(mangaId: Long)
|
||||
|
||||
|
||||
@@ -126,6 +126,18 @@ class TrackingRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearUpdates(ids: Collection<Long>) {
|
||||
when {
|
||||
ids.isEmpty() -> return
|
||||
ids.size == 1 -> db.tracksDao.clearCounter(ids.single())
|
||||
else -> db.withTransaction {
|
||||
for (id in ids) {
|
||||
db.tracksDao.clearCounter(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncWithHistory(manga: Manga, chapterId: Long) {
|
||||
val chapters = manga.chapters ?: return
|
||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package org.koitharu.kotatsu.tracker.ui.updates
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -12,6 +17,22 @@ class UpdatesFragment : MangaListFragment() {
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_updates, menu)
|
||||
return super.onCreateActionMode(controller, mode, menu)
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
viewModel.remove(controller.snapshot())
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onActionItemClicked(controller, mode, item)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = UpdatesFragment()
|
||||
|
||||
@@ -59,4 +59,10 @@ class UpdatesViewModel @Inject constructor(
|
||||
override fun onRefresh() = Unit
|
||||
|
||||
override fun onRetry() = Unit
|
||||
|
||||
fun remove(ids: Set<Long>) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.clearUpdates(ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setDefaults(0)
|
||||
.setOngoing(true)
|
||||
.setOngoing(false)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
|
||||
@@ -37,7 +37,7 @@ class RecentListFactory(
|
||||
|
||||
override fun getLoadingView() = null
|
||||
|
||||
override fun getItemId(position: Int) = dataSet[position].id
|
||||
override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L
|
||||
|
||||
override fun onDataSetChanged() {
|
||||
val data = runBlocking { historyRepository.getList(0, 10) }
|
||||
@@ -48,7 +48,7 @@ class RecentListFactory(
|
||||
|
||||
override fun getViewAt(position: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, R.layout.item_recent)
|
||||
val item = dataSet[position]
|
||||
val item = dataSet.getOrNull(position) ?: return views
|
||||
runCatching {
|
||||
coil.executeBlocking(
|
||||
ImageRequest.Builder(context)
|
||||
|
||||
@@ -40,7 +40,7 @@ class ShelfListFactory(
|
||||
|
||||
override fun getLoadingView() = null
|
||||
|
||||
override fun getItemId(position: Int) = dataSet[position].id
|
||||
override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L
|
||||
|
||||
override fun onDataSetChanged() {
|
||||
val data = runBlocking {
|
||||
@@ -58,7 +58,7 @@ class ShelfListFactory(
|
||||
|
||||
override fun getViewAt(position: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, R.layout.item_shelf)
|
||||
val item = dataSet[position]
|
||||
val item = dataSet.getOrNull(position) ?: return views
|
||||
views.setTextViewText(R.id.textView_title, item.title)
|
||||
runCatching {
|
||||
coil.executeBlocking(
|
||||
|
||||
12
app/src/main/res/drawable/ic_list_end.xml
Normal file
12
app/src/main/res/drawable/ic_list_end.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12C7,9.58 8.72,7.56 11,7.1V3H13V7.1C15.28,7.56 17,9.58 17,12M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_list_next.xml
Normal file
12
app/src/main/res/drawable/ic_list_next.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12C17,14.42 15.28,16.44 13,16.9V21H11V16.9C8.72,16.44 7,14.42 7,12M12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15M13,3V5H11V3H13Z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_list_start.xml
Normal file
12
app/src/main/res/drawable/ic_list_start.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M12,7A5,5 0 0,1 17,12C17,14.42 15.28,16.44 13,16.9V21H11V16.9C8.72,16.44 7,14.42 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_select_group.xml
Normal file
11
app/src/main/res/drawable/ic_select_group.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M5 3A2 2 0 0 0 3 5H5M7 3V5H9V3M11 3V5H13V3M15 3V5H17V3M19 3V5H21A2 2 0 0 0 19 3M3 7V9H5V7M7 7V11H11V7M13 7V11H17V7M19 7V9H21V7M3 11V13H5V11M19 11V13H21V11M7 13V17H11V13M13 13V17H17V13M3 15V17H5V15M19 15V17H21V15M3 19A2 2 0 0 0 5 21V19M7 19V21H9V19M11 19V21H13V19M15 19V21H17V19M19 19V21A2 2 0 0 0 21 19Z" />
|
||||
</vector>
|
||||
@@ -58,7 +58,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/add_new_category"
|
||||
android:src="@drawable/ic_add"
|
||||
android:text="@string/create_category"
|
||||
app:fabSize="normal"
|
||||
app:icon="@drawable/ic_add"
|
||||
|
||||
57
app/src/main/res/layout/activity_manga_directories.xml
Normal file
57
app/src/main/res/layout/activity_manga_directories.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_scrollFlags="noScroll">
|
||||
|
||||
</com.google.android.material.appbar.MaterialToolbar>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:layout_anchor="@id/appbar"
|
||||
app:layout_anchorGravity="bottom" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/fab_add"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/pick_custom_directory"
|
||||
android:text="@string/add"
|
||||
app:fabSize="normal"
|
||||
app:icon="@drawable/ic_add"
|
||||
app:layout_anchor="@id/recyclerView"
|
||||
app:layout_anchorGravity="bottom|end"
|
||||
app:layout_behavior="org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior"
|
||||
app:layout_dodgeInsetEdges="bottom" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
13
app/src/main/res/layout/dialog_directory_select.xml
Normal file
13
app/src/main/res/layout/dialog_directory_select.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
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:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:scrollIndicators="top|bottom"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
16
app/src/main/res/layout/item_download_option.xml
Normal file
16
app/src/main/res/layout/item_download_option.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
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:id="@+id/button_file"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
tools:subtitle="@string/chapters"
|
||||
tools:title="@string/download_option_whole_manga" />
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:minHeight="?listPreferredItemHeightLarge"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?listPreferredItemHeight"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="12dp"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:paddingBottom="16dp">
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
|
||||
android:id="@+id/imageView_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:src="?android:listChoiceIndicatorSingle"
|
||||
tools:ignore="TouchTargetSizeCheck" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginStart="?listPreferredItemPaddingStart"
|
||||
android:layout_toEndOf="@id/imageView_indicator"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/textView_title"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginStart="?listPreferredItemPaddingStart"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_toEndOf="@id/imageView_indicator"
|
||||
android:ellipsize="end"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
tools:text="@tools:sample/lorem[20]" />
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
|
||||
</RelativeLayout>
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
tools:text="@tools:sample/lorem[20]" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
53
app/src/main/res/layout/item_storage_config.xml
Normal file
53
app/src/main/res/layout/item_storage_config.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
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="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?listPreferredItemHeight"
|
||||
android:orientation="horizontal"
|
||||
android:paddingVertical="12dp"
|
||||
android:paddingStart="?listPreferredItemPaddingStart">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="6dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
app:drawableTint="@color/warning"
|
||||
tools:drawableStart="@drawable/ic_alert_outline"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
tools:text="@tools:sample/lorem[20]" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView_remove"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/remove"
|
||||
android:padding="?listPreferredItemPaddingEnd"
|
||||
app:srcCompat="@drawable/ic_delete" />
|
||||
|
||||
</LinearLayout>
|
||||
29
app/src/main/res/menu/mode_updates.xml
Normal file
29
app/src/main/res/menu/mode_updates.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_share"
|
||||
android:icon="?actionModeShareDrawable"
|
||||
android:title="@string/share"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_remove"
|
||||
android:icon="@drawable/ic_delete"
|
||||
android:title="@string/delete"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:icon="@drawable/ic_save"
|
||||
android:title="@string/save"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_select_all"
|
||||
android:icon="?actionModeSelectAllDrawable"
|
||||
android:title="@android:string/selectAll"
|
||||
app:showAsAction="ifRoom|withText" />
|
||||
</menu>
|
||||
@@ -20,7 +20,7 @@
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:orderInCategory="40"
|
||||
android:title="@string/save"
|
||||
android:title="@string/download"
|
||||
android:visible="false"
|
||||
app:showAsAction="never" />
|
||||
|
||||
|
||||
@@ -9,4 +9,10 @@
|
||||
android:title="@string/_import"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:orderInCategory="100"
|
||||
android:title="@string/settings"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user