pull/167/head
yangyxd 5 years ago
commit 74a435c4bd
  1. 1
      app/src/main/assets/updateLog.md
  2. 2
      app/src/main/java/io/legado/app/data/entities/Book.kt
  3. 34
      app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt
  4. 3
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  5. 1
      app/src/main/java/io/legado/app/help/storage/OldBook.kt
  6. 5
      app/src/main/java/io/legado/app/model/SearchBook.kt
  7. 5
      app/src/main/java/io/legado/app/model/WebBook.kt
  8. 32
      app/src/main/java/io/legado/app/model/webBook/BookList.kt
  9. 5
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
  10. 5
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceViewModel.kt
  11. 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 4
      app/src/main/java/io/legado/app/ui/book/search/DiffCallBack.kt
  13. 2
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  14. 26
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  15. 2
      app/src/main/java/io/legado/app/ui/main/explore/ExploreAdapter.kt
  16. 17
      app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt
  17. 13
      app/src/main/java/io/legado/app/ui/widget/SearchView.kt
  18. 2
      app/src/main/res/layout/activity_rss_read.xml
  19. 1
      app/src/main/res/layout/view_search.xml

@ -8,6 +8,7 @@
* 解决看过书籍的移到顶部需要向上滚动才能看到的bug
* 只有再书源被删除找不到书源时才会自动换源
* 美化界面by yangyxd
* 订阅后台播放
**2020/03/16**
* 修复滚动模式切换章节位置不归0的bug

@ -32,7 +32,7 @@ data class Book(
var customIntro: String? = null, // 简介内容(用户修改)
var charset: String? = null, // 自定义字符集名称(仅适用于本地书籍)
var type: Int = 0, // 0:text 1:audio
var group: Int = 1, // 自定义分组索引号
var group: Int = 0, // 自定义分组索引号
var latestChapterTitle: String? = null, // 最新章节标题
var latestChapterTime: Long = System.currentTimeMillis(), // 最新章节标题更新时间
var lastCheckTime: Long = System.currentTimeMillis(), // 最近一次更新书籍信息的时间

@ -6,7 +6,7 @@ import kotlin.coroutines.CoroutineContext
class Coroutine<T>(
scope: CoroutineScope,
val scope: CoroutineScope,
context: CoroutineContext = Dispatchers.IO,
block: suspend CoroutineScope.() -> T
) {
@ -31,6 +31,7 @@ class Coroutine<T>(
private var success: Callback<T?>? = null
private var error: Callback<Throwable>? = null
private var finally: VoidCallback? = null
private var cancel: VoidCallback? = null
private var timeMillis: Long? = null
private var errorReturn: Result<T>? = null
@ -45,7 +46,7 @@ class Coroutine<T>(
get() = job.isCompleted
init {
this.job = executeInternal(scope, context, block)
this.job = executeInternal(context, block)
}
fun timeout(timeMillis: () -> Long): Coroutine<T> {
@ -100,9 +101,28 @@ class Coroutine<T>(
return this@Coroutine
}
fun onCancel(
context: CoroutineContext? = null,
block: suspend CoroutineScope.() -> Unit
): Coroutine<T> {
this.cancel = VoidCallback(context, block)
return this@Coroutine
}
//取消当前任务
fun cancel(cause: CancellationException? = null) {
job.cancel(cause)
cancel?.let {
MainScope().launch {
if (null == it.context) {
it.block.invoke(scope)
} else {
withContext(scope.coroutineContext.plus(it.context)) {
it.block.invoke(this)
}
}
}
}
}
fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle {
@ -110,7 +130,6 @@ class Coroutine<T>(
}
private fun executeInternal(
scope: CoroutineScope,
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): Job {
@ -118,24 +137,30 @@ class Coroutine<T>(
try {
start?.let { dispatchVoidCallback(this, it) }
val value = executeBlock(scope, context, timeMillis ?: 0L, block)
if (isActive) {
success?.let { dispatchCallback(this, value, it) }
}
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
val consume: Boolean = errorReturn?.value?.let { value ->
if (isActive) {
success?.let { dispatchCallback(this, value, it) }
}
true
} ?: false
if (!consume) {
if (!consume && isActive) {
error?.let { dispatchCallback(this, e, it) }
}
} finally {
if (isActive) {
finally?.let { dispatchVoidCallback(this, it) }
}
}
}
}
private suspend inline fun dispatchVoidCallback(scope: CoroutineScope, callback: VoidCallback) {
if (null == callback.context) {
@ -152,6 +177,7 @@ class Coroutine<T>(
value: R,
callback: Callback<R>
) {
if (!scope.isActive) return
if (null == callback.context) {
callback.block.invoke(scope, value)
} else {

@ -54,6 +54,7 @@ object Backup {
suspend fun backup(context: Context, path: String = legadoPath) {
context.putPrefLong(PreferKey.lastBackup, System.currentTimeMillis())
withContext(IO) {
synchronized(this@Backup) {
writeListToJson(App.db.bookDao().all, "bookshelf.json", backupPath)
writeListToJson(App.db.bookGroupDao().all, "bookGroup.json", backupPath)
writeListToJson(App.db.bookSourceDao().all, "bookSource.json", backupPath)
@ -86,6 +87,7 @@ object Backup {
}
}
}
}
private fun writeListToJson(list: List<Any>, fileName: String, path: String) {
if (list.isNotEmpty()) {
@ -96,6 +98,7 @@ object Backup {
@Throws(java.lang.Exception::class)
private fun copyBackup(context: Context, uri: Uri) {
DocumentFile.fromTreeUri(context, uri)?.let { treeDoc ->
for (fileName in backupFileNames) {
val file = File(backupPath + File.separator + fileName)

@ -40,7 +40,6 @@ object OldBook {
book.durChapterTitle = jsonItem.readString("$.durChapterName")
book.durChapterPos = jsonItem.readInt("$.durChapterPage") ?: 0
book.durChapterTime = jsonItem.readLong("$.finalDate") ?: 0
book.group = jsonItem.readInt("$.group") ?: 0
book.intro = jsonItem.readString("$.bookInfoBean.introduce")
book.latestChapterTitle = jsonItem.readString("$.lastChapterName")
book.lastCheckCount = jsonItem.readInt("$.newChapters") ?: 0

@ -0,0 +1,5 @@
package io.legado.app.model
class SearchBook {
}

@ -29,11 +29,12 @@ class WebBook(val bookSource: BookSource) {
context: CoroutineContext = Dispatchers.IO
): Coroutine<List<SearchBook>> {
return Coroutine.async(scope, context) {
searchBookSuspend(key, page)
searchBookSuspend(scope, key, page)
}
}
suspend fun searchBookSuspend(
scope: CoroutineScope,
key: String,
page: Int? = 1
): ArrayList<SearchBook> {
@ -47,6 +48,7 @@ class WebBook(val bookSource: BookSource) {
)
val res = analyzeUrl.getResponseAwait(bookSource.bookSourceUrl)
return BookList.analyzeBookList(
scope,
res.body,
bookSource,
analyzeUrl,
@ -75,6 +77,7 @@ class WebBook(val bookSource: BookSource) {
)
val res = analyzeUrl.getResponseAwait(bookSource.bookSourceUrl)
BookList.analyzeBookList(
scope,
res.body,
bookSource,
analyzeUrl,

@ -9,11 +9,15 @@ import io.legado.app.model.Debug
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.NetworkUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.isActive
object BookList {
@Throws(Exception::class)
fun analyzeBookList(
scope: CoroutineScope,
body: String?,
bookSource: BookSource,
analyzeUrl: AnalyzeUrl,
@ -28,12 +32,13 @@ object BookList {
)
)
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${analyzeUrl.ruleUrl}")
if (!scope.isActive) throw CancellationException()
val analyzeRule = AnalyzeRule(null)
analyzeRule.setContent(body, baseUrl)
bookSource.bookUrlPattern?.let {
if (baseUrl.matches(it.toRegex())) {
Debug.log(bookSource.bookSourceUrl, "≡链接为详情页")
getInfoItem(analyzeRule, bookSource, baseUrl)?.let { searchBook ->
getInfoItem(scope, analyzeRule, bookSource, baseUrl)?.let { searchBook ->
searchBook.infoHtml = body
bookList.add(searchBook)
}
@ -59,7 +64,7 @@ object BookList {
collections = analyzeRule.getElements(ruleList)
if (collections.isEmpty() && bookSource.bookUrlPattern.isNullOrEmpty()) {
Debug.log(bookSource.bookSourceUrl, "└列表为空,按详情页解析")
getInfoItem(analyzeRule, bookSource, baseUrl)?.let { searchBook ->
getInfoItem(scope, analyzeRule, bookSource, baseUrl)?.let { searchBook ->
searchBook.infoHtml = body
bookList.add(searchBook)
}
@ -74,8 +79,9 @@ object BookList {
val ruleWordCount = analyzeRule.splitSourceRule(bookListRule.wordCount)
Debug.log(bookSource.bookSourceUrl, "└列表大小:${collections.size}")
for ((index, item) in collections.withIndex()) {
if (!scope.isActive) throw CancellationException()
getSearchItem(
item, analyzeRule, bookSource, baseUrl, index == 0,
scope, item, analyzeRule, bookSource, baseUrl, index == 0,
ruleName = ruleName, ruleBookUrl = ruleBookUrl, ruleAuthor = ruleAuthor,
ruleCoverUrl = ruleCoverUrl, ruleIntro = ruleIntro, ruleKind = ruleKind,
ruleLastChapter = ruleLastChapter, ruleWordCount = ruleWordCount
@ -93,7 +99,9 @@ object BookList {
return bookList
}
@Throws(Exception::class)
private fun getInfoItem(
scope: CoroutineScope,
analyzeRule: AnalyzeRule,
bookSource: BookSource,
baseUrl: String
@ -108,29 +116,37 @@ object BookList {
with(bookSource.getBookInfoRule()) {
init?.let {
if (it.isNotEmpty()) {
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "≡执行详情页初始化规则")
analyzeRule.setContent(analyzeRule.getElement(it))
}
}
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取书名")
searchBook.name = analyzeRule.getString(name)
Debug.log(bookSource.bookSourceUrl, "${searchBook.name}")
if (searchBook.name.isNotEmpty()) {
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取作者")
searchBook.author = BookHelp.formatAuthor(analyzeRule.getString(author))
Debug.log(bookSource.bookSourceUrl, "${searchBook.author}")
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取分类")
searchBook.kind = analyzeRule.getStringList(kind)?.joinToString(",")
Debug.log(bookSource.bookSourceUrl, "${searchBook.kind}")
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取字数")
searchBook.wordCount = analyzeRule.getString(wordCount)
Debug.log(bookSource.bookSourceUrl, "${searchBook.wordCount}")
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取最新章节")
searchBook.latestChapterTitle = analyzeRule.getString(lastChapter)
Debug.log(bookSource.bookSourceUrl, "${searchBook.latestChapterTitle}")
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取简介")
searchBook.intro = analyzeRule.getString(intro)
Debug.log(bookSource.bookSourceUrl, "${searchBook.intro}", true)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取封面链接")
searchBook.coverUrl = analyzeRule.getString(coverUrl, true)
Debug.log(bookSource.bookSourceUrl, "${searchBook.coverUrl}")
@ -140,7 +156,9 @@ object BookList {
return null
}
@Throws(Exception::class)
private fun getSearchItem(
scope: CoroutineScope,
item: Any,
analyzeRule: AnalyzeRule,
bookSource: BookSource,
@ -162,30 +180,38 @@ object BookList {
searchBook.originOrder = bookSource.customOrder
analyzeRule.book = searchBook
analyzeRule.setContent(item)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取书名", log)
searchBook.name = analyzeRule.getString(ruleName)
Debug.log(bookSource.bookSourceUrl, "${searchBook.name}", log)
if (searchBook.name.isNotEmpty()) {
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取作者", log)
searchBook.author = BookHelp.formatAuthor(analyzeRule.getString(ruleAuthor))
Debug.log(bookSource.bookSourceUrl, "${searchBook.author}", log)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取分类", log)
searchBook.kind = analyzeRule.getStringList(ruleKind)?.joinToString(",")
Debug.log(bookSource.bookSourceUrl, "${searchBook.kind}", log)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取字数", log)
searchBook.wordCount = analyzeRule.getString(ruleWordCount)
Debug.log(bookSource.bookSourceUrl, "${searchBook.wordCount}", log)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取最新章节", log)
searchBook.latestChapterTitle = analyzeRule.getString(ruleLastChapter)
Debug.log(bookSource.bookSourceUrl, "${searchBook.latestChapterTitle}", log)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取简介", log)
searchBook.intro = analyzeRule.getString(ruleIntro)
Debug.log(bookSource.bookSourceUrl, "${searchBook.intro}", log, true)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取封面链接", log)
analyzeRule.getString(ruleCoverUrl).let {
if (it.isNotEmpty()) searchBook.coverUrl = NetworkUtils.getAbsoluteURL(baseUrl, it)
}
Debug.log(bookSource.bookSourceUrl, "${searchBook.coverUrl}", log)
if (!scope.isActive) throw CancellationException()
Debug.log(bookSource.bookSourceUrl, "┌获取详情页链接", log)
searchBook.bookUrl = analyzeRule.getString(ruleBookUrl, true)
if (searchBook.bookUrl.isEmpty()) {

@ -51,7 +51,6 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
fun search() {
task = execute {
searchStateData.postValue(true)
val bookSourceList = App.db.bookSourceDao().allEnabled
for (item in bookSourceList) {
//task取消时自动取消 by (scope = this@execute)
@ -72,6 +71,10 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
}
}
}
}.onStart {
searchStateData.postValue(true)
}.onCancel {
searchStateData.postValue(false)
}
task?.invokeOnCompletion {

@ -72,7 +72,6 @@ class ChangeSourceViewModel(application: Application) : BaseViewModel(applicatio
fun search() {
task = execute {
searchStateData.postValue(true)
val bookSourceList = App.db.bookSourceDao().allEnabled
for (item in bookSourceList) {
//task取消时自动取消 by (scope = this@execute)
@ -95,6 +94,10 @@ class ChangeSourceViewModel(application: Application) : BaseViewModel(applicatio
}
}
}
}.onStart {
searchStateData.postValue(true)
}.onCancel {
searchStateData.postValue(false)
}
task?.invokeOnCompletion {

@ -161,7 +161,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
execute {
App.db.bookSourceDao().allTextEnabled.forEach { source ->
try {
val searchBooks = WebBook(source).searchBookSuspend(name)
val searchBooks = WebBook(source).searchBookSuspend(this, name)
searchBooks.getOrNull(0)?.let {
if (it.name == name && (it.author == author || author == "")) {
changeTo(it.toBook())

@ -50,8 +50,8 @@ class DiffCallBack(private val oldItems: List<SearchBook>, private val newItems:
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val payload = Bundle()
val newItem: SearchBook? = if ((newItemPosition >= 0) && (newItemPosition < newItems.size)) newItems[newItemPosition] else null
val oldItem: SearchBook? = if ((oldItemPosition >= 0) && (oldItemPosition < oldItems.size)) oldItems[oldItemPosition] else null
val newItem = newItems.getOrNull(newItemPosition)
val oldItem = oldItems.getOrNull(oldItemPosition)
if (newItem == null) return payload
if (oldItem?.name != newItem.name) {
payload.putString("name", newItem.name)

@ -3,7 +3,6 @@ package io.legado.app.ui.book.search
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import androidx.appcompat.widget.SearchView
@ -113,6 +112,7 @@ class SearchActivity : VMBaseActivity<SearchViewModel>(R.layout.activity_book_se
viewModel.saveSearchKey(query)
viewModel.search(it)
}
openOrCloseHistory(false)
return true
}

@ -12,8 +12,10 @@ import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.WebBook
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.isActive
import java.util.concurrent.Executors
class SearchViewModel(application: Application) : BaseViewModel(application) {
@ -43,7 +45,6 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
searchKey = key
searchBooks.clear()
}
isSearchLiveData.postValue(true)
task = execute {
val searchGroup = context.getPrefString("searchGroup") ?: ""
val bookSourceList = if (searchGroup.isBlank()) {
@ -56,21 +57,28 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
WebBook(item).searchBook(
searchKey,
searchPage,
scope = this@execute,
scope = this,
context = searchPool
)
.timeout(30000L)
.onSuccess(IO) {
if (isActive) {
it?.let { list ->
if (context.getPrefBoolean(PreferKey.precisionSearch)) {
precisionSearch(list)
precisionSearch(this, list)
} else {
App.db.searchBookDao().insert(*list.toTypedArray())
mergeItems(list)
mergeItems(this, list)
}
}
}
}
}
}.onStart {
isSearchLiveData.postValue(true)
}.onCancel {
isSearchLiveData.postValue(false)
isLoading = false
}
task?.invokeOnCompletion {
@ -82,7 +90,7 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
/**
* 精确搜索处理
*/
private fun precisionSearch(searchBooks: List<SearchBook>) {
private fun precisionSearch(scope: CoroutineScope, searchBooks: List<SearchBook>) {
val books = arrayListOf<SearchBook>()
searchBooks.forEach { searchBook ->
if (searchBook.name.contains(searchKey, true)
@ -90,14 +98,16 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
) books.add(searchBook)
}
App.db.searchBookDao().insert(*books.toTypedArray())
mergeItems(books)
if (scope.isActive) {
mergeItems(scope, books)
}
}
/**
* 合并搜索结果并排序
*/
@Synchronized
private fun mergeItems(newDataS: List<SearchBook>) {
private fun mergeItems(scope: CoroutineScope, newDataS: List<SearchBook>) {
if (newDataS.isNotEmpty()) {
val copyDataS = ArrayList(searchBooks)
val searchBooksAdd = ArrayList<SearchBook>()
@ -141,6 +151,7 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
}
}
}
if (!scope.isActive) return
searchBooks.sortWith(Comparator { o1, o2 ->
if (o1.name == searchKey && o2.name != searchKey) {
1
@ -166,6 +177,7 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
0
}
})
if (!scope.isActive) return
searchBooks = copyDataS
searchBookLiveData.postValue(copyDataS)
}

@ -24,7 +24,7 @@ import org.jetbrains.anko.sdk27.listeners.onLongClick
class ExploreAdapter(context: Context, private val scope: CoroutineScope, val callBack: CallBack) :
SimpleRecyclerAdapter<BookSource>(context, R.layout.item_find_book) {
private var exIndex = 0
private var exIndex = -1
private var scrollTo = -1
override fun convert(holder: ItemViewHolder, item: BookSource, payloads: MutableList<Any>) {

@ -0,0 +1,17 @@
package io.legado.app.ui.rss.read
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.webkit.WebView
class VisibleWebView(
context: Context,
attrs: AttributeSet? = null
) : WebView(context, attrs) {
override fun onWindowVisibilityChanged(visibility: Int) {
super.onWindowVisibilityChanged(View.VISIBLE)
}
}

@ -11,7 +11,6 @@ import android.text.style.ImageSpan
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import io.legado.app.R
@ -41,8 +40,7 @@ class SearchView : SearchView {
super.onLayout(changed, left, top, right, bottom)
try {
if (textView == null) {
textView =
findViewById<View>(androidx.appcompat.R.id.search_src_text) as TextView
textView = findViewById(androidx.appcompat.R.id.search_src_text)
mSearchHintIcon = this.context.getDrawable(R.drawable.ic_search_hint)
updateQueryHint()
}
@ -69,9 +67,8 @@ class SearchView : SearchView {
}
private fun updateQueryHint() {
if (textView != null) {
val hint = queryHint
textView!!.hint = getDecoratedHint(hint ?: "")
textView?.let {
it.hint = getDecoratedHint(queryHint ?: "")
}
}
@ -91,9 +88,8 @@ class SearchView : SearchView {
super.setQueryHint(hint)
updateQueryHint()
}
}
internal class CenteredImageSpan(drawable: Drawable?) : ImageSpan(drawable!!) {
internal class CenteredImageSpan(drawable: Drawable?) : ImageSpan(drawable!!) {
override fun draw(
canvas: Canvas, text: CharSequence,
start: Int, end: Int, x: Float,
@ -110,4 +106,5 @@ internal class CenteredImageSpan(drawable: Drawable?) : ImageSpan(drawable!!) {
b.draw(canvas)
canvas.restore()
}
}
}

@ -15,7 +15,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<WebView
<io.legado.app.ui.rss.read.VisibleWebView
android:id="@+id/web_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

@ -14,5 +14,4 @@
app:queryBackground="@null"
app:submitBackground="@null"
app:searchHintIcon="@drawable/ic_search_hint"
app:goIcon="@drawable/ic_search"
app:defaultQueryHint="搜索"/>
Loading…
Cancel
Save