Shikimori authorization
This commit is contained in:
@@ -61,6 +61,12 @@
|
||||
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="kotatsu" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.reader.readerModule
|
||||
import org.koitharu.kotatsu.remotelist.remoteListModule
|
||||
import org.koitharu.kotatsu.search.searchModule
|
||||
import org.koitharu.kotatsu.settings.settingsModule
|
||||
import org.koitharu.kotatsu.shikimori.shikimoriModule
|
||||
import org.koitharu.kotatsu.suggestions.suggestionsModule
|
||||
import org.koitharu.kotatsu.tracker.trackerModule
|
||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||
@@ -69,6 +70,7 @@ class KotatsuApp : Application() {
|
||||
readerModule,
|
||||
appWidgetModule,
|
||||
suggestionsModule,
|
||||
shikimoriModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ object CommonHeaders {
|
||||
const val ACCEPT = "Accept"
|
||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||
const val COOKIE = "Cookie"
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
|
||||
val CACHE_CONTROL_DISABLED: CacheControl
|
||||
get() = CacheControl.Builder().noStore().build()
|
||||
|
||||
@@ -249,6 +249,7 @@ class AppSettings(context: Context) {
|
||||
const val KEY_PAGES_PRELOAD = "pages_preload"
|
||||
const val KEY_SUGGESTIONS = "suggestions"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||
const val KEY_SHIKIMORI = "shikimori"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -15,6 +17,9 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
|
||||
import org.koitharu.kotatsu.settings.*
|
||||
import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment
|
||||
|
||||
private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
|
||||
|
||||
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
|
||||
|
||||
@@ -27,6 +32,7 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
|
||||
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<ActivitySettingsSimpleBinding>() {
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
|
||||
@@ -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<LocalStorageManager>()
|
||||
private val shikimoriRepository by inject<ShikimoriRepository>(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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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<ShikimoriSettingsViewModel> {
|
||||
parametersOf(arguments?.getString(ARG_AUTH_CODE))
|
||||
}
|
||||
private val coil by inject<ImageLoader>(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<Preference>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ShikimoriUser?>()
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -266,4 +266,6 @@
|
||||
<string name="always">Always</string>
|
||||
<string name="preload_pages">Preload pages</string>
|
||||
<string name="logged_in_as">Logged in as %s</string>
|
||||
<string name="shikimori" translatable="false">Shikimori</string>
|
||||
<string name="shikimori_info">Sign in into your Shikimori account to get more features</string>
|
||||
</resources>
|
||||
@@ -95,6 +95,12 @@
|
||||
android:title="@string/check_for_new_chapters"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment"
|
||||
android:title="@string/shikimori"
|
||||
android:key="shikimori"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
|
||||
android:title="@string/backup_restore"
|
||||
|
||||
13
app/src/main/res/xml/pref_shikimori.xml
Normal file
13
app/src/main/res/xml/pref_shikimori.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
app:initialExpandedChildrenCount="5">
|
||||
|
||||
<Preference
|
||||
android:key="shiki_user"
|
||||
android:persistent="false"
|
||||
android:title="@string/loading_"
|
||||
app:iconSpaceReserved="true" />
|
||||
|
||||
</PreferenceScreen>
|
||||
Reference in New Issue
Block a user