From 786914b1a6a0576d65c32fb373b844eec09637db Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 10 Mar 2022 19:49:40 +0200 Subject: [PATCH 01/54] Shikimori authorization --- app/src/main/AndroidManifest.xml | 6 ++ .../java/org/koitharu/kotatsu/KotatsuApp.kt | 2 + .../kotatsu/core/network/CommonHeaders.kt | 1 + .../kotatsu/core/prefs/AppSettings.kt | 1 + .../reader/ui/SimpleSettingsActivity.kt | 15 ++++ .../kotatsu/settings/MainSettingsFragment.kt | 29 ++++++++ .../kotatsu/shikimori/ShikimoriModule.kt | 31 ++++++++ .../shikimori/data/ShikimoriAuthenticator.kt | 46 ++++++++++++ .../shikimori/data/ShikimoriInterceptor.kt | 19 +++++ .../shikimori/data/ShikimoriRepository.kt | 51 +++++++++++++ .../shikimori/data/ShikimoriStorage.kt | 21 ++++++ .../shikimori/data/model/ShikimoriUser.kt | 16 ++++ .../shikimori/ui/ShikimoriSettingsFragment.kt | 73 +++++++++++++++++++ .../ui/ShikimoriSettingsViewModel.kt | 40 ++++++++++ .../kotatsu/utils/PreferenceIconTarget.kt | 22 ++++++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/pref_main.xml | 6 ++ app/src/main/res/xml/pref_shikimori.xml | 13 ++++ 18 files changed, 394 insertions(+) create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt create mode 100644 app/src/main/res/xml/pref_shikimori.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb22f131e..d177b69df 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,12 @@ + + + + + + () { @@ -27,6 +32,7 @@ class SimpleSettingsActivity : BaseActivity() { R.id.container, when (intent?.action) { Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment() + Intent.ACTION_VIEW -> handleUri(intent.data) ?: return ACTION_READER -> ReaderSettingsFragment() ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() ACTION_SOURCE -> SourceSettingsFragment.newInstance( @@ -50,6 +56,15 @@ class SimpleSettingsActivity : BaseActivity() { } } + private fun handleUri(uri: Uri?): Fragment? { + when (uri?.host) { + HOST_SHIKIMORI_AUTH -> return ShikimoriSettingsFragment + .newInstance(authCode = uri.getQueryParameter("code")) + } + finishAfterTransition() + return null + } + companion object { private const val ACTION_READER = diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index 7a2b992ae..e25b01028 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings import android.content.Intent import android.content.SharedPreferences +import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -12,6 +13,8 @@ import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceScreen import androidx.preference.TwoStatePreference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch import leakcanary.LeakCanary import org.koin.android.ext.android.inject @@ -24,6 +27,7 @@ import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.SliderPreference +import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.names import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat @@ -37,6 +41,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), StorageSelectDialog.OnStorageSelectListener { private val storageManager by inject() + private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -165,6 +170,14 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), } true } + AppSettings.KEY_SHIKIMORI -> { + if (!shikimoriRepository.isAuthorized) { + showShikimoriDialog() + true + } else { + super.onPreferenceTreeClick(preference) + } + } else -> super.onPreferenceTreeClick(preference) } } @@ -179,4 +192,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), summary = storage?.getStorageName(context) ?: getString(R.string.not_available) } } + + private fun showShikimoriDialog() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.shikimori) + .setMessage(R.string.shikimori_info) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.sign_in) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(shikimoriRepository.oauthUrl) + startActivity(intent) + }.onFailure { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_LONG).show() + } + }.show() + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt new file mode 100644 index 000000000..1fd1d2b05 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.shikimori + +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.network.CurlLoggingInterceptor +import org.koitharu.kotatsu.shikimori.data.ShikimoriAuthenticator +import org.koitharu.kotatsu.shikimori.data.ShikimoriInterceptor +import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.shikimori.data.ShikimoriStorage +import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsViewModel + +val shikimoriModule + get() = module { + single { ShikimoriStorage(androidContext()) } + factory { + val okHttp = OkHttpClient.Builder().apply { + authenticator(ShikimoriAuthenticator(get(), ::get)) + addInterceptor(ShikimoriInterceptor(get())) + if (BuildConfig.DEBUG) { + addNetworkInterceptor(CurlLoggingInterceptor()) + } + }.build() + ShikimoriRepository(okHttp, get()) + } + viewModel { params -> + ShikimoriSettingsViewModel(get(), params.getOrNull()) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt new file mode 100644 index 000000000..b62d5bff0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.shikimori.data + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.koitharu.kotatsu.core.network.CommonHeaders + +class ShikimoriAuthenticator( + private val storage: ShikimoriStorage, + private val repositoryProvider: () -> ShikimoriRepository, +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + val accessToken = storage.accessToken ?: return null + if (!isRequestWithAccessToken(response)) { + return null; + } + synchronized (this) { + val newAccessToken = storage.accessToken ?: return null + if (accessToken != newAccessToken) { + return newRequestWithAccessToken(response.request, newAccessToken); + } + val updatedAccessToken = refreshAccessToken() ?: return null + return newRequestWithAccessToken(response.request, updatedAccessToken); + } + } + + private fun isRequestWithAccessToken(response: Response): Boolean { + val header = response.request.header(CommonHeaders.AUTHORIZATION) + return header?.startsWith("Bearer") == true + } + + private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { + return request.newBuilder() + .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") + .build() + } + + private fun refreshAccessToken(): String? { + val repository = repositoryProvider() + runBlocking { repository.authorize(null) } + return storage.accessToken + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt new file mode 100644 index 000000000..33ff454c3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.shikimori.data + +import okhttp3.Interceptor +import okhttp3.Response +import org.koitharu.kotatsu.core.network.CommonHeaders + +private const val USER_AGENT_SHIKIMORI = "Kotatsu" + +class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI) + storage.accessToken?.let { + request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") + } + return chain.proceed(request.build()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt new file mode 100644 index 000000000..cd2f3ad8d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.shikimori.data + +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.utils.ext.await +import org.koitharu.kotatsu.utils.ext.parseJson + +private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" +private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8" +private const val REDIRECT_URI = "kotatsu://shikimori-auth" + +class ShikimoriRepository( + private val okHttp: OkHttpClient, + private val storage: ShikimoriStorage, +) { + + val oauthUrl: String + get() = "https://shikimori.one/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&response_type=code&scope=" + + val isAuthorized: Boolean + get() = storage.accessToken != null + + suspend fun authorize(code: String?) { + val body = FormBody.Builder() + body.add("grant_type", "authorization_code") + body.add("client_id", CLIENT_ID) + body.add("client_secret", CLIENT_SECRET) + if (code != null) { + body.add("redirect_uri", REDIRECT_URI) + body.add("code", code) + } else { + body.add("refresh_token", checkNotNull(storage.refreshToken)) + } + val request = Request.Builder() + .post(body.build()) + .url("https://shikimori.one/oauth/token") + val response = okHttp.newCall(request.build()).await().parseJson() + storage.accessToken = response.getString("access_token") + storage.refreshToken = response.getString("refresh_token") + } + + suspend fun getUser(): ShikimoriUser { + val request = Request.Builder() + .get() + .url("https://shikimori.one/api/users/whoami") + val response = okHttp.newCall(request.build()).await().parseJson() + return ShikimoriUser(response) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt new file mode 100644 index 000000000..2edc946ba --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.shikimori.data + +import android.content.Context +import androidx.core.content.edit + +private const val PREF_NAME = "shikimori" +private const val KEY_ACCESS_TOKEN = "access_token" +private const val KEY_REFRESH_TOKEN = "refresh_token" + +class ShikimoriStorage(context: Context) { + + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + var accessToken: String? + get() = prefs.getString(KEY_ACCESS_TOKEN, null) + set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } + + var refreshToken: String? + get() = prefs.getString(KEY_REFRESH_TOKEN, null) + set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt new file mode 100644 index 000000000..eff1464e6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.shikimori.data.model + +import org.json.JSONObject + +class ShikimoriUser( + val id: Long, + val nickname: String, + val avatar: String, +) { + + constructor(json: JSONObject) : this( + id = json.getLong("id"), + nickname = json.getString("nickname"), + avatar = json.getString("avatar"), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt new file mode 100644 index 000000000..ed4e3a600 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt @@ -0,0 +1,73 @@ +package org.koitharu.kotatsu.shikimori.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import coil.ImageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.utils.PreferenceIconTarget +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.withArgs + +private const val KEY_USER = "shiki_user" + +class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { + + private val viewModel by viewModel { + parametersOf(arguments?.getString(ARG_AUTH_CODE)) + } + private val coil by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_shikimori) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.user.observe(viewLifecycleOwner, this::onUserChanged) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + KEY_USER -> openAuthorization() + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun onUserChanged(user: ShikimoriUser?) { + val pref = findPreference(KEY_USER) ?: return + pref.isSelectable = user == null + pref.title = user?.nickname ?: getString(R.string.sign_in) + ImageRequest.Builder(requireContext()) + .data(user?.avatar) + .transformations(CircleCropTransformation()) + .target(PreferenceIconTarget(pref)) + .enqueueWith(coil) + } + + private fun openAuthorization(): Boolean { + return runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(viewModel.authorizationUrl) + startActivity(intent) + }.isSuccess + } + + companion object { + + private const val ARG_AUTH_CODE = "auth_code" + + fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) { + putString(ARG_AUTH_CODE, authCode) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt new file mode 100644 index 000000000..3fd4186d5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.shikimori.ui + +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.Dispatchers +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser + +class ShikimoriSettingsViewModel( + private val repository: ShikimoriRepository, + authCode: String?, +) : BaseViewModel() { + + val authorizationUrl: String + get() = repository.oauthUrl + + val user = MutableLiveData() + + init { + if (authCode != null) { + authorize(authCode) + } else { + loadUser() + } + } + + private fun loadUser() = launchJob(Dispatchers.Default) { + val userModel = if (repository.isAuthorized) { + repository.getUser() + } else { + null + } + user.postValue(userModel) + } + + private fun authorize(code: String) = launchJob(Dispatchers.Default) { + repository.authorize(code) + user.postValue(repository.getUser()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt new file mode 100644 index 000000000..edece17d7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.utils + +import android.graphics.drawable.Drawable +import androidx.preference.Preference +import coil.target.Target + +class PreferenceIconTarget( + private val preference: Preference, +) : Target { + + override fun onError(error: Drawable?) { + preference.icon = error + } + + override fun onStart(placeholder: Drawable?) { + preference.icon = placeholder + } + + override fun onSuccess(result: Drawable) { + preference.icon = result + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5dc3b73a..139c22abe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -266,4 +266,6 @@ Always Preload pages Logged in as %s + Shikimori + Sign in into your Shikimori account to get more features \ No newline at end of file diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml index 0f4d7d6e3..9b2770caa 100644 --- a/app/src/main/res/xml/pref_main.xml +++ b/app/src/main/res/xml/pref_main.xml @@ -95,6 +95,12 @@ android:title="@string/check_for_new_chapters" app:iconSpaceReserved="false" /> + + + + + + + From 9d1c4bd66016de1b06ee587bc42f7b1667df8877 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 11 Apr 2022 13:17:26 +0300 Subject: [PATCH 02/54] Add more shikimori api methods --- .../koitharu/kotatsu/details/DetailsModule.kt | 2 +- .../kotatsu/details/ui/DetailsViewModel.kt | 20 +++++++++ .../shikimori/data/ShikimoriRepository.kt | 44 ++++++++++++++++++- .../shikimori/data/model/ShikimoriManga.kt | 44 +++++++++++++++++++ .../data/model/ShikimoriMangaInfo.kt | 20 +++++++++ 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt index 7e3bd8622..916b75de1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt @@ -8,6 +8,6 @@ val detailsModule get() = module { viewModel { intent -> - DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get()) + DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 7c5fe8574..722a5bf1a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.details.ui import androidx.core.os.LocaleListCompat +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope @@ -26,6 +27,8 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.iterator @@ -39,6 +42,7 @@ class DetailsViewModel( private val trackingRepository: TrackingRepository, private val mangaDataRepository: MangaDataRepository, private val settings: AppSettings, + private val shikimoriRepository: ShikimoriRepository, ) : BaseViewModel() { private var loadingJob: Job @@ -85,6 +89,7 @@ class DetailsViewModel( .asLiveData(viewModelScope.coroutineContext) val onMangaRemoved = SingleLiveEvent() + val shikimoriInfo = MutableLiveData() val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() @@ -204,6 +209,7 @@ class DetailsViewModel( localMangaRepository.findSavedManga(manga) } }.getOrNull() + findShikimoriManga(manga) } private fun mapChapters( @@ -312,4 +318,18 @@ class DetailsViewModel( it.chapter.name.contains(query, ignoreCase = true) } } + + private fun findShikimoriManga(manga: Manga) { + if (!shikimoriRepository.isAuthorized) { + return + } + launchJob(Dispatchers.Default) { + val data = runCatching { + shikimoriRepository.findMangaInfo(manga) + }.getOrNull() + if (data != null) { + shikimoriInfo.postValue(data) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt index 65a2f828c..c4ae8d251 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt @@ -3,8 +3,11 @@ package org.koitharu.kotatsu.shikimori.data import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" @@ -49,4 +52,41 @@ class ShikimoriRepository( val response = okHttp.newCall(request.build()).await().parseJson() return ShikimoriUser(response) } + + suspend fun findMangaInfo(manga: Manga): ShikimoriMangaInfo? { + val q = manga.title.urlEncoded() + val request = Request.Builder() + .get() + .url("https://shikimori.one/api/mangas?limit=20&search=$q&censored=false") + val response = okHttp.newCall(request.build()).await().parseJsonArray() + val candidates = response.mapJSON { ShikimoriManga(it) } + val bestCandidate = candidates.minByOrNull { + it.name.levenshteinDistance(manga.title) + } ?: return null + return getMangaInfo(bestCandidate.id) + } + + suspend fun getRelatedManga(id: Long): List { + val request = Request.Builder() + .get() + .url("https://shikimori.one/api/mangas/$id/related") + val response = okHttp.newCall(request.build()).await().parseJsonArray() + return response.mapJSON { jo -> ShikimoriManga(jo) } + } + + suspend fun getSimilarManga(id: Long): List { + val request = Request.Builder() + .get() + .url("https://shikimori.one/api/mangas/$id/similar") + val response = okHttp.newCall(request.build()).await().parseJsonArray() + return response.mapJSON { jo -> ShikimoriManga(jo) } + } + + suspend fun getMangaInfo(id: Long): ShikimoriMangaInfo { + val request = Request.Builder() + .get() + .url("https://shikimori.one/api/mangas/$id") + val response = okHttp.newCall(request.build()).await().parseJson() + return ShikimoriMangaInfo(response) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt new file mode 100644 index 000000000..8ac0411fb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt @@ -0,0 +1,44 @@ +package org.koitharu.kotatsu.shikimori.data.model + +import org.json.JSONObject + +class ShikimoriManga( + val id: Long, + val name: String, + val cover: String, + val url: String, +) { + + constructor(json: JSONObject) : this( + id = json.getLong("id"), + name = json.getString("name"), + cover = json.getJSONObject("image").getString("preview"), + url = json.getString("url"), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShikimoriManga + + if (id != other.id) return false + if (name != other.name) return false + if (cover != other.cover) return false + if (url != other.url) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + cover.hashCode() + result = 31 * result + url.hashCode() + return result + } + + override fun toString(): String { + return "ShikimoriManga #$id \"$name\" $url" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt new file mode 100644 index 000000000..22adad008 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.shikimori.data.model + +import org.json.JSONObject + +class ShikimoriMangaInfo( + val id: Long, + val name: String, + val cover: String, + val url: String, + val descriptionHtml: String, +) { + + constructor(json: JSONObject) : this( + id = json.getLong("id"), + name = json.getString("name"), + cover = json.getJSONObject("image").getString("preview"), + url = json.getString("url"), + descriptionHtml = json.getString("description_html"), + ) +} \ No newline at end of file From f61497ffd906087e353c2a01e0e6aec16fb108c6 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 9 May 2022 12:57:38 +0300 Subject: [PATCH 03/54] Shikimori manga tracking selection list --- app/build.gradle | 2 +- .../kotatsu/base/ui/BaseBottomSheet.kt | 9 ++ .../kotatsu/details/ui/DetailsActivity.kt | 20 ++-- .../kotatsu/details/ui/DetailsFragment.kt | 1 + .../kotatsu/details/ui/DetailsViewModel.kt | 20 +--- .../kotatsu/settings/SettingsActivity.kt | 6 ++ .../kotatsu/shikimori/ShikimoriModule.kt | 2 + .../shikimori/data/ShikimoriAuthenticator.kt | 8 +- .../shikimori/data/ShikimoriRepository.kt | 32 +++++- .../shikimori/data/model/ShikimoriManga.kt | 13 ++- .../shikimori/ui/ShikimoriSettingsFragment.kt | 4 +- .../selector/ShikimoriSelectorBottomSheet.kt | 97 +++++++++++++++++++ .../ui/selector/ShikimoriSelectorViewModel.kt | 69 +++++++++++++ .../ui/selector/adapter/ShikimoriMangaAD.kt | 52 ++++++++++ .../adapter/ShikimoriSelectorAdapter.kt | 40 ++++++++ .../org/koitharu/kotatsu/utils/ext/CoilExt.kt | 2 +- .../res/layout-w600dp/fragment_details.xml | 3 +- app/src/main/res/layout/fragment_details.xml | 3 +- .../main/res/layout/sheet_shiki_selector.xml | 46 +++++++++ app/src/main/res/menu/opt_details.xml | 6 ++ app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_content.xml | 12 +-- 24 files changed, 398 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt create mode 100644 app/src/main/res/layout/sheet_shiki_selector.xml diff --git a/app/build.gradle b/app/build.gradle index 913934c49..ddd401a15 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -66,7 +66,7 @@ android { } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation('com.github.nv95:kotatsu-parsers:b495e5e457') { + implementation('com.github.nv95:kotatsu-parsers:44e6842025') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt index 75503afc5..647cd93ae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt @@ -49,6 +49,15 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() { } } + fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) { + val b = behavior ?: return + b.addBottomSheetCallback(callback) + val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) + if (rootView != null) { + callback.onStateChanged(rootView, b.state) + } + } + protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index a1920bf80..04363de41 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -45,6 +45,7 @@ import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity +import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.getDisplayMessage @@ -151,14 +152,11 @@ class DetailsActivity : override fun onPrepareOptionsMenu(menu: Menu): Boolean { val manga = viewModel.manga.value - menu.findItem(R.id.action_save).isVisible = - manga?.source != null && manga.source != MangaSource.LOCAL - menu.findItem(R.id.action_delete).isVisible = - manga?.source == MangaSource.LOCAL - menu.findItem(R.id.action_browser).isVisible = - manga?.source != MangaSource.LOCAL - menu.findItem(R.id.action_shortcut).isVisible = - ShortcutManagerCompat.isRequestPinShortcutSupported(this) + menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL + menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL + menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL + menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this) + menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isShikimoriAvailable return super.onPrepareOptionsMenu(menu) } @@ -209,6 +207,12 @@ class DetailsActivity : } true } + R.id.action_shiki_track -> { + viewModel.manga.value?.let { + ShikimoriSelectorBottomSheet.show(supportFragmentManager, it) + } + true + } R.id.action_shortcut -> { viewModel.manga.value?.let { lifecycleScope.launch { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index 99c7319e3..7aa5d96a0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -34,6 +34,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ext.* diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 2f242ead6..d5ab2e2c2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.details.ui import androidx.core.os.LocaleListCompat -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope @@ -28,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -90,7 +88,8 @@ class DetailsViewModel( .asLiveData(viewModelScope.coroutineContext) val onMangaRemoved = SingleLiveEvent() - val shikimoriInfo = MutableLiveData() + val isShikimoriAvailable: Boolean + get() = shikimoriRepository.isAuthorized val branches = mangaData.map { it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() @@ -216,7 +215,6 @@ class DetailsViewModel( }.onFailure { error -> if (BuildConfig.DEBUG) error.printStackTrace() }.getOrNull() - findShikimoriManga(manga) } private fun mapChapters( @@ -329,18 +327,4 @@ class DetailsViewModel( it.chapter.name.contains(query, ignoreCase = true) } } - - private fun findShikimoriManga(manga: Manga) { - if (!shikimoriRepository.isAuthorized) { - return - } - launchJob(Dispatchers.Default) { - val data = runCatching { - shikimoriRepository.findMangaInfo(manga) - }.getOrNull() - if (data != null) { - shikimoriInfo.postValue(data) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index 75f20b8ff..bce7c3c31 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -121,6 +121,7 @@ class SettingsActivity : Intent.ACTION_VIEW -> handleUri(intent.data) ?: return ACTION_READER -> ReaderSettingsFragment() ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() + ACTION_SHIKIMORI -> ShikimoriSettingsFragment() ACTION_SOURCE -> SourceSettingsFragment.newInstance( intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL ) @@ -146,6 +147,7 @@ class SettingsActivity : private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" + private const val ACTION_SHIKIMORI = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SHIKIMORI_SETTINGS" private const val EXTRA_SOURCE = "source" private const val HOST_SHIKIMORI_AUTH = "shikimori-auth" @@ -156,6 +158,10 @@ class SettingsActivity : Intent(context, SettingsActivity::class.java) .setAction(ACTION_READER) + fun newShikimoriSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SHIKIMORI) + fun newSuggestionsSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_SUGGESTIONS) diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt index 7011ad798..64a2bda32 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.shikimori.data.ShikimoriInterceptor import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.shikimori.data.ShikimoriStorage import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsViewModel +import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorViewModel val shikimoriModule get() = module { @@ -23,4 +24,5 @@ val shikimoriModule viewModel { params -> ShikimoriSettingsViewModel(get(), params.getOrNull()) } + viewModel { params -> ShikimoriSelectorViewModel(params[0], get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt index b62d5bff0..6a73b5829 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt @@ -15,15 +15,15 @@ class ShikimoriAuthenticator( override fun authenticate(route: Route?, response: Response): Request? { val accessToken = storage.accessToken ?: return null if (!isRequestWithAccessToken(response)) { - return null; + return null } - synchronized (this) { + synchronized(this) { val newAccessToken = storage.accessToken ?: return null if (accessToken != newAccessToken) { - return newRequestWithAccessToken(response.request, newAccessToken); + return newRequestWithAccessToken(response.request, newAccessToken) } val updatedAccessToken = refreshAccessToken() ?: return null - return newRequestWithAccessToken(response.request, updatedAccessToken); + return newRequestWithAccessToken(response.request, updatedAccessToken) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt index c4ae8d251..2cfca1505 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt @@ -1,11 +1,15 @@ package org.koitharu.kotatsu.shikimori.data import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.parseJsonArray +import org.koitharu.kotatsu.parsers.util.urlEncoded import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser @@ -13,6 +17,8 @@ import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8" private const val REDIRECT_URI = "kotatsu://shikimori-auth" +private const val BASE_URL = "https://shikimori.one/api/" +private const val MANGA_PAGE_SIZE = 10 class ShikimoriRepository( private val okHttp: OkHttpClient, @@ -53,16 +59,32 @@ class ShikimoriRepository( return ShikimoriUser(response) } + suspend fun findManga(query: String, offset: Int): List { + val page = offset / MANGA_PAGE_SIZE + val pageOffset = offset % MANGA_PAGE_SIZE + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("mangas") + .addEncodedQueryParameter("page", (page + 1).toString()) + .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) + .addEncodedQueryParameter("censored", false.toString()) + .addQueryParameter("search", query) + .build() + val request = Request.Builder().url(url).get().build() + val response = okHttp.newCall(request).await().parseJsonArray() + val list = response.mapJSON { ShikimoriManga(it) } + return if (pageOffset != 0) list.drop(pageOffset) else list + } + suspend fun findMangaInfo(manga: Manga): ShikimoriMangaInfo? { val q = manga.title.urlEncoded() val request = Request.Builder() .get() - .url("https://shikimori.one/api/mangas?limit=20&search=$q&censored=false") + .url("https://shikimori.one/api/mangas?limit=5&search=$q&censored=false") val response = okHttp.newCall(request.build()).await().parseJsonArray() val candidates = response.mapJSON { ShikimoriManga(it) } - val bestCandidate = candidates.minByOrNull { - it.name.levenshteinDistance(manga.title) - } ?: return null + val bestCandidate = candidates.filter { + it.name.equals(manga.title, ignoreCase = true) || it.name.equals(manga.altTitle, ignoreCase = true) + }.singleOrNull() ?: return null return getMangaInfo(bestCandidate.id) } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt index 8ac0411fb..0369ea255 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt @@ -1,19 +1,24 @@ package org.koitharu.kotatsu.shikimori.data.model import org.json.JSONObject +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl class ShikimoriManga( val id: Long, val name: String, + val altName: String?, val cover: String, val url: String, -) { +) : ListModel { constructor(json: JSONObject) : this( id = json.getLong("id"), name = json.getString("name"), - cover = json.getJSONObject("image").getString("preview"), - url = json.getString("url"), + altName = json.getStringOrNull("russian"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), ) override fun equals(other: Any?): Boolean { @@ -24,6 +29,7 @@ class ShikimoriManga( if (id != other.id) return false if (name != other.name) return false + if (altName != other.altName) return false if (cover != other.cover) return false if (url != other.url) return false @@ -33,6 +39,7 @@ class ShikimoriManga( override fun hashCode(): Int { var result = id.hashCode() result = 31 * result + name.hashCode() + result = 31 * result + altName.hashCode() result = 31 * result + cover.hashCode() result = 31 * result + url.hashCode() return result diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt index ed4e3a600..27fa2ed7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt @@ -18,8 +18,6 @@ import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.withArgs -private const val KEY_USER = "shiki_user" - class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { private val viewModel by viewModel { @@ -64,6 +62,8 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { companion object { + private const val KEY_USER = "shiki_user" + private const val ARG_AUTH_CODE = "auth_code" fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) { diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt new file mode 100644 index 000000000..484c71aa3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt @@ -0,0 +1,97 @@ +package org.koitharu.kotatsu.shikimori.ui.selector + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.FragmentManager +import coil.transform.CircleCropTransformation +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.databinding.SheetShikiSelectorBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikimoriSelectorAdapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.withArgs + +class ShikimoriSelectorBottomSheet : + BaseBottomSheet(), + OnListItemClickListener, + PaginationScrollListener.Callback, View.OnClickListener { + + private val viewModel by viewModel { + parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga) + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetShikiSelectorBinding { + return SheetShikiSelectorBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.subtitle = viewModel.manga.title + binding.toolbar.setNavigationOnClickListener { dismiss() } + addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) + val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this) + with(binding.recyclerView) { + adapter = listAdapter + addOnScrollListener(PaginationScrollListener(4, this@ShikimoriSelectorBottomSheet)) + } + binding.imageViewUser.setOnClickListener(this) + + viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + viewModel.onError.observe(viewLifecycleOwner, ::onError) + viewModel.avatar.observe(viewLifecycleOwner, ::setUserAvatar) + } + + override fun onClick(v: View) { + when (v.id) { + R.id.imageView_user -> startActivity(SettingsActivity.newShikimoriSettingsIntent(v.context)) + } + } + + override fun onItemClick(item: ShikimoriManga, view: View) { + } + + override fun onScrolledToEnd() { + viewModel.loadList(append = true) + } + + private fun onError(e: Throwable) { + Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() + if (viewModel.isEmpty) { + dismissAllowingStateLoss() + } + } + + private fun setUserAvatar(url: String?) { + val iconSize = resources.getDimensionPixelSize(R.dimen.action_bar_item_size) + binding.imageViewUser.newImageRequest(url) + .transformations(CircleCropTransformation()) + .size(iconSize, iconSize) + .enqueueWith(get()) + } + + companion object { + + private const val TAG = "ShikimoriSelectorBottomSheet" + + fun show(fm: FragmentManager, manga: Manga) = + ShikimoriSelectorBottomSheet().withArgs(1) { + putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false)) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt new file mode 100644 index 000000000..7e671f1e0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt @@ -0,0 +1,69 @@ +package org.koitharu.kotatsu.shikimori.ui.selector + +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter +import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct + +class ShikimoriSelectorViewModel( + val manga: Manga, + private val repository: ShikimoriRepository, +) : BaseViewModel() { + + private val shikiMangaList = MutableStateFlow?>(null) + private val hasNextPage = MutableStateFlow(false) + private var loadingJob: Job? = null + + val content: LiveData> = combine( + shikiMangaList.filterNotNull(), + hasNextPage + ) { list, isHasNextPage -> + when { + list.isEmpty() -> listOf() + isHasNextPage -> list + LoadingFooter + else -> list + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + val avatar = liveData { + emit(runCatching { repository.getUser().avatar }.getOrNull()) + } + + val isEmpty: Boolean + get() = shikiMangaList.value.isNullOrEmpty() + + init { + loadList(append = false) + } + + fun loadList(append: Boolean) { + if (loadingJob?.isActive == true) { + return + } + if (append && !hasNextPage.value) { + return + } + loadingJob = launchLoadingJob(Dispatchers.Default) { + val offset = if (append) shikiMangaList.value?.size ?: 0 else 0 + val list = repository.findManga(manga.title, offset) + if (!append) { + shikiMangaList.value = list + } else if (list.isNotEmpty()) { + shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt new file mode 100644 index 000000000..4685806c8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.shikimori.ui.selector.adapter + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import coil.request.Disposable +import coil.size.Scale +import coil.util.CoilUtils +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemMangaListBinding +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.textAndVisible + +fun shikimoriMangaAD( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } +) { + + var imageRequest: Disposable? = null + + itemView.setOnClickListener { + clickListener.onItemClick(item, it) + } + + bind { + imageRequest?.dispose() + binding.textViewTitle.text = item.name + binding.textViewSubtitle.textAndVisible = item.altName + imageRequest = binding.imageViewCover.newImageRequest(item.cover) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) + .allowRgb565(true) + .lifecycle(lifecycleOwner) + .enqueueWith(coil) + } + + onViewRecycled { + imageRequest?.dispose() + imageRequest = null + CoilUtils.dispose(binding.imageViewCover) + binding.imageViewCover.setImageDrawable(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt new file mode 100644 index 000000000..5457aa078 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.shikimori.ui.selector.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD +import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import kotlin.jvm.internal.Intrinsics + +class ShikimoriSelectorAdapter( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(loadingStateAD()) + .addDelegate(shikimoriMangaAD(lifecycleOwner, coil, clickListener)) + .addDelegate(loadingFooterAD()) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return when { + oldItem === newItem -> true + oldItem is ShikimoriManga && newItem is ShikimoriManga -> oldItem.id == newItem.id + else -> false + } + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index 53cf3bdb2..16416bf51 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -11,7 +11,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener -fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context) +fun ImageView.newImageRequest(url: String?) = ImageRequest.Builder(context) .data(url) .crossfade(true) .target(this) diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 936a0ac6c..17cb204f4 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -160,7 +160,7 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_details.xml b/app/src/main/res/menu/opt_details.xml index 97236c2f0..d6cc9be85 100644 --- a/app/src/main/res/menu/opt_details.xml +++ b/app/src/main/res/menu/opt_details.xml @@ -17,6 +17,12 @@ android:visible="false" app:showAsAction="never" /> + + Название Изменить Изменить категорию + Отслеживание \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 282d15034..461b93d8b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -21,6 +21,8 @@ 16dp 2dp 12dp + 24dp + 32dp 124dp 4dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7199a5b89..aca50b55e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,4 +289,5 @@ Name Edit Edit category + Tracking \ No newline at end of file diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index 178cec046..0c0400bf2 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -11,9 +11,13 @@ + + - - Date: Mon, 9 May 2022 16:24:24 +0300 Subject: [PATCH 04/54] Cache shikimori user --- .../decor/AbstractSelectionItemDecoration.kt | 2 +- .../kotatsu/core/prefs/AppSettings.kt | 11 +--- .../list/ui/MangaSelectionDecoration.kt | 14 ++--- .../shikimori/data/ShikimoriAuthenticator.kt | 9 +++- .../shikimori/data/ShikimoriRepository.kt | 49 +++++++++++++---- .../shikimori/data/ShikimoriStorage.kt | 15 ++++++ .../shikimori/data/model/ShikimoriUser.kt | 26 ++++++++++ .../shikimori/ui/ShikimoriSettingsFragment.kt | 6 +++ .../ui/ShikimoriSettingsViewModel.kt | 8 +++ .../selector/ShikimoriSelectorBottomSheet.kt | 11 +++- .../ui/selector/ShikimoriSelectorViewModel.kt | 7 ++- .../adapter/ShikiMangaSelectionDecoration.kt | 52 +++++++++++++++++++ .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 30 ++++++++++- .../org/koitharu/kotatsu/utils/ext/HttpExt.kt | 10 ++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_shikimori.xml | 6 +++ 16 files changed, 225 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt index ac624d3c6..1974f6a5d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt @@ -12,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() { private val bounds = Rect() private val boundsF = RectF() - private val selection = HashSet() + protected val selection = HashSet() protected var hasBackground: Boolean = true protected var hasForeground: Boolean = false diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index ac70b392a..a3ebc7f7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.ext.getEnumValue +import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.toUriOrNull @@ -236,15 +237,7 @@ class AppSettings(context: Context) { prefs.unregisterOnSharedPreferenceChangeListener(listener) } - fun observe() = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - trySendBlocking(key) - } - prefs.registerOnSharedPreferenceChangeListener(listener) - awaitClose { - prefs.unregisterOnSharedPreferenceChangeListener(listener) - } - } + fun observe() = prefs.observe() companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt index 3ef530824..8422d00ec 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt @@ -18,17 +18,17 @@ import org.koitharu.kotatsu.utils.ext.getItem import org.koitharu.kotatsu.utils.ext.getThemeColor import com.google.android.material.R as materialR -class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { +open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) - private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) - private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) - private val fillColor = ColorUtils.setAlphaComponent( + protected val paint = Paint(Paint.ANTI_ALIAS_FLAG) + protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) + protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) + protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) + protected val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), 0x74 ) - private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) + protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) init { hasBackground = false diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt index 6a73b5829..6bf1381c2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt @@ -5,6 +5,7 @@ import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders class ShikimoriAuthenticator( @@ -38,9 +39,13 @@ class ShikimoriAuthenticator( .build() } - private fun refreshAccessToken(): String? { + private fun refreshAccessToken(): String? = runCatching { val repository = repositoryProvider() runBlocking { repository.authorize(null) } return storage.accessToken - } + }.onFailure { + if (BuildConfig.DEBUG) { + it.printStackTrace() + } + }.getOrNull() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt index 2cfca1505..5accdf614 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt @@ -4,6 +4,7 @@ import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import org.json.JSONObject import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.mapJSON @@ -13,11 +14,12 @@ import org.koitharu.kotatsu.parsers.util.urlEncoded import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.utils.ext.toRequestBody private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8" private const val REDIRECT_URI = "kotatsu://shikimori-auth" -private const val BASE_URL = "https://shikimori.one/api/" +private const val BASE_URL = "https://shikimori.one/" private const val MANGA_PAGE_SIZE = 10 class ShikimoriRepository( @@ -26,7 +28,7 @@ class ShikimoriRepository( ) { val oauthUrl: String - get() = "https://shikimori.one/oauth/authorize?client_id=$CLIENT_ID&" + + get() = "${BASE_URL}oauth/authorize?client_id=$CLIENT_ID&" + "redirect_uri=$REDIRECT_URI&response_type=code&scope=" val isAuthorized: Boolean @@ -45,7 +47,7 @@ class ShikimoriRepository( } val request = Request.Builder() .post(body.build()) - .url("https://shikimori.one/oauth/token") + .url("${BASE_URL}oauth/token") val response = okHttp.newCall(request.build()).await().parseJson() storage.accessToken = response.getString("access_token") storage.refreshToken = response.getString("refresh_token") @@ -54,15 +56,24 @@ class ShikimoriRepository( suspend fun getUser(): ShikimoriUser { val request = Request.Builder() .get() - .url("https://shikimori.one/api/users/whoami") + .url("${BASE_URL}api/users/whoami") val response = okHttp.newCall(request.build()).await().parseJson() - return ShikimoriUser(response) + return ShikimoriUser(response).also { storage.user = it } + } + + fun getCachedUser(): ShikimoriUser? { + return storage.user + } + + fun logout() { + storage.clear() } suspend fun findManga(query: String, offset: Int): List { val page = offset / MANGA_PAGE_SIZE val pageOffset = offset % MANGA_PAGE_SIZE val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") .addPathSegment("mangas") .addEncodedQueryParameter("page", (page + 1).toString()) .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) @@ -75,11 +86,31 @@ class ShikimoriRepository( return if (pageOffset != 0) list.drop(pageOffset) else list } + suspend fun trackManga(manga: Manga, shikiMangaId: Long) { + val user = getCachedUser() ?: getUser() + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("target_id", shikiMangaId) + put("target_type", "Manga") + put("user_id", user.id) + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .build() + val request = Request.Builder().url(url).post(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + } + suspend fun findMangaInfo(manga: Manga): ShikimoriMangaInfo? { val q = manga.title.urlEncoded() val request = Request.Builder() .get() - .url("https://shikimori.one/api/mangas?limit=5&search=$q&censored=false") + .url("${BASE_URL}api/mangas?limit=5&search=$q&censored=false") val response = okHttp.newCall(request.build()).await().parseJsonArray() val candidates = response.mapJSON { ShikimoriManga(it) } val bestCandidate = candidates.filter { @@ -91,7 +122,7 @@ class ShikimoriRepository( suspend fun getRelatedManga(id: Long): List { val request = Request.Builder() .get() - .url("https://shikimori.one/api/mangas/$id/related") + .url("${BASE_URL}api/mangas/$id/related") val response = okHttp.newCall(request.build()).await().parseJsonArray() return response.mapJSON { jo -> ShikimoriManga(jo) } } @@ -99,7 +130,7 @@ class ShikimoriRepository( suspend fun getSimilarManga(id: Long): List { val request = Request.Builder() .get() - .url("https://shikimori.one/api/mangas/$id/similar") + .url("${BASE_URL}api/mangas/$id/similar") val response = okHttp.newCall(request.build()).await().parseJsonArray() return response.mapJSON { jo -> ShikimoriManga(jo) } } @@ -107,7 +138,7 @@ class ShikimoriRepository( suspend fun getMangaInfo(id: Long): ShikimoriMangaInfo { val request = Request.Builder() .get() - .url("https://shikimori.one/api/mangas/$id") + .url("${BASE_URL}api/mangas/$id") val response = okHttp.newCall(request.build()).await().parseJson() return ShikimoriMangaInfo(response) } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt index 2edc946ba..210432670 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt @@ -2,10 +2,13 @@ package org.koitharu.kotatsu.shikimori.data import android.content.Context import androidx.core.content.edit +import org.json.JSONObject +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser private const val PREF_NAME = "shikimori" private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" +private const val KEY_USER = "user" class ShikimoriStorage(context: Context) { @@ -18,4 +21,16 @@ class ShikimoriStorage(context: Context) { var refreshToken: String? get() = prefs.getString(KEY_REFRESH_TOKEN, null) set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } + + var user: ShikimoriUser? + get() = prefs.getString(KEY_USER, null)?.let { + ShikimoriUser(JSONObject(it)) + } + set(value) = prefs.edit { + putString(KEY_USER, value?.toJson()?.toString()) + } + + fun clear() = prefs.edit { + clear() + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt index eff1464e6..d42188a63 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt @@ -13,4 +13,30 @@ class ShikimoriUser( nickname = json.getString("nickname"), avatar = json.getString("avatar"), ) + + fun toJson() = JSONObject().apply { + put("id", id) + put("nickname", nickname) + put("avatar", avatar) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShikimoriUser + + if (id != other.id) return false + if (nickname != other.nickname) return false + if (avatar != other.avatar) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + nickname.hashCode() + result = 31 * result + avatar.hashCode() + return result + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt index 27fa2ed7a..aa7cbc6a4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt @@ -37,6 +37,10 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { KEY_USER -> openAuthorization() + KEY_LOGOUT -> { + viewModel.logout() + true + } else -> super.onPreferenceTreeClick(preference) } } @@ -50,6 +54,7 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { .transformations(CircleCropTransformation()) .target(PreferenceIconTarget(pref)) .enqueueWith(coil) + findPreference(KEY_LOGOUT)?.isVisible = user != null } private fun openAuthorization(): Boolean { @@ -63,6 +68,7 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { companion object { private const val KEY_USER = "shiki_user" + private const val KEY_LOGOUT = "shiki_logout" private const val ARG_AUTH_CODE = "auth_code" diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt index 3fd4186d5..88e62f08d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt @@ -24,8 +24,16 @@ class ShikimoriSettingsViewModel( } } + fun logout() { + launchJob(Dispatchers.Default) { + repository.logout() + user.postValue(null) + } + } + private fun loadUser() = launchJob(Dispatchers.Default) { val userModel = if (repository.isAuthorized) { + repository.getCachedUser()?.let(user::postValue) repository.getUser() } else { null diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt index 484c71aa3..ed2cf4997 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt @@ -20,6 +20,7 @@ import org.koitharu.kotatsu.databinding.SheetShikiSelectorBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikiMangaSelectionDecoration import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikimoriSelectorAdapter import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.ext.enqueueWith @@ -30,7 +31,8 @@ import org.koitharu.kotatsu.utils.ext.withArgs class ShikimoriSelectorBottomSheet : BaseBottomSheet(), OnListItemClickListener, - PaginationScrollListener.Callback, View.OnClickListener { + PaginationScrollListener.Callback, + View.OnClickListener { private val viewModel by viewModel { parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga) @@ -46,13 +48,19 @@ class ShikimoriSelectorBottomSheet : binding.toolbar.setNavigationOnClickListener { dismiss() } addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this) + val decoration = ShikiMangaSelectionDecoration(view.context) with(binding.recyclerView) { adapter = listAdapter + addItemDecoration(decoration) addOnScrollListener(PaginationScrollListener(4, this@ShikimoriSelectorBottomSheet)) } binding.imageViewUser.setOnClickListener(this) viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + viewModel.selectedItemId.observe(viewLifecycleOwner) { + decoration.checkedItemId = it + binding.recyclerView.invalidateItemDecorations() + } viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.avatar.observe(viewLifecycleOwner, ::setUserAvatar) } @@ -64,6 +72,7 @@ class ShikimoriSelectorBottomSheet : } override fun onItemClick(item: ShikimoriManga, view: View) { + viewModel.selectedItemId.value = item.id } override fun onScrolledToEnd() { diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt index 7e671f1e0..4488d0875 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt @@ -1,8 +1,10 @@ package org.koitharu.kotatsu.shikimori.ui.selector import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -37,7 +39,10 @@ class ShikimoriSelectorViewModel( } }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) - val avatar = liveData { + val selectedItemId = MutableLiveData(RecyclerView.NO_ID) + + val avatar = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { + emit(repository.getCachedUser()?.avatar) emit(runCatching { repository.getUser().avatar }.getOrNull()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt new file mode 100644 index 000000000..72f758bc3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.shikimori.ui.selector.adapter + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID +import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration +import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.utils.ext.getItem + +class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { + + var checkedItemId: Long + get() = selection.singleOrNull() ?: NO_ID + set(value) { + clearSelection() + if (value != NO_ID) { + selection.add(value) + } + } + + override fun getItemId(parent: RecyclerView, child: View): Long { + val holder = parent.getChildViewHolder(child) ?: return NO_ID + val item = holder.getItem(ShikimoriManga::class.java) ?: return NO_ID + return item.id + } + + override fun onDrawForeground( + canvas: Canvas, + parent: RecyclerView, + child: View, + bounds: RectF, + state: RecyclerView.State, + ) { + paint.color = strokeColor + paint.style = Paint.Style.STROKE + canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint) + checkIcon?.run { + val offset = (bounds.height() - intrinsicHeight) / 2 + setBounds( + (bounds.right - offset - intrinsicWidth).toInt(), + (bounds.top + offset).toInt(), + (bounds.right - offset).toInt(), + (bounds.top + offset + intrinsicHeight).toInt(), + ) + draw(canvas) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 6f15f7cd3..c3fdde215 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.utils.ext import android.content.Context +import android.content.SharedPreferences import android.content.pm.ResolveInfo import android.net.ConnectivityManager import android.net.Network @@ -10,8 +11,14 @@ import android.os.Build import androidx.activity.result.ActivityResultLauncher import androidx.core.app.ActivityOptionsCompat import androidx.work.CoroutineWorker -import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.suspendCancellableCoroutine val Context.connectivityManager: ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -55,4 +62,23 @@ fun ActivityResultLauncher.tryLaunch(input: I, options: ActivityOptionsCo return runCatching { launch(input, options) }.isSuccess -} \ No newline at end of file +} + +fun SharedPreferences.observe() = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + trySendBlocking(key) + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { + unregisterOnSharedPreferenceChangeListener(listener) + } +} + +fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow { + emit(valueProducer()) + observe().collect { upstreamKey -> + if (upstreamKey == key) { + emit(valueProducer()) + } + } +}.distinctUntilChanged() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt new file mode 100644 index 000000000..058ca4ea6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt @@ -0,0 +1,10 @@ + +package org.koitharu.kotatsu.utils.ext + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +private val TYPE_JSON = "application/json".toMediaType() + +fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d24c165d3..78a8144e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -291,4 +291,5 @@ Edit category Tracking No favourite categories + Logout \ No newline at end of file diff --git a/app/src/main/res/xml/pref_shikimori.xml b/app/src/main/res/xml/pref_shikimori.xml index b77f72f4f..0e8d9118f 100644 --- a/app/src/main/res/xml/pref_shikimori.xml +++ b/app/src/main/res/xml/pref_shikimori.xml @@ -10,4 +10,10 @@ android:title="@string/loading_" app:iconSpaceReserved="true" /> + + From 3be96cf035c47e190278115ff489d36af535859a Mon Sep 17 00:00:00 2001 From: SkyfaceD Date: Tue, 17 May 2022 15:40:34 +0600 Subject: [PATCH 05/54] Hide shikimori sensitive information --- app/build.gradle | 3 +++ .../kotatsu/shikimori/data/ShikimoriRepository.kt | 9 ++++----- build.gradle | 9 +++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ddd401a15..aa3e958a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,6 +24,9 @@ android { arg 'room.schemaLocation', "$projectDir/schemas".toString() } } + + buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', localProperty('shikimori.clientId') + buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', localProperty('shikimori.clientSecret') } buildTypes { debug { diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt index 2cfca1505..e8dfae344 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt @@ -4,6 +4,7 @@ import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.mapJSON @@ -14,8 +15,6 @@ import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser -private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" -private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8" private const val REDIRECT_URI = "kotatsu://shikimori-auth" private const val BASE_URL = "https://shikimori.one/api/" private const val MANGA_PAGE_SIZE = 10 @@ -26,7 +25,7 @@ class ShikimoriRepository( ) { val oauthUrl: String - get() = "https://shikimori.one/oauth/authorize?client_id=$CLIENT_ID&" + + get() = "https://shikimori.one/oauth/authorize?client_id=${BuildConfig.SHIKIMORI_CLIENT_ID}&" + "redirect_uri=$REDIRECT_URI&response_type=code&scope=" val isAuthorized: Boolean @@ -35,8 +34,8 @@ class ShikimoriRepository( suspend fun authorize(code: String?) { val body = FormBody.Builder() body.add("grant_type", "authorization_code") - body.add("client_id", CLIENT_ID) - body.add("client_secret", CLIENT_SECRET) + body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID) + body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET) if (code != null) { body.add("redirect_uri", REDIRECT_URI) body.add("code", code) diff --git a/build.gradle b/build.gradle index 390d5748a..be41a3021 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,15 @@ allprojects { } } +Object localProperty(String name, Object defaultValue = 'null') { + Properties localProperties = new Properties() + project.rootProject.file('local.properties').withInputStream { localProperties.load(it) } + + def value = localProperties[name] + + return value != null ? value : defaultValue +} + task clean(type: Delete) { delete rootProject.buildDir } \ No newline at end of file From ec89ba0155dcbd69f4136555f6a5fa7d1b8bfdc2 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 22 Jun 2022 12:46:48 +0300 Subject: [PATCH 06/54] Shikimori interaction implementation --- .../java/org/koitharu/kotatsu/KotatsuApp.kt | 2 +- .../koitharu/kotatsu/core/db/MangaDatabase.kt | 18 +- .../core/db/migrations/Migration11To12.kt | 25 +++ .../koitharu/kotatsu/details/DetailsModule.kt | 2 +- .../kotatsu/details/ui/DetailsActivity.kt | 6 +- .../kotatsu/details/ui/DetailsFragment.kt | 33 ++- .../kotatsu/details/ui/DetailsViewModel.kt | 23 +- .../scrobbling/ScrobblingInfoBottomSheet.kt | 119 +++++++++++ .../koitharu/kotatsu/history/HistoryModule.kt | 2 +- .../history/domain/HistoryRepository.kt | 7 + .../kotatsu/scrobbling/data/ScrobblingDao.kt | 20 ++ .../scrobbling/data/ScrobblingEntity.kt | 35 ++++ .../kotatsu/scrobbling/domain/Scrobbler.kt | 78 +++++++ .../domain/model/ScrobblerManga.kt} | 19 +- .../domain/model/ScrobblerMangaInfo.kt | 9 + .../domain/model/ScrobblerService.kt | 14 ++ .../scrobbling/domain/model/ScrobblingInfo.kt | 52 +++++ .../domain/model/ScrobblingStatus.kt | 11 + .../scrobbling/shikimori/ShikimoriModule.kt | 32 +++ .../shikimori/data/ShikimoriAuthenticator.kt | 2 +- .../shikimori/data/ShikimoriInterceptor.kt | 9 +- .../shikimori/data/ShikimoriRepository.kt | 196 ++++++++++++++++++ .../shikimori/data/ShikimoriStorage.kt | 4 +- .../shikimori/data/model/ShikimoriUser.kt | 2 +- .../shikimori/domain/ShikimoriScrobbler.kt | 64 ++++++ .../shikimori/ui/ShikimoriSettingsFragment.kt | 4 +- .../ui/ShikimoriSettingsViewModel.kt | 10 +- .../selector/ScrobblingSelectorBottomSheet.kt | 155 ++++++++++++++ .../selector/ScrobblingSelectorViewModel.kt} | 57 +++-- .../adapter/ShikiMangaSelectionDecoration.kt | 6 +- .../ui/selector/adapter/ShikimoriMangaAD.kt | 8 +- .../adapter/ShikimoriSelectorAdapter.kt | 10 +- .../settings/ContentSettingsFragment.kt | 32 +-- .../settings/HistorySettingsFragment.kt | 35 ++++ .../kotatsu/settings/SettingsActivity.kt | 2 +- .../kotatsu/shikimori/ShikimoriModule.kt | 28 --- .../shikimori/data/ShikimoriRepository.kt | 145 ------------- .../data/model/ShikimoriMangaInfo.kt | 20 -- .../selector/ShikimoriSelectorBottomSheet.kt | 106 ---------- .../kotatsu/utils/ext/CollectionExt.kt | 9 + .../koitharu/kotatsu/utils/ext/CommonExt.kt | 6 +- .../koitharu/kotatsu/utils/ext/LiveDataExt.kt | 11 +- .../main/res/drawable-hdpi/ic_shikimori.png | Bin 0 -> 1361 bytes .../main/res/drawable-mdpi/ic_shikimori.png | Bin 0 -> 723 bytes .../main/res/drawable-xhdpi/ic_shikimori.png | Bin 0 -> 1695 bytes .../main/res/drawable-xxhdpi/ic_shikimori.png | Bin 0 -> 3177 bytes .../res/drawable-xxxhdpi/ic_shikimori.png | Bin 0 -> 3743 bytes .../res/layout-w600dp/fragment_details.xml | 15 ++ app/src/main/res/layout/fragment_details.xml | 17 +- .../res/layout/layout_scrobbling_info.xml | 68 ++++++ app/src/main/res/layout/sheet_scrobbling.xml | 113 ++++++++++ ...ctor.xml => sheet_scrobbling_selector.xml} | 14 +- app/src/main/res/menu/opt_shiki_selector.xml | 13 ++ app/src/main/res/values/arrays.xml | 8 + app/src/main/res/values/strings.xml | 7 +- app/src/main/res/xml/pref_content.xml | 5 - app/src/main/res/xml/pref_history.xml | 9 + 57 files changed, 1280 insertions(+), 417 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt rename app/src/main/java/org/koitharu/kotatsu/{shikimori/data/model/ShikimoriManga.kt => scrobbling/domain/model/ScrobblerManga.kt} (57%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/ShikimoriAuthenticator.kt (96%) rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/ShikimoriInterceptor.kt (65%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/ShikimoriStorage.kt (87%) rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/data/model/ShikimoriUser.kt (93%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/ui/ShikimoriSettingsFragment.kt (95%) rename app/src/main/java/org/koitharu/kotatsu/{ => scrobbling}/shikimori/ui/ShikimoriSettingsViewModel.kt (76%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt rename app/src/main/java/org/koitharu/kotatsu/{shikimori/ui/selector/ShikimoriSelectorViewModel.kt => scrobbling/ui/selector/ScrobblingSelectorViewModel.kt} (57%) rename app/src/main/java/org/koitharu/kotatsu/{shikimori => scrobbling}/ui/selector/adapter/ShikiMangaSelectionDecoration.kt (87%) rename app/src/main/java/org/koitharu/kotatsu/{shikimori => scrobbling}/ui/selector/adapter/ShikimoriMangaAD.kt (85%) rename app/src/main/java/org/koitharu/kotatsu/{shikimori => scrobbling}/ui/selector/adapter/ShikimoriSelectorAdapter.kt (82%) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt create mode 100644 app/src/main/res/drawable-hdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-mdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_shikimori.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_shikimori.png create mode 100644 app/src/main/res/layout/layout_scrobbling_info.xml create mode 100644 app/src/main/res/layout/sheet_scrobbling.xml rename app/src/main/res/layout/{sheet_shiki_selector.xml => sheet_scrobbling_selector.xml} (81%) create mode 100644 app/src/main/res/menu/opt_shiki_selector.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index bc98a2e45..0ab9b2d1e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -31,9 +31,9 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.remotelist.remoteListModule +import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.settings.settingsModule -import org.koitharu.kotatsu.shikimori.shikimoriModule import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.widget.WidgetUpdater diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 2c74455f8..82d5052aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -6,8 +6,14 @@ import androidx.room.Room import androidx.room.RoomDatabase import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarksDao -import org.koitharu.kotatsu.core.db.dao.* -import org.koitharu.kotatsu.core.db.entity.* +import org.koitharu.kotatsu.core.db.dao.MangaDao +import org.koitharu.kotatsu.core.db.dao.PreferencesDao +import org.koitharu.kotatsu.core.db.dao.TagsDao +import org.koitharu.kotatsu.core.db.dao.TrackLogsDao +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity @@ -15,6 +21,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.tracker.data.TrackEntity @@ -26,8 +34,9 @@ import org.koitharu.kotatsu.tracker.data.TracksDao MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, + ScrobblingEntity::class, ], - version = 11, + version = 12, ) abstract class MangaDatabase : RoomDatabase() { @@ -50,6 +59,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract val suggestionDao: SuggestionDao abstract val bookmarksDao: BookmarksDao + + abstract val scrobblingDao: ScrobblingDao } fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( @@ -67,6 +78,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( Migration8To9(), Migration9To10(), Migration10To11(), + Migration11To12(), ).addCallback( DatabasePrePopulateCallback(context.resources) ).build() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt new file mode 100644 index 000000000..8c3f2800f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration11To12 : Migration(11, 12) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `scrobblings` ( + `scrobbler` INTEGER NOT NULL, + `id` INTEGER NOT NULL, + `manga_id` INTEGER NOT NULL, + `target_id` INTEGER NOT NULL, + `status` TEXT, + `chapter` INTEGER NOT NULL, + `comment` TEXT, + `rating` REAL NOT NULL, + PRIMARY KEY(`scrobbler`, `id`, `manga_id`) + ) + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt index 916b75de1..88b40e2be 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt @@ -8,6 +8,6 @@ val detailsModule get() = module { viewModel { intent -> - DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) + DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 46a6aff01..44ee1e7ff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -42,8 +42,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity -import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet import org.koitharu.kotatsu.utils.ext.getDisplayMessage class DetailsActivity : @@ -156,7 +156,7 @@ class DetailsActivity : menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this) - menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isShikimoriAvailable + menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable return super.onPrepareOptionsMenu(menu) } @@ -199,7 +199,7 @@ class DetailsActivity : } R.id.action_shiki_track -> { viewModel.manga.value?.let { - ShikimoriSelectorBottomSheet.show(supportFragmentManager, it) + ScrobblingSelectorBottomSheet.show(supportFragmentManager, it) } true } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index ab29930dc..c54b58391 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -17,6 +17,7 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader import coil.request.ImageRequest +import coil.size.Scale import coil.util.CoilUtils import com.google.android.material.chip.Chip import kotlinx.coroutines.launch @@ -31,6 +32,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.databinding.FragmentDetailsBinding +import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga @@ -39,9 +41,9 @@ import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.* @@ -68,6 +70,7 @@ class DetailsFragment : binding.buttonRead.setOnClickListener(this) binding.buttonRead.setOnLongClickListener(this) binding.imageViewCover.setOnClickListener(this) + binding.scrobblingLayout.root.setOnClickListener(this) binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.chipsTags.onChipClickListener = this viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) @@ -75,6 +78,7 @@ class DetailsFragment : viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) + viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) addMenuProvider(DetailsMenuProvider()) } @@ -210,12 +214,39 @@ class DetailsFragment : } } + private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) { + with(binding.scrobblingLayout) { + root.isVisible = scrobbling != null + if (scrobbling == null) { + CoilUtils.dispose(imageViewCover) + return + } + imageViewCover.newImageRequest(scrobbling.coverUrl) + .crossfade(true) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) + .lifecycle(viewLifecycleOwner) + .enqueueWith(coil) + textViewTitle.text = scrobbling.title + textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0) + ratingBar.rating = scrobbling.rating * ratingBar.numStars + textViewStatus.text = scrobbling.status?.let { + resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal) + } + } + } + override fun onClick(v: View) { val manga = viewModel.manga.value ?: return when (v.id) { R.id.button_favorite -> { FavouriteCategoriesBottomSheet.show(childFragmentManager, manga) } + R.id.scrobbling_layout -> { + ScrobblingInfoBottomSheet.show(childFragmentManager) + } R.id.button_read -> { val chapterId = viewModel.readingHistory.value?.chapterId if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 05b9537b6..42e0ec024 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -26,7 +26,8 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -41,7 +42,7 @@ class DetailsViewModel( mangaDataRepository: MangaDataRepository, private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, - private val shikimoriRepository: ShikimoriRepository, + private val scrobbler: Scrobbler, ) : BaseViewModel() { private val delegate = MangaDetailsDelegate( @@ -81,8 +82,11 @@ class DetailsViewModel( }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) val onMangaRemoved = SingleLiveEvent() - val isShikimoriAvailable: Boolean - get() = shikimoriRepository.isAuthorized + val isScrobblingAvailable: Boolean + get() = scrobbler.isAvailable + + val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId) + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) val branches: LiveData> = delegate.manga.map { val chapters = it?.chapters ?: return@map emptyList() @@ -192,6 +196,17 @@ class DetailsViewModel( } } + fun updateScrobbling(rating: Float, status: ScrobblingStatus?) { + launchJob(Dispatchers.Default) { + scrobbler.updateScrobblingInfo( + mangaId = delegate.mangaId, + rating = rating, + status = status, + comment = null, + ) + } + } + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { delegate.doLoad() } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt new file mode 100644 index 000000000..dd68d16ab --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt @@ -0,0 +1,119 @@ +package org.koitharu.kotatsu.details.ui.scrobbling + +import android.app.ActivityOptions +import android.content.Intent +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.RatingBar +import android.widget.Toast +import androidx.core.net.toUri +import androidx.fragment.app.FragmentManager +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Scale +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.databinding.SheetScrobblingBinding +import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.image.ui.ImageActivity +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +class ScrobblingInfoBottomSheet : + BaseBottomSheet(), + AdapterView.OnItemSelectedListener, + RatingBar.OnRatingBarChangeListener, + View.OnClickListener { + + private val viewModel by sharedViewModel() + private val coil by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding { + return SheetScrobblingBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) + viewModel.onError.observe(viewLifecycleOwner) { + Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show() + } + + binding.spinnerStatus.onItemSelectedListener = this + binding.ratingBar.onRatingBarChangeListener = this + binding.buttonOpen.setOnClickListener(this) + binding.imageViewCover.setOnClickListener(this) + binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + viewModel.updateScrobbling( + rating = binding.ratingBar.rating / binding.ratingBar.numStars, + status = enumValues().getOrNull(position), + ) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + + override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) { + if (fromUser) { + viewModel.updateScrobbling( + rating = rating / ratingBar.numStars, + status = enumValues().getOrNull(binding.spinnerStatus.selectedItemPosition), + ) + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_open -> { + val url = viewModel.scrobblingInfo.value?.externalUrl ?: return + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity( + Intent.createChooser(intent, getString(R.string.open_in_browser)) + ) + } + R.id.imageView_cover -> { + val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return + val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height) + startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle()) + } + } + } + + private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) { + if (scrobbling == null) { + dismissAllowingStateLoss() + return + } + binding.textViewTitle.text = scrobbling.title + binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars + binding.textViewDescription.text = scrobbling.description + binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) + ImageRequest.Builder(context ?: return) + .target(binding.imageViewCover) + .data(scrobbling.coverUrl) + .crossfade(true) + .lifecycle(viewLifecycleOwner) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) + .enqueueWith(coil) + } + + companion object { + + private const val TAG = "ScrobblingInfoBottomSheet" + + fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt index 246cb3a5f..e155bb4eb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt @@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel val historyModule get() = module { - factory { HistoryRepository(get(), get(), get()) } + factory { HistoryRepository(get(), get(), get(), getAll()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index 4519b60e4..024b9e9e3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -13,6 +13,8 @@ import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.toMangaHistory import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.tryScrobble import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.ext.mapItems @@ -20,6 +22,7 @@ class HistoryRepository( private val db: MangaDatabase, private val trackingRepository: TrackingRepository, private val settings: AppSettings, + private val scrobblers: List, ) { suspend fun getList(offset: Int, limit: Int = 20): List { @@ -78,6 +81,10 @@ class HistoryRepository( ) ) trackingRepository.syncWithHistory(manga, chapterId) + val chapter = manga.chapters?.find { x -> x.id == chapterId } + if (chapter != null) { + scrobblers.forEach { it.tryScrobble(manga.id, chapter) } + } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt new file mode 100644 index 000000000..38b798b94 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.scrobbling.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class ScrobblingDao { + + @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") + abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity? + + @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") + abstract fun observe(scrobbler: Int, mangaId: Long): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(entity: ScrobblingEntity) + + @Update + abstract suspend fun update(entity: ScrobblingEntity) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt new file mode 100644 index 000000000..dc4e02d8e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.scrobbling.data + +import androidx.room.ColumnInfo +import androidx.room.Entity + +@Entity( + tableName = "scrobblings", + primaryKeys = ["scrobbler", "id", "manga_id"], +) +class ScrobblingEntity( + @ColumnInfo(name = "scrobbler") val scrobbler: Int, + @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "manga_id") val mangaId: Long, + @ColumnInfo(name = "target_id") val targetId: Long, + @ColumnInfo(name = "status") val status: String?, + @ColumnInfo(name = "chapter") val chapter: Int, + @ColumnInfo(name = "comment") val comment: String?, + @ColumnInfo(name = "rating") val rating: Float, +) { + + fun copy( + status: String?, + comment: String?, + rating: Float, + ) = ScrobblingEntity( + scrobbler = scrobbler, + id = id, + mangaId = mangaId, + targetId = targetId, + status = status, + chapter = chapter, + comment = comment, + rating = rating, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt new file mode 100644 index 000000000..5f7e42cc8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.scrobbling.domain + +import androidx.collection.LongSparseArray +import androidx.collection.getOrElse +import androidx.core.text.parseAsHtml +import java.util.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity +import org.koitharu.kotatsu.scrobbling.domain.model.* +import org.koitharu.kotatsu.utils.ext.findKey +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +abstract class Scrobbler( + protected val db: MangaDatabase, + val scrobblerService: ScrobblerService, +) { + + private val infoCache = LongSparseArray() + protected val statuses = EnumMap(ScrobblingStatus::class.java) + + abstract val isAvailable: Boolean + + abstract suspend fun findManga(query: String, offset: Int): List + + abstract suspend fun linkManga(mangaId: Long, targetId: Long) + + abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter) + + suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null + return entity.toScrobblingInfo(mangaId) + } + + abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?) + + fun observeScrobblingInfo(mangaId: Long): Flow { + return db.scrobblingDao.observe(scrobblerService.id, mangaId) + .map { it?.toScrobblingInfo(mangaId) } + } + + protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo + + private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? { + val mangaInfo = infoCache.getOrElse(targetId) { + runCatching { + getMangaInfo(targetId) + }.onFailure { + it.printStackTraceDebug() + }.onSuccess { + infoCache.put(targetId, it) + }.getOrNull() ?: return null + } + return ScrobblingInfo( + scrobbler = scrobblerService, + mangaId = mangaId, + targetId = targetId, + status = statuses.findKey(status), + chapter = chapter, + comment = comment, + rating = rating, + title = mangaInfo.name, + coverUrl = mangaInfo.cover, + description = mangaInfo.descriptionHtml.parseAsHtml(), + externalUrl = mangaInfo.url, + ) + } +} + +suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean { + return runCatching { + scrobble(mangaId, chapter) + }.onFailure { + it.printStackTraceDebug() + }.isSuccess +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt similarity index 57% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt index 0369ea255..9e28c9d7d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt @@ -1,11 +1,8 @@ -package org.koitharu.kotatsu.shikimori.data.model +package org.koitharu.kotatsu.scrobbling.domain.model -import org.json.JSONObject import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.util.json.getStringOrNull -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -class ShikimoriManga( +class ScrobblerManga( val id: Long, val name: String, val altName: String?, @@ -13,19 +10,11 @@ class ShikimoriManga( val url: String, ) : ListModel { - constructor(json: JSONObject) : this( - id = json.getLong("id"), - name = json.getString("name"), - altName = json.getStringOrNull("russian"), - cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), - url = json.getString("url").toAbsoluteUrl("shikimori.one"), - ) - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - other as ShikimoriManga + other as ScrobblerManga if (id != other.id) return false if (name != other.name) return false @@ -46,6 +35,6 @@ class ShikimoriManga( } override fun toString(): String { - return "ShikimoriManga #$id \"$name\" $url" + return "ScrobblerManga #$id \"$name\" $url" } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt new file mode 100644 index 000000000..940262041 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +class ScrobblerMangaInfo( + val id: Long, + val name: String, + val cover: String, + val url: String, + val descriptionHtml: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt new file mode 100644 index 000000000..45038ed12 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.koitharu.kotatsu.R + +enum class ScrobblerService( + val id: Int, + @StringRes val titleResId: Int, + @DrawableRes val iconResId: Int, +) { + + SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt new file mode 100644 index 000000000..87393d6ec --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +class ScrobblingInfo( + val scrobbler: ScrobblerService, + val mangaId: Long, + val targetId: Long, + val status: ScrobblingStatus?, + val chapter: Int, + val comment: String?, + val rating: Float, + val title: String, + val coverUrl: String, + val description: CharSequence?, + val externalUrl: String, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScrobblingInfo + + if (scrobbler != other.scrobbler) return false + if (mangaId != other.mangaId) return false + if (targetId != other.targetId) return false + if (status != other.status) return false + if (chapter != other.chapter) return false + if (comment != other.comment) return false + if (rating != other.rating) return false + if (title != other.title) return false + if (coverUrl != other.coverUrl) return false + if (description != other.description) return false + if (externalUrl != other.externalUrl) return false + + return true + } + + override fun hashCode(): Int { + var result = scrobbler.hashCode() + result = 31 * result + mangaId.hashCode() + result = 31 * result + targetId.hashCode() + result = 31 * result + (status?.hashCode() ?: 0) + result = 31 * result + chapter + result = 31 * result + (comment?.hashCode() ?: 0) + result = 31 * result + rating.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + coverUrl.hashCode() + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + externalUrl.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt new file mode 100644 index 000000000..cfb408094 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +enum class ScrobblingStatus { + + PLANNED, + READING, + RE_READING, + COMPLETED, + ON_HOLD, + DROPPED, +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt new file mode 100644 index 000000000..ec3c65b57 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.scrobbling.shikimori + +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage +import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler +import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsViewModel +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorViewModel + +val shikimoriModule + get() = module { + single { ShikimoriStorage(androidContext()) } + factory { + val okHttp = OkHttpClient.Builder().apply { + authenticator(ShikimoriAuthenticator(get(), ::get)) + addInterceptor(ShikimoriInterceptor(get())) + }.build() + ShikimoriRepository(okHttp, get(), get()) + } + factory { ShikimoriScrobbler(get(), get()) } bind Scrobbler::class + viewModel { params -> + ShikimoriSettingsViewModel(get(), params.getOrNull()) + } + viewModel { params -> ScrobblingSelectorViewModel(params[0], get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt similarity index 96% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt index 6bf1381c2..8a94bf98a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.data +package org.koitharu.kotatsu.scrobbling.shikimori.data import kotlinx.coroutines.runBlocking import okhttp3.Authenticator diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt similarity index 65% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt index 33ff454c3..f203f2e4c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt @@ -1,7 +1,8 @@ -package org.koitharu.kotatsu.shikimori.data +package org.koitharu.kotatsu.scrobbling.shikimori.data import okhttp3.Interceptor import okhttp3.Response +import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders private const val USER_AGENT_SHIKIMORI = "Kotatsu" @@ -14,6 +15,10 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor storage.accessToken?.let { request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") } - return chain.proceed(request.build()) + val response = chain.proceed(request.build()) + if (!response.isSuccessful && !response.isRedirect) { + throw IOException("${response.code} ${response.message}") + } + return response } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt new file mode 100644 index 000000000..6d1c60549 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt @@ -0,0 +1,196 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.data + +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.parseJsonArray +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.utils.ext.toRequestBody + +private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" +private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8" +private const val REDIRECT_URI = "kotatsu://shikimori-auth" +private const val BASE_URL = "https://shikimori.one/" +private const val MANGA_PAGE_SIZE = 10 + +class ShikimoriRepository( + private val okHttp: OkHttpClient, + private val storage: ShikimoriStorage, + private val db: MangaDatabase, +) { + + val oauthUrl: String + get() = "${BASE_URL}oauth/authorize?client_id=$CLIENT_ID&" + + "redirect_uri=$REDIRECT_URI&response_type=code&scope=" + + val isAuthorized: Boolean + get() = storage.accessToken != null + + suspend fun authorize(code: String?) { + val body = FormBody.Builder() + body.add("grant_type", "authorization_code") + body.add("client_id", CLIENT_ID) + body.add("client_secret", CLIENT_SECRET) + if (code != null) { + body.add("redirect_uri", REDIRECT_URI) + body.add("code", code) + } else { + body.add("refresh_token", checkNotNull(storage.refreshToken)) + } + val request = Request.Builder() + .post(body.build()) + .url("${BASE_URL}oauth/token") + val response = okHttp.newCall(request.build()).await().parseJson() + storage.accessToken = response.getString("access_token") + storage.refreshToken = response.getString("refresh_token") + } + + suspend fun loadUser(): ShikimoriUser { + val request = Request.Builder() + .get() + .url("${BASE_URL}api/users/whoami") + val response = okHttp.newCall(request.build()).await().parseJson() + return ShikimoriUser(response).also { storage.user = it } + } + + fun getCachedUser(): ShikimoriUser? { + return storage.user + } + + fun logout() { + storage.clear() + } + + suspend fun findManga(query: String, offset: Int): List { + val page = offset / MANGA_PAGE_SIZE + val pageOffset = offset % MANGA_PAGE_SIZE + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("mangas") + .addEncodedQueryParameter("page", (page + 1).toString()) + .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) + .addEncodedQueryParameter("censored", false.toString()) + .addQueryParameter("search", query) + .build() + val request = Request.Builder().url(url).get().build() + val response = okHttp.newCall(request).await().parseJsonArray() + val list = response.mapJSON { ScrobblerManga(it) } + return if (pageOffset != 0) list.drop(pageOffset) else list + } + + suspend fun createRate(mangaId: Long, shikiMangaId: Long) { + val user = getCachedUser() ?: loadUser() + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("target_id", shikiMangaId) + put("target_type", "Manga") + put("user_id", user.id) + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .build() + val request = Request.Builder().url(url).post(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("chapters", chapter.number) + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .addPathSegment(rateId.toString()) + .build() + val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + val payload = JSONObject() + payload.put( + "user_rate", + JSONObject().apply { + put("score", rating.toString()) + if (comment != null) { + put("text", comment) + } + if (status != null) { + put("status", status) + } + } + ) + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("user_rates") + .addPathSegment(rateId.toString()) + .build() + val request = Request.Builder().url(url).patch(payload.toRequestBody()).build() + val response = okHttp.newCall(request).await().parseJson() + saveRate(response, mangaId) + } + + suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + val request = Request.Builder() + .get() + .url("${BASE_URL}api/mangas/$id") + val response = okHttp.newCall(request.build()).await().parseJson() + return ScrobblerMangaInfo(response) + } + + private suspend fun saveRate(json: JSONObject, mangaId: Long) { + val entity = ScrobblingEntity( + scrobbler = ScrobblerService.SHIKIMORI.id, + id = json.getInt("id"), + mangaId = mangaId, + targetId = json.getLong("target_id"), + status = json.getString("status"), + chapter = json.getInt("chapters"), + comment = json.getString("text"), + rating = json.getDouble("score").toFloat() / 10f, + ) + db.scrobblingDao.insert(entity) + } + + private fun ScrobblerManga(json: JSONObject) = ScrobblerManga( + id = json.getLong("id"), + name = json.getString("name"), + altName = json.getStringOrNull("russian"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), + ) + + private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( + id = json.getLong("id"), + name = json.getString("name"), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), + descriptionHtml = json.getString("description_html"), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt similarity index 87% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt index 210432670..0dfe0421e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt @@ -1,9 +1,9 @@ -package org.koitharu.kotatsu.shikimori.data +package org.koitharu.kotatsu.scrobbling.shikimori.data import android.content.Context import androidx.core.content.edit import org.json.JSONObject -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser private const val PREF_NAME = "shikimori" private const val KEY_ACCESS_TOKEN = "access_token" diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt similarity index 93% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt index d42188a63..79ecfa6c5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.data.model +package org.koitharu.kotatsu.scrobbling.shikimori.data.model import org.json.JSONObject diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt new file mode 100644 index 000000000..4bdb7db1f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt @@ -0,0 +1,64 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.domain + +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository + +private const val RATING_MAX = 10f + +class ShikimoriScrobbler( + private val repository: ShikimoriRepository, + db: MangaDatabase, +) : Scrobbler(db, ScrobblerService.SHIKIMORI) { + + init { + statuses[ScrobblingStatus.PLANNED] = "planned" + statuses[ScrobblingStatus.READING] = "watching" + statuses[ScrobblingStatus.RE_READING] = "rewatching" + statuses[ScrobblingStatus.COMPLETED] = "completed" + statuses[ScrobblingStatus.ON_HOLD] = "on_hold" + statuses[ScrobblingStatus.DROPPED] = "dropped" + } + + override val isAvailable: Boolean + get() = repository.isAuthorized + + override suspend fun findManga(query: String, offset: Int): List { + return repository.findManga(query, offset) + } + + override suspend fun linkManga(mangaId: Long, targetId: Long) { + repository.createRate(mangaId, targetId) + } + + override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return + repository.updateRate(entity.id, entity.mangaId, chapter) + } + + override suspend fun updateScrobblingInfo( + mangaId: Long, + rating: Float, + status: ScrobblingStatus?, + comment: String?, + ) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) + requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } + repository.updateRate( + rateId = entity.id, + mangaId = entity.mangaId, + rating = rating * RATING_MAX, + status = statuses[status], + comment = comment, + ) + } + + override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + return repository.getMangaInfo(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt similarity index 95% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt index aa7cbc6a4..10098a239 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.ui +package org.koitharu.kotatsu.scrobbling.shikimori.ui import android.content.Intent import android.net.Uri @@ -13,7 +13,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser import org.koitharu.kotatsu.utils.PreferenceIconTarget import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.withArgs diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt similarity index 76% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt index 88e62f08d..ef8f73b85 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt @@ -1,10 +1,10 @@ -package org.koitharu.kotatsu.shikimori.ui +package org.koitharu.kotatsu.scrobbling.shikimori.ui import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser class ShikimoriSettingsViewModel( private val repository: ShikimoriRepository, @@ -34,7 +34,7 @@ class ShikimoriSettingsViewModel( private fun loadUser() = launchJob(Dispatchers.Default) { val userModel = if (repository.isAuthorized) { repository.getCachedUser()?.let(user::postValue) - repository.getUser() + repository.loadUser() } else { null } @@ -43,6 +43,6 @@ class ShikimoriSettingsViewModel( private fun authorize(code: String) = launchJob(Dispatchers.Default) { repository.authorize(code) - user.postValue(repository.getUser()) + user.postValue(repository.loadUser()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt new file mode 100644 index 000000000..276502ec7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt @@ -0,0 +1,155 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.* +import android.widget.Toast +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.FragmentManager +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration +import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.withArgs + +class ScrobblingSelectorBottomSheet : + BaseBottomSheet(), + OnListItemClickListener, + PaginationScrollListener.Callback, + View.OnClickListener, + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + DialogInterface.OnKeyListener { + + private val viewModel by viewModel { + parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga) + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding { + return SheetScrobblingSelectorBinding.inflate(inflater, container, false) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).also { + it.setOnKeyListener(this) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { dismiss() } + addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) + val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this) + val decoration = ShikiMangaSelectionDecoration(view.context) + with(binding.recyclerView) { + adapter = listAdapter + addItemDecoration(decoration) + addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet)) + } + binding.buttonDone.setOnClickListener(this) + initOptionsMenu() + + viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + viewModel.selectedItemId.observe(viewLifecycleOwner) { + decoration.checkedItemId = it + binding.recyclerView.invalidateItemDecorations() + } + viewModel.onError.observe(viewLifecycleOwner, ::onError) + viewModel.onClose.observe(viewLifecycleOwner) { + dismiss() + } + viewModel.searchQuery.observe(viewLifecycleOwner) { + binding.toolbar.subtitle = it + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> viewModel.onDoneClick() + } + } + + override fun onItemClick(item: ScrobblerManga, view: View) { + viewModel.selectedItemId.value = item.id + } + + override fun onScrolledToEnd() { + viewModel.loadList(append = true) + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + setExpanded(isExpanded = true, isLocked = true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + val searchView = (item.actionView as? SearchView) ?: return false + searchView.setQuery("", false) + searchView.post { setExpanded(isExpanded = false, isLocked = false) } + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean { + if (query == null || query.length < 3) { + return false + } + viewModel.search(query) + binding.toolbar.menu.findItem(R.id.action_search)?.collapseActionView() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean = false + + override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false + if (menuItem.isActionViewExpanded) { + if (event?.action == KeyEvent.ACTION_UP) { + menuItem.collapseActionView() + } + return true + } + } + return false + } + + private fun onError(e: Throwable) { + Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() + if (viewModel.isEmpty) { + dismissAllowingStateLoss() + } + } + + private fun initOptionsMenu() { + binding.toolbar.inflateMenu(R.menu.opt_shiki_selector) + val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title + } + + companion object { + + private const val TAG = "ScrobblingSelectorBottomSheet" + + fun show(fm: FragmentManager, manga: Manga) = + ScrobblingSelectorBottomSheet().withArgs(1) { + putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false)) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt similarity index 57% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt index 4488d0875..2c881b23b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt @@ -1,10 +1,9 @@ -package org.koitharu.kotatsu.shikimori.ui.selector +package org.koitharu.kotatsu.scrobbling.ui.selector import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope -import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -15,18 +14,20 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -class ShikimoriSelectorViewModel( +class ScrobblingSelectorViewModel( val manga: Manga, - private val repository: ShikimoriRepository, + private val scrobbler: Scrobbler, ) : BaseViewModel() { - private val shikiMangaList = MutableStateFlow?>(null) + private val shikiMangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private var loadingJob: Job? = null + private var doneJob: Job? = null val content: LiveData> = combine( shikiMangaList.filterNotNull(), @@ -39,17 +40,29 @@ class ShikimoriSelectorViewModel( } }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) - val selectedItemId = MutableLiveData(RecyclerView.NO_ID) - - val avatar = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { - emit(repository.getCachedUser()?.avatar) - emit(runCatching { repository.getUser().avatar }.getOrNull()) - } + val selectedItemId = MutableLiveData(NO_ID) + val searchQuery = MutableLiveData(manga.title) + val onClose = SingleLiveEvent() val isEmpty: Boolean get() = shikiMangaList.value.isNullOrEmpty() init { + launchJob(Dispatchers.Default) { + try { + val info = scrobbler.getScrobblingInfoOrNull(manga.id) + if (info != null) { + selectedItemId.postValue(info.targetId) + } + } finally { + loadList(append = false) + } + } + } + + fun search(query: String) { + loadingJob?.cancel() + searchQuery.value = query loadList(append = false) } @@ -62,7 +75,7 @@ class ShikimoriSelectorViewModel( } loadingJob = launchLoadingJob(Dispatchers.Default) { val offset = if (append) shikiMangaList.value?.size ?: 0 else 0 - val list = repository.findManga(manga.title, offset) + val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset) if (!append) { shikiMangaList.value = list } else if (list.isNotEmpty()) { @@ -71,4 +84,18 @@ class ShikimoriSelectorViewModel( hasNextPage.value = list.isNotEmpty() } } + + fun onDoneClick() { + if (doneJob?.isActive == true) { + return + } + val targetId = selectedItemId.value ?: NO_ID + if (targetId == NO_ID) { + onClose.call(Unit) + } + doneJob = launchJob(Dispatchers.Default) { + scrobbler.linkManga(manga.id, targetId) + onClose.postCall(Unit) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt similarity index 87% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt index 72f758bc3..3cd806a99 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikiMangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.ui.selector.adapter +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter import android.content.Context import android.graphics.Canvas @@ -8,7 +8,7 @@ import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.utils.ext.getItem class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { @@ -24,7 +24,7 @@ class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration override fun getItemId(parent: RecyclerView, child: View): Long { val holder = parent.getChildViewHolder(child) ?: return NO_ID - val item = holder.getItem(ShikimoriManga::class.java) ?: return NO_ID + val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID return item.id } diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt similarity index 85% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt index 4685806c8..f786b5d95 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriMangaAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.shikimori.ui.selector.adapter +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader @@ -10,7 +10,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.textAndVisible @@ -18,8 +18,8 @@ import org.koitharu.kotatsu.utils.ext.textAndVisible fun shikimoriMangaAD( lifecycleOwner: LifecycleOwner, coil: ImageLoader, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt similarity index 82% rename from app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt index 5457aa078..90c6af56b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/adapter/ShikimoriSelectorAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt @@ -1,20 +1,20 @@ -package org.koitharu.kotatsu.shikimori.ui.selector.adapter +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import kotlin.jvm.internal.Intrinsics import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga -import kotlin.jvm.internal.Intrinsics +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga class ShikimoriSelectorAdapter( lifecycleOwner: LifecycleOwner, coil: ImageLoader, - clickListener: OnListItemClickListener, + clickListener: OnListItemClickListener, ) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { @@ -28,7 +28,7 @@ class ShikimoriSelectorAdapter( override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { return when { oldItem === newItem -> true - oldItem is ShikimoriManga && newItem is ShikimoriManga -> oldItem.id == newItem.id + oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id else -> false } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 011d7664e..ed9cae71e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -1,14 +1,10 @@ package org.koitharu.kotatsu.settings -import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import android.os.Bundle import android.view.View import androidx.preference.ListPreference import androidx.preference.Preference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import java.io.File import kotlinx.coroutines.launch import org.koin.android.ext.android.inject @@ -20,7 +16,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.SliderPreference -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.viewLifecycleScope @@ -31,7 +26,6 @@ class ContentSettingsFragment : StorageSelectDialog.OnStorageSelectListener { private val storageManager by inject() - private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_content) @@ -55,12 +49,12 @@ class ContentSettingsFragment : ).names() setDefaultValueCompat(DoHProvider.NONE.name) } - bindRemoteSourcesSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() + bindRemoteSourcesSummary() settings.subscribe(this) } @@ -96,14 +90,6 @@ class ContentSettingsFragment : .show() true } - AppSettings.KEY_SHIKIMORI -> { - if (!shikimoriRepository.isAuthorized) { - showShikimoriDialog() - true - } else { - super.onPreferenceTreeClick(preference) - } - } else -> super.onPreferenceTreeClick(preference) } } @@ -125,20 +111,4 @@ class ContentSettingsFragment : summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) } } - - private fun showShikimoriDialog() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.shikimori) - .setMessage(R.string.shikimori_info) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.sign_in) { _, _ -> - runCatching { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(shikimoriRepository.oauthUrl) - startActivity(intent) - }.onFailure { - Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_LONG).show() - } - }.show() - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index dfa8a7bd0..c4c5f46ba 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.settings +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.View import androidx.preference.Preference @@ -14,6 +16,7 @@ import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.FileSize @@ -25,6 +28,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach private val trackerRepo by inject(mode = LazyThreadSafetyMode.NONE) private val searchRepository by inject(mode = LazyThreadSafetyMode.NONE) private val storageManager by inject(mode = LazyThreadSafetyMode.NONE) + private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_history) @@ -50,6 +54,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } } + override fun onResume() { + super.onResume() + bindShikimoriSummary() + } + override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_PAGES_CACHE_CLEAR -> { @@ -81,6 +90,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } true } + AppSettings.KEY_SHIKIMORI -> { + if (!shikimoriRepository.isAuthorized) { + launchShikimoriAuth() + true + } else { + super.onPreferenceTreeClick(preference) + } + } else -> super.onPreferenceTreeClick(preference) } } @@ -142,4 +159,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } }.show() } + + private fun bindShikimoriSummary() { + findPreference(AppSettings.KEY_SHIKIMORI)?.summary = if (shikimoriRepository.isAuthorized) { + getString(R.string.logged_in_as, shikimoriRepository.getCachedUser()?.nickname) + } else { + getString(R.string.disabled) + } + } + + private fun launchShikimoriAuth() { + runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(shikimoriRepository.oauthUrl) + startActivity(intent) + }.onFailure { + Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index bce7c3c31..ea566d4bb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -23,7 +23,7 @@ import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment +import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment import org.koitharu.kotatsu.utils.ext.isScrolledToTop class SettingsActivity : diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt deleted file mode 100644 index 64a2bda32..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.shikimori - -import okhttp3.OkHttpClient -import org.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.dsl.module -import org.koitharu.kotatsu.shikimori.data.ShikimoriAuthenticator -import org.koitharu.kotatsu.shikimori.data.ShikimoriInterceptor -import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.shikimori.data.ShikimoriStorage -import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsViewModel -import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorViewModel - -val shikimoriModule - get() = module { - single { ShikimoriStorage(androidContext()) } - factory { - val okHttp = OkHttpClient.Builder().apply { - authenticator(ShikimoriAuthenticator(get(), ::get)) - addInterceptor(ShikimoriInterceptor(get())) - }.build() - ShikimoriRepository(okHttp, get()) - } - viewModel { params -> - ShikimoriSettingsViewModel(get(), params.getOrNull()) - } - viewModel { params -> ShikimoriSelectorViewModel(params[0], get()) } - } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt deleted file mode 100644 index 5accdf614..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.koitharu.kotatsu.shikimori.data - -import okhttp3.FormBody -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONObject -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.json.mapJSON -import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.util.parseJsonArray -import org.koitharu.kotatsu.parsers.util.urlEncoded -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser -import org.koitharu.kotatsu.utils.ext.toRequestBody - -private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU" -private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8" -private const val REDIRECT_URI = "kotatsu://shikimori-auth" -private const val BASE_URL = "https://shikimori.one/" -private const val MANGA_PAGE_SIZE = 10 - -class ShikimoriRepository( - private val okHttp: OkHttpClient, - private val storage: ShikimoriStorage, -) { - - val oauthUrl: String - get() = "${BASE_URL}oauth/authorize?client_id=$CLIENT_ID&" + - "redirect_uri=$REDIRECT_URI&response_type=code&scope=" - - val isAuthorized: Boolean - get() = storage.accessToken != null - - suspend fun authorize(code: String?) { - val body = FormBody.Builder() - body.add("grant_type", "authorization_code") - body.add("client_id", CLIENT_ID) - body.add("client_secret", CLIENT_SECRET) - if (code != null) { - body.add("redirect_uri", REDIRECT_URI) - body.add("code", code) - } else { - body.add("refresh_token", checkNotNull(storage.refreshToken)) - } - val request = Request.Builder() - .post(body.build()) - .url("${BASE_URL}oauth/token") - val response = okHttp.newCall(request.build()).await().parseJson() - storage.accessToken = response.getString("access_token") - storage.refreshToken = response.getString("refresh_token") - } - - suspend fun getUser(): ShikimoriUser { - val request = Request.Builder() - .get() - .url("${BASE_URL}api/users/whoami") - val response = okHttp.newCall(request.build()).await().parseJson() - return ShikimoriUser(response).also { storage.user = it } - } - - fun getCachedUser(): ShikimoriUser? { - return storage.user - } - - fun logout() { - storage.clear() - } - - suspend fun findManga(query: String, offset: Int): List { - val page = offset / MANGA_PAGE_SIZE - val pageOffset = offset % MANGA_PAGE_SIZE - val url = BASE_URL.toHttpUrl().newBuilder() - .addPathSegment("api") - .addPathSegment("mangas") - .addEncodedQueryParameter("page", (page + 1).toString()) - .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString()) - .addEncodedQueryParameter("censored", false.toString()) - .addQueryParameter("search", query) - .build() - val request = Request.Builder().url(url).get().build() - val response = okHttp.newCall(request).await().parseJsonArray() - val list = response.mapJSON { ShikimoriManga(it) } - return if (pageOffset != 0) list.drop(pageOffset) else list - } - - suspend fun trackManga(manga: Manga, shikiMangaId: Long) { - val user = getCachedUser() ?: getUser() - val payload = JSONObject() - payload.put( - "user_rate", - JSONObject().apply { - put("target_id", shikiMangaId) - put("target_type", "Manga") - put("user_id", user.id) - } - ) - val url = BASE_URL.toHttpUrl().newBuilder() - .addPathSegment("api") - .addPathSegment("v2") - .addPathSegment("user_rates") - .build() - val request = Request.Builder().url(url).post(payload.toRequestBody()).build() - val response = okHttp.newCall(request).await().parseJson() - } - - suspend fun findMangaInfo(manga: Manga): ShikimoriMangaInfo? { - val q = manga.title.urlEncoded() - val request = Request.Builder() - .get() - .url("${BASE_URL}api/mangas?limit=5&search=$q&censored=false") - val response = okHttp.newCall(request.build()).await().parseJsonArray() - val candidates = response.mapJSON { ShikimoriManga(it) } - val bestCandidate = candidates.filter { - it.name.equals(manga.title, ignoreCase = true) || it.name.equals(manga.altTitle, ignoreCase = true) - }.singleOrNull() ?: return null - return getMangaInfo(bestCandidate.id) - } - - suspend fun getRelatedManga(id: Long): List { - val request = Request.Builder() - .get() - .url("${BASE_URL}api/mangas/$id/related") - val response = okHttp.newCall(request.build()).await().parseJsonArray() - return response.mapJSON { jo -> ShikimoriManga(jo) } - } - - suspend fun getSimilarManga(id: Long): List { - val request = Request.Builder() - .get() - .url("${BASE_URL}api/mangas/$id/similar") - val response = okHttp.newCall(request.build()).await().parseJsonArray() - return response.mapJSON { jo -> ShikimoriManga(jo) } - } - - suspend fun getMangaInfo(id: Long): ShikimoriMangaInfo { - val request = Request.Builder() - .get() - .url("${BASE_URL}api/mangas/$id") - val response = okHttp.newCall(request.build()).await().parseJson() - return ShikimoriMangaInfo(response) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt deleted file mode 100644 index 22adad008..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriMangaInfo.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.shikimori.data.model - -import org.json.JSONObject - -class ShikimoriMangaInfo( - val id: Long, - val name: String, - val cover: String, - val url: String, - val descriptionHtml: String, -) { - - constructor(json: JSONObject) : this( - id = json.getLong("id"), - name = json.getString("name"), - cover = json.getJSONObject("image").getString("preview"), - url = json.getString("url"), - descriptionHtml = json.getString("description_html"), - ) -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt deleted file mode 100644 index ed2cf4997..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/selector/ShikimoriSelectorBottomSheet.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.koitharu.kotatsu.shikimori.ui.selector - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.FragmentManager -import coil.transform.CircleCropTransformation -import org.koin.android.ext.android.get -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.domain.MangaIntent -import org.koitharu.kotatsu.base.ui.BaseBottomSheet -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.databinding.SheetShikiSelectorBinding -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga -import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikiMangaSelectionDecoration -import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikimoriSelectorAdapter -import org.koitharu.kotatsu.utils.BottomSheetToolbarController -import org.koitharu.kotatsu.utils.ext.enqueueWith -import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import org.koitharu.kotatsu.utils.ext.newImageRequest -import org.koitharu.kotatsu.utils.ext.withArgs - -class ShikimoriSelectorBottomSheet : - BaseBottomSheet(), - OnListItemClickListener, - PaginationScrollListener.Callback, - View.OnClickListener { - - private val viewModel by viewModel { - parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga) - } - - override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetShikiSelectorBinding { - return SheetShikiSelectorBinding.inflate(inflater, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.toolbar.subtitle = viewModel.manga.title - binding.toolbar.setNavigationOnClickListener { dismiss() } - addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) - val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this) - val decoration = ShikiMangaSelectionDecoration(view.context) - with(binding.recyclerView) { - adapter = listAdapter - addItemDecoration(decoration) - addOnScrollListener(PaginationScrollListener(4, this@ShikimoriSelectorBottomSheet)) - } - binding.imageViewUser.setOnClickListener(this) - - viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } - viewModel.selectedItemId.observe(viewLifecycleOwner) { - decoration.checkedItemId = it - binding.recyclerView.invalidateItemDecorations() - } - viewModel.onError.observe(viewLifecycleOwner, ::onError) - viewModel.avatar.observe(viewLifecycleOwner, ::setUserAvatar) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.imageView_user -> startActivity(SettingsActivity.newShikimoriSettingsIntent(v.context)) - } - } - - override fun onItemClick(item: ShikimoriManga, view: View) { - viewModel.selectedItemId.value = item.id - } - - override fun onScrolledToEnd() { - viewModel.loadList(append = true) - } - - private fun onError(e: Throwable) { - Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() - if (viewModel.isEmpty) { - dismissAllowingStateLoss() - } - } - - private fun setUserAvatar(url: String?) { - val iconSize = resources.getDimensionPixelSize(R.dimen.action_bar_item_size) - binding.imageViewUser.newImageRequest(url) - .transformations(CircleCropTransformation()) - .size(iconSize, iconSize) - .enqueueWith(get()) - } - - companion object { - - private const val TAG = "ShikimoriSelectorBottomSheet" - - fun show(fm: FragmentManager, manga: Manga) = - ShikimoriSelectorBottomSheet().withArgs(1) { - putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false)) - }.show(fm, TAG) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 0ab153da6..f66a6dc22 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -34,4 +34,13 @@ fun List.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList } else { ArrayList(this) +} + +fun Map.findKey(value: V): K? { + for ((k, v) in entries) { + if (v == value) { + return k + } + } + return null } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt index dc6974749..22f95e4a4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt @@ -1,18 +1,20 @@ package org.koitharu.kotatsu.utils.ext +import android.content.ActivityNotFoundException import android.content.res.Resources +import java.io.FileNotFoundException +import java.net.SocketTimeoutException import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException -import java.io.FileNotFoundException -import java.net.SocketTimeoutException fun Throwable.getDisplayMessage(resources: Resources) = when (this) { is AuthRequiredException -> resources.getString(R.string.auth_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) + is ActivityNotFoundException, is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is FileNotFoundException -> resources.getString(R.string.file_not_found) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index e3cd66b43..3ac39ee9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -4,10 +4,10 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.liveData -import kotlinx.coroutines.Deferred import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.utils.BufferedObserver fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer) { @@ -18,6 +18,10 @@ fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer } } +fun LiveData.requireValue(): T = checkNotNull(value) { + "LiveData value is null" +} + fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) { var previous: T? = null this.observe(owner) { @@ -26,6 +30,7 @@ fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: Buffere } } +@Deprecated("Use variant with default value") fun Flow.asLiveDataDistinct( context: CoroutineContext = EmptyCoroutineContext ): LiveData = liveData(context) { @@ -36,6 +41,10 @@ fun Flow.asLiveDataDistinct( } } +fun StateFlow.asLiveDataDistinct( + context: CoroutineContext = EmptyCoroutineContext +): LiveData = asLiveDataDistinct(context, value) + fun Flow.asLiveDataDistinct( context: CoroutineContext = EmptyCoroutineContext, defaultValue: T diff --git a/app/src/main/res/drawable-hdpi/ic_shikimori.png b/app/src/main/res/drawable-hdpi/ic_shikimori.png new file mode 100644 index 0000000000000000000000000000000000000000..28bf0507f04337711bd3c6f286f43260602aa8b9 GIT binary patch literal 1361 zcmV-X1+MyuP))3Hz$B1LPJ%Yj z4qC&dL44MHySTp;ronr#GfPh_g6rX%h6tJ&9=e5oIqXP#I9$_$$Arb~MFB0~1ULmwhx6f*a8Kv= zB`aE*h4hi%!W+FyzX;ZY7Sduy%{GXUl%uuemO^_&SC|7EsuiT;8WmBjq3Yr~q@k$$!BAW*7F&owW4KN=od!n)J4s$+!}-_*oDkLQIPYP$Nmo2 zzo%EYVpCg|Q$NAzJE16gTwFmepg67PcDk?*t|KePRujf4a2~9KibR>U;CT!l2>rrD zPHxl8Zi|yG0~4m(s31|y9?(+yqNzcrKNd_KH^I5#*&+15hk5I}ygU?3hg)Z8yMPPn z)Jgx*XUdyn+kToZ)wv)NoVwVL)(STUFIiC$(hg% z%tc10y>qy@Wykxk!o5(EAkzey>@>R*#b#c)8hU}@>cnlqMUV9_Trh_Nf ziGOR^gn4q|d-C_yR5fGqb%G&?lmawVaFFS7Yhqy79NCh+E;sc%ghE3#nrM zZS$=sf6N&=Won z{nPaNi!Z6qTnjgbYg*PhbZ{#`T`RYcj`t>X2YtZY@@?py_;GkL{2k}8Hx(#I3k`?q z&;cd|0nRxBJX5*#L*Z1I0Y+hcOWS)-extyn#$vGUbb-O}80d)QEebLhy#hCb^LSzy z32VS3$6G1SmRzdV88x+UrFC8^n^Z@)9B3hZ#boKrc+|KXCWE2F}K{>rd8cOqv;WFi*nEB4H2Yc{s{WWCyCeW%x{k=)-9Xmpg$}D z>*4Hh-C!9X1ScLZ{aLLbPk2tOqu0V~VPTIK<$~Ov+u>~(1a7mAbb@DO?GUg7qvW zQfr`FWVo9G%+^}eFP7M>k^12aQ1N}1C&;k1&X{|2*muJ374$}#_pRtgp9?F>d}FbU zUlg8$EDgStTN|61{s!<1q-5*z&O?_ocs`g}J-3+P-1}tMn%UhhMSEt_&y23*A|}WI za36T^i~jTgzYUa<<7}3oRt$U@{6a9q^_Pz!?O^U4e(LB;`bF9p9o`q50DcAgjHgla zTFLkM9WN9%T3&}HA-LpkbU9iS~fBUeYBAXrrVyh}N THtKp&#f{PdgG;!R{h~OH9_mb$A3<;1)cG zcLmnBU~V$Jg*!z8`f)X)i72O{cI}Y>zM?m)&a4WPWoCmYV<_UD$Q?S~{Ls_?JV#om=8o|4%8<_9LU|dST z>b3BUSrZuaB+W~o<+;e?KZ8!-)v*{Bgt6DPSA(LiH@i$@^WFqFl~|Z3Ub9Z4bc{>bg?PI)T}+0Zh5=X=`}4PP`V@g9T;tE>dhBC~i_X z&J@tHWpEh$Rr?+Zoe literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_shikimori.png b/app/src/main/res/drawable-xhdpi/ic_shikimori.png new file mode 100644 index 0000000000000000000000000000000000000000..69ab19103640fcd67b7ba7000f547282004f29ae GIT binary patch literal 1695 zcmV;Q24MM#P)Lgy zy)pN`?>y|2=~_GcX68oC!XJ?>lsOa>^_M zGr(W)8~hHo{U`hq>is-CZ`DMO0*h1Q>To371dqTt_@<<#NqDBhbHj?T4XgmO!jwS) zJ1tRxKluGCya%VkHQ=)>$x*-&+%m8$*Ga)qd^a>|;|suKFgrNXIrUa`@F8C80k7m} z(59j7sBjD{7CPIeY))U&ZycOu;k%}SZ96d?6)q1O!49xQkE31HKWBA4c(fl1Y)&Pc z!Ts!ko4KIpgn3{=n7=|<2h3Thl{y6$rsOl=BHkDt0Nr4}#rjodAy@@=0zKHX z;o4aNZTyNbI{NJ^;Y@fHypR68-wHXX*THL`>+Y<;ev}+rDD4dV0H47J@G&^m?}L8a z>nG6bOgRH@!MD9Auy0VHX`K|?FtXkelz4)xeqBX>gczWlL4%p=>F1_S*1fOmoWbh` zFmQDdXTc8JS#jaVnX&W!*3Ul)H^YnInrk(CbqXvQD%U0UD$a^+Jr}OVlexkSJkRwF z*a~I=UG;--2{cCICiB>xO3ndYWRt{1O;`IAdKblRz_Nz)Jz+;M*Ga`jT(~d5-S9ZP3@&s>y^R<@OdPj>V_{nO0*n>b zS1LIQqy?>PHi(&}zS_mvRxEGAX6C6!D~jog17cea-UauGo}(0B(d`pmY}6&Oq8B^L&Gu@xZ--se=pCM%RIuGB|7VfeW;2esm9I zT`RUVM5+`pci9V!yfN>waqU#L2BWm1i*yu=>!a9KulFwgHDHLFEmOc9l|I=VWiYh5 zG*@#YZPJcI=MHyT|3{cbaRh0(uMc7hPGM zXp4SlpfT5RbYBORP-X;!SQN8Zqk9xn^xGj?fvL+ap#OLK>KfE!eDCbJH*ofbNYx@9 z6)JbC{l?BZ&35omxD|}1D?!@HwDO_?zVlg{VH&OVUW!g(=R*Gu;(jf-5cEX$Axi;2 z@+jpfSRdX2w~yv1Mp*r{TT2zFO1{bzIFRqpPI_=Bzc&o`dKulXt~pzB;(%+x>@gM1 z!H%2XRHeiEeN%x{ZsW#1c9d)Rp}8oUiD^qT!7L?m6ez363{qFB0?tO6Xyfh+QqkOW z({Yq9;9?hhp39SmwpfU#ByriNz8V@{LX_U4KcozB+q-n-D z?llV+vo6vZ(8dk>#roa+ECb_1&s-yAHgCFM0CRU&D{U1xi`ouIYP7RX`7vM$;u(>P zyQ*FjF}ID;%r@ZnT@~MZB%>0wfvt@K?g6fbn5AkCC&TFw(*ySrt_ky)q8O;`uLy*W@(C zAt`kFL5>32y9ZC3CLMB*dU?&jII_Ce^TG1xfu>hw4(^Zr`^rP!qIkIG-l0@-6!45* z8&@DPW}4HM&3cFIG?2?#md-UiI)#kP|?cZTl|dO35*y pUOO~F%TH8bf(~y+<->jk{sxNwV?Y;JblU&`002ovPDHLkV1il+HZuSK literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_shikimori.png b/app/src/main/res/drawable-xxhdpi/ic_shikimori.png new file mode 100644 index 0000000000000000000000000000000000000000..f4002413e22b0f525f819e8435d2e3c88e6f10b1 GIT binary patch literal 3177 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 P00000NkvXXu0mjfE&2FM literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png b/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png new file mode 100644 index 0000000000000000000000000000000000000000..cd989aec44321ba67229c990f2e31410e9d56293 GIT binary patch literal 3743 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 JPDHLkV1h@~7~%i` literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 2131010e1..f00741b07 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -187,6 +187,21 @@ app:layout_constraintTop_toBottomOf="@id/textView_bookmarks" tools:listitem="@layout/item_bookmark" /> + + + + diff --git a/app/src/main/res/layout/layout_scrobbling_info.xml b/app/src/main/res/layout/layout_scrobbling_info.xml new file mode 100644 index 000000000..079308b0d --- /dev/null +++ b/app/src/main/res/layout/layout_scrobbling_info.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_scrobbling.xml b/app/src/main/res/layout/sheet_scrobbling.xml new file mode 100644 index 000000000..2894ecb5d --- /dev/null +++ b/app/src/main/res/layout/sheet_scrobbling.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_shiki_selector.xml b/app/src/main/res/layout/sheet_scrobbling_selector.xml similarity index 81% rename from app/src/main/res/layout/sheet_shiki_selector.xml rename to app/src/main/res/layout/sheet_scrobbling_selector.xml index 73ca60544..34a489cd1 100644 --- a/app/src/main/res/layout/sheet_shiki_selector.xml +++ b/app/src/main/res/layout/sheet_scrobbling_selector.xml @@ -19,14 +19,14 @@ app:navigationIcon="?actionModeCloseDrawable" app:title="@string/tracking"> - + android:layout_marginEnd="4dp" + android:text="@string/done" /> diff --git a/app/src/main/res/menu/opt_shiki_selector.xml b/app/src/main/res/menu/opt_shiki_selector.xml new file mode 100644 index 000000000..0f0d58fbd --- /dev/null +++ b/app/src/main/res/menu/opt_shiki_selector.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 176b70687..612506b1d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -45,4 +45,12 @@ @string/right_to_left @string/webtoon + + @string/status_planned + @string/status_reading + @string/status_re_reading + @string/status_completed + @string/status_on_hold + @string/status_dropped + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e97191287..0ce4ecaee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,7 +271,6 @@ Removal completed Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage Shikimori - Sign in into your Shikimori account to get more features Parallel downloads Download slowdown Helps avoid blocking your IP address @@ -304,4 +303,10 @@ Helps with background updates checks Something went wrong. Please submit a bug report to the developers to help us fix it. Send + Planned + Reading + Re-reading + Completed + On hold + Dropped \ No newline at end of file diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index 65470e164..ffd9f261f 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -13,11 +13,6 @@ android:key="suggestions" android:title="@string/suggestions" /> - - + + + + + + \ No newline at end of file From ced4ca01cba52754f1c5d418ad088835f0218cf0 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 23 Jun 2022 14:54:30 +0300 Subject: [PATCH 07/54] Add icon to metadata --- metadata/en-US/icon.png | Bin 0 -> 24934 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 metadata/en-US/icon.png diff --git a/metadata/en-US/icon.png b/metadata/en-US/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f63e533f0297b36535f01f0a8aa6e9316aa2bf8c GIT binary patch literal 24934 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 pA0(s`aL*9}2p1gJ)=T0H{6D Date: Fri, 24 Jun 2022 12:54:48 +0300 Subject: [PATCH 08/54] Update dependencies --- app/build.gradle | 12 ++++++------ app/src/main/res/values/styles.xml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index bf66187c7..67ad71a1c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -73,7 +73,7 @@ afterEvaluate { } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation('com.github.nv95:kotatsu-parsers:8a3b6df91d') { + implementation('com.github.nv95:kotatsu-parsers:c92f89f307') { exclude group: 'org.json', module: 'json' } @@ -82,10 +82,10 @@ dependencies { implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.activity:activity-ktx:1.5.0-rc01' implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc01' - implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc01' - implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc01' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc02' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc02' + implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc02' + implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc02' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' @@ -95,7 +95,7 @@ dependencies { implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'com.google.android.material:material:1.7.0-alpha02' //noinspection LifecycleAnnotationProcessorWithJava8 - kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc01' + kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc02' implementation 'androidx.room:room-runtime:2.4.2' implementation 'androidx.room:room-ktx:2.4.2' diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b44b1c1e5..e2f663c0e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -133,7 +133,7 @@ ?attr/colorOnBackground - + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index e2f663c0e..8951634e9 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -88,9 +88,8 @@ From 03c2b5577668f4130b2b1f29402406af39b7c9a3 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 25 Jun 2022 21:10:56 +0300 Subject: [PATCH 15/54] Replace done menu item with button --- .../edit/FavouritesCategoryEditActivity.kt | 20 ++++------- .../select/FavouriteCategoriesBottomSheet.kt | 17 ++-------- .../widget/shelf/ShelfConfigActivity.kt | 34 ++++++++----------- .../main/res/layout/activity_categories.xml | 18 ++++++++-- .../res/layout/activity_category_edit.xml | 13 ++++++- .../res/layout/dialog_favorite_categories.xml | 14 ++++++-- app/src/main/res/menu/opt_config.xml | 11 ------ app/src/main/res/values/dimens.xml | 1 + 8 files changed, 64 insertions(+), 64 deletions(-) delete mode 100644 app/src/main/res/menu/opt_config.xml diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt index b38dfec26..96de7ea86 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt @@ -3,8 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.AdapterView @@ -24,7 +22,8 @@ import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.utils.ext.getDisplayMessage -class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener { +class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener, + View.OnClickListener { private val viewModel by viewModel { parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID)) @@ -39,6 +38,7 @@ class FavouritesCategoryEditActivity : BaseActivity setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material) } initSortSpinner() + binding.buttonDone.setOnClickListener(this) viewModel.onSaved.observe(this) { finishAfterTransition() } viewModel.category.observe(this, ::onCategoryChanged) @@ -62,22 +62,14 @@ class FavouritesCategoryEditActivity : BaseActivity } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.opt_config, menu) - menu.findItem(R.id.action_done)?.setTitle(R.string.save) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.action_done -> { - viewModel.save( + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> viewModel.save( title = binding.editName.text?.toString().orEmpty(), sortOrder = getSelectedSortOrder(), isTrackerEnabled = binding.switchTracker.isChecked, ) - true } - else -> super.onOptionsItemSelected(item) } override fun onWindowInsetsChanged(insets: Insets) { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt index aa2bacbbe..f13cce9a7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt @@ -2,11 +2,9 @@ package org.koitharu.kotatsu.favourites.ui.categories.select import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.widget.Toolbar import androidx.fragment.app.FragmentManager import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -28,7 +26,7 @@ class FavouriteCategoriesBottomSheet : BaseBottomSheet(), OnListItemClickListener, CategoriesEditDelegate.CategoriesEditCallback, - Toolbar.OnMenuItemClickListener, View.OnClickListener { + View.OnClickListener { private val viewModel by viewModel { parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga }) @@ -45,7 +43,7 @@ class FavouriteCategoriesBottomSheet : super.onViewCreated(view, savedInstanceState) adapter = MangaCategoriesAdapter(this) binding.recyclerViewCategories.adapter = adapter - binding.toolbar.setOnMenuItemClickListener(this) + binding.buttonDone.setOnClickListener(this) binding.itemCreate.setOnClickListener(this) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) @@ -57,19 +55,10 @@ class FavouriteCategoriesBottomSheet : super.onDestroyView() } - override fun onMenuItemClick(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_done -> { - dismiss() - true - } - else -> false - } - } - override fun onClick(v: View) { when (v.id) { R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) + R.id.button_done -> dismiss() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt index b7eea4171..b404b3286 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt @@ -4,11 +4,10 @@ import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.graphics.Insets +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView @@ -26,7 +25,7 @@ import org.koitharu.kotatsu.widget.shelf.model.CategoryItem import com.google.android.material.R as materialR class ShelfConfigActivity : BaseActivity(), - OnListItemClickListener { + OnListItemClickListener, View.OnClickListener { private val viewModel by viewModel() @@ -45,6 +44,8 @@ class ShelfConfigActivity : BaseActivity(), MaterialDividerItemDecoration(this, RecyclerView.VERTICAL) ) binding.recyclerView.adapter = adapter + binding.buttonDone.isVisible = true + binding.buttonDone.setOnClickListener(this) binding.fabAdd.hide() val appWidgetId = intent?.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, @@ -61,23 +62,18 @@ class ShelfConfigActivity : BaseActivity(), viewModel.onError.observe(this, this::onError) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.opt_config, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.action_done -> { - config.categoryId = viewModel.checkedId - updateWidget() - setResult( - Activity.RESULT_OK, - Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) - ) - finish() - true + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> { + config.categoryId = viewModel.checkedId + updateWidget() + setResult( + Activity.RESULT_OK, + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) + ) + finish() + } } - else -> super.onOptionsItemSelected(item) } override fun onItemClick(item: CategoryItem, view: View) { diff --git a/app/src/main/res/layout/activity_categories.xml b/app/src/main/res/layout/activity_categories.xml index ff28de4f7..e427c9be0 100644 --- a/app/src/main/res/layout/activity_categories.xml +++ b/app/src/main/res/layout/activity_categories.xml @@ -8,9 +8,9 @@ + android:layout_height="wrap_content" + android:fitsSystemWindows="true"> + app:layout_collapseMode="pin"> + +