From 786914b1a6a0576d65c32fb373b844eec09637db Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 10 Mar 2022 19:49:40 +0200 Subject: [PATCH 01/69] 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/69] 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/69] 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/69] 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/69] 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 d2bbfe01f157c4c5e730a30051c6be1666f67064 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 20 Jun 2022 10:53:58 +0300 Subject: [PATCH 06/69] Add ACRA for crash reports --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 8 -- .../java/org/koitharu/kotatsu/KotatsuApp.kt | 38 ++++++++- .../koitharu/kotatsu/base/ui/BaseActivity.kt | 6 +- .../kotatsu/core/ui/AppCrashHandler.kt | 22 ----- .../koitharu/kotatsu/core/ui/CrashActivity.kt | 83 ------------------- .../ui/service/DownloadNotification.kt | 11 +-- .../kotatsu/tracker/work/TrackWorker.kt | 5 -- app/src/main/res/layout/activity_crash.xml | 64 -------------- app/src/main/res/values/constants.xml | 1 + app/src/main/res/values/strings.xml | 2 + 11 files changed, 46 insertions(+), 197 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt delete mode 100644 app/src/main/res/layout/activity_crash.xml diff --git a/app/build.gradle b/app/build.gradle index 5f6c280a0..4e4979550 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -112,6 +112,9 @@ dependencies { implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.github.solkin:disk-lru-cache:1.4' + implementation 'ch.acra:acra-mail:5.9.3' + implementation 'ch.acra:acra-dialog:5.9.3' + debugImplementation 'org.jsoup:jsoup:1.15.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1688abcf..72cfa4f2c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,11 +68,6 @@ android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:windowSoftInputMode="adjustResize" /> - - diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index e00dd1680..8035a83fc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -1,9 +1,15 @@ package org.koitharu.kotatsu import android.app.Application +import android.content.Context import android.os.StrictMode import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.strictmode.FragmentStrictMode +import org.acra.ReportField +import org.acra.config.dialog +import org.acra.config.mailSender +import org.acra.data.StringFormat +import org.acra.ktx.initAcra import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -13,7 +19,6 @@ import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.network.networkModule import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.AppCrashHandler import org.koitharu.kotatsu.core.ui.uiModule import org.koitharu.kotatsu.details.detailsModule import org.koitharu.kotatsu.favourites.favouritesModule @@ -41,7 +46,6 @@ class KotatsuApp : Application() { enableStrictMode() } initKoin() - Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext)) AppCompatDelegate.setDefaultNightMode(get().theme) registerActivityLifecycleCallbacks(get()) registerActivityLifecycleCallbacks(get()) @@ -75,6 +79,36 @@ class KotatsuApp : Application() { } } + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.KEY_VALUE_LIST + reportContent = listOf( + ReportField.PACKAGE_NAME, + ReportField.APP_VERSION_CODE, + ReportField.APP_VERSION_NAME, + ReportField.ANDROID_VERSION, + ReportField.PHONE_MODEL, + ReportField.CRASH_CONFIGURATION, + ReportField.STACK_TRACE, + ReportField.SHARED_PREFERENCES, + ) + dialog { + text = getString(R.string.crash_text) + title = getString(R.string.error_occurred) + positiveButtonText = getString(R.string.send) + resIcon = R.drawable.ic_alert_outline + resTheme = android.R.style.Theme_Material_Light_Dialog_Alert + } + mailSender { + mailTo = getString(R.string.email_error_report) + reportAsFile = true + reportFileName = "stacktrace.txt" + } + } + } + private fun enableStrictMode() { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt index 9cdce9654..bd172d695 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt @@ -12,7 +12,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.Toolbar -import androidx.core.app.ActivityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -83,8 +82,9 @@ abstract class BaseActivity : override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove - ActivityCompat.recreate(this) - return true + // ActivityCompat.recreate(this) + throw RuntimeException("Test crash") + // return true } return super.onKeyDown(keyCode, event) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt deleted file mode 100644 index fb3216cb2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.content.Context -import android.content.Intent -import android.util.Log -import kotlin.system.exitProcess -import org.koitharu.kotatsu.utils.ext.printStackTraceDebug - -class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler { - - override fun uncaughtException(t: Thread, e: Throwable) { - val intent = CrashActivity.newIntent(applicationContext, e) - intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - try { - applicationContext.startActivity(intent) - } catch (t: Throwable) { - t.printStackTraceDebug() - } - Log.e("CRASH", e.message, e) - exitProcess(1) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt deleted file mode 100644 index 7d4d31878..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.databinding.ActivityCrashBinding -import org.koitharu.kotatsu.main.ui.MainActivity -import org.koitharu.kotatsu.parsers.util.ellipsize -import org.koitharu.kotatsu.utils.ShareHelper - -class CrashActivity : Activity(), View.OnClickListener { - - private lateinit var binding: ActivityCrashBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityCrashBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.textView.text = intent.getStringExtra(Intent.EXTRA_TEXT) - binding.buttonClose.setOnClickListener(this) - binding.buttonRestart.setOnClickListener(this) - binding.buttonReport.setOnClickListener(this) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.opt_crash, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_share -> { - ShareHelper(this).shareText(binding.textView.text.toString()) - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_close -> { - finish() - } - R.id.button_restart -> { - val intent = Intent(applicationContext, MainActivity::class.java) - intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - finish() - } - R.id.button_report -> { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse("https://github.com/nv95/Kotatsu/issues") - try { - startActivity(Intent.createChooser(intent, getString(R.string.report_github))) - } catch (_: ActivityNotFoundException) { - } - } - } - } - - companion object { - - private const val MAX_TRACE_SIZE = 131071 - - fun newIntent(context: Context, error: Throwable): Intent { - val crashInfo = error - .stackTraceToString() - .trimIndent() - .ellipsize(MAX_TRACE_SIZE) - val intent = Intent(context, CrashActivity::class.java) - intent.putExtra(Intent.EXTRA_TEXT, crashInfo) - return intent - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index a424e7086..a8f0744bd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -11,8 +11,8 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap +import com.google.android.material.R as materialR import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.CrashActivity import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.ui.DownloadsActivity @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import com.google.android.material.R as materialR class DownloadNotification(private val context: Context, startId: Int) { @@ -92,14 +91,6 @@ class DownloadNotification(private val context: Context, startId: Int) { builder.setContentText(message) builder.setAutoCancel(true) builder.setOngoing(false) - builder.setContentIntent( - PendingIntent.getActivity( - context, - state.manga.hashCode(), - CrashActivity.newIntent(context, state.error), - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE - ) - ) builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 5545b7562..51aedc866 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -216,10 +216,5 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : works.any { x -> x.state == WorkInfo.State.RUNNING } } } - - suspend fun getInfo(context: Context): List { - val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() - return WorkManager.getInstance(context).getWorkInfos(query).await().orEmpty() - } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_crash.xml b/app/src/main/res/layout/activity_crash.xml deleted file mode 100644 index f6a974b44..000000000 --- a/app/src/main/res/layout/activity_crash.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - -