Merge branch 'master' of https://github.com/Celeter/legado
	
		
	
				
					
				
			
						commit
						9c81d03788
					
				| @ -0,0 +1,197 @@ | ||||
| package io.legado.app.service | ||||
| 
 | ||||
| import android.app.DownloadManager | ||||
| import android.content.BroadcastReceiver | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.content.IntentFilter | ||||
| import android.net.Uri | ||||
| import android.os.Build | ||||
| import android.os.Handler | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.content.FileProvider | ||||
| import androidx.core.os.bundleOf | ||||
| import io.legado.app.BuildConfig | ||||
| import io.legado.app.R | ||||
| import io.legado.app.base.BaseService | ||||
| import io.legado.app.constant.AppConst | ||||
| import io.legado.app.constant.IntentAction | ||||
| import io.legado.app.help.IntentHelp | ||||
| import io.legado.app.utils.RealPathUtil | ||||
| import io.legado.app.utils.msg | ||||
| import org.jetbrains.anko.downloadManager | ||||
| import org.jetbrains.anko.toast | ||||
| import java.io.File | ||||
| 
 | ||||
| 
 | ||||
| class DownloadService : BaseService() { | ||||
| 
 | ||||
|     private val downloads = hashMapOf<Long, String>() | ||||
|     private val completeDownloads = hashSetOf<Long>() | ||||
|     private val handler = Handler() | ||||
|     private val runnable = Runnable { | ||||
|         checkDownloadState() | ||||
|     } | ||||
| 
 | ||||
|     private val downloadReceiver = object : BroadcastReceiver() { | ||||
|         override fun onReceive(context: Context, intent: Intent) { | ||||
|             queryState() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) | ||||
|     } | ||||
| 
 | ||||
|     override fun onTaskRemoved(rootIntent: Intent?) { | ||||
|         super.onTaskRemoved(rootIntent) | ||||
|         stopSelf() | ||||
|     } | ||||
| 
 | ||||
|     override fun onDestroy() { | ||||
|         super.onDestroy() | ||||
|         unregisterReceiver(downloadReceiver) | ||||
|     } | ||||
| 
 | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         when (intent?.action) { | ||||
|             IntentAction.start -> startDownload( | ||||
|                 intent.getLongExtra("downloadId", 0), | ||||
|                 intent.getStringExtra("fileName") ?: "未知文件" | ||||
|             ) | ||||
|             IntentAction.play -> { | ||||
|                 val id = intent.getLongExtra("downloadId", 0) | ||||
|                 if (downloads[id]?.endsWith(".apk") == true) { | ||||
|                     installApk(id) | ||||
|                 } | ||||
|             } | ||||
|             IntentAction.stop -> { | ||||
|                 val downloadId = intent.getLongExtra("downloadId", 0) | ||||
|                 downloads.remove(downloadId) | ||||
|                 if (downloads.isEmpty()) { | ||||
|                     stopSelf() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return super.onStartCommand(intent, flags, startId) | ||||
|     } | ||||
| 
 | ||||
|     private fun startDownload(downloadId: Long, fileName: String) { | ||||
|         if (downloadId > 0) { | ||||
|             downloads[downloadId] = fileName | ||||
|             queryState() | ||||
|             checkDownloadState() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun checkDownloadState() { | ||||
|         handler.removeCallbacks(runnable) | ||||
|         queryState() | ||||
|         handler.postDelayed(runnable, 1000) | ||||
|     } | ||||
| 
 | ||||
|     //查询下载进度 | ||||
|     private fun queryState() { | ||||
|         val ids = downloads.keys | ||||
|         val query = DownloadManager.Query() | ||||
|         query.setFilterById(*ids.toLongArray()) | ||||
|         downloadManager.query(query).use { cursor -> | ||||
|             if (!cursor.moveToFirst()) return | ||||
|             val id = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_ID)) | ||||
|             val progress: Int = | ||||
|                 cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) | ||||
|             val max: Int = | ||||
|                 cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) | ||||
|             val status = | ||||
|                 when (cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))) { | ||||
|                     DownloadManager.STATUS_PAUSED -> "暂停" | ||||
|                     DownloadManager.STATUS_PENDING -> "待下载" | ||||
|                     DownloadManager.STATUS_RUNNING -> "下载中" | ||||
|                     DownloadManager.STATUS_SUCCESSFUL -> { | ||||
|                         if (!completeDownloads.contains(id)) { | ||||
|                             completeDownloads.add(id) | ||||
|                             if (downloads[id]?.endsWith(".apk") == true) { | ||||
|                                 installApk(id) | ||||
|                             } | ||||
|                         } | ||||
|                         "下载完成" | ||||
|                     } | ||||
|                     DownloadManager.STATUS_FAILED -> "下载失败" | ||||
|                     else -> "未知状态" | ||||
|                 } | ||||
|             updateNotification(id, "${downloads[id]} $status", max, progress) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun installApk(downloadId: Long) { | ||||
|         downloadManager.getUriForDownloadedFile(downloadId)?.let { | ||||
|             val filePath = RealPathUtil.getPath(this, it) ?: return | ||||
|             val file = File(filePath) | ||||
|             //调用系统安装apk | ||||
|             val intent = Intent() | ||||
|             intent.action = Intent.ACTION_VIEW | ||||
|             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||||
| 
 | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {   //7.0版本以上 | ||||
|                 val uriForFile: Uri = | ||||
|                     FileProvider.getUriForFile( | ||||
|                         this, | ||||
|                         "${BuildConfig.APPLICATION_ID}.fileProvider", | ||||
|                         file | ||||
|                     ) | ||||
|                 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) | ||||
|                 intent.setDataAndType(uriForFile, "application/vnd.android.package-archive") | ||||
|             } else { | ||||
|                 val uri: Uri = Uri.fromFile(file) | ||||
|                 intent.setDataAndType(uri, "application/vnd.android.package-archive") | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 startActivity(intent) | ||||
|             } catch (e: Exception) { | ||||
|                 toast(e.msg) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 更新通知 | ||||
|      */ | ||||
|     private fun updateNotification(downloadId: Long, content: String, max: Int, progress: Int) { | ||||
|         val notificationBuilder = NotificationCompat.Builder(this, AppConst.channelIdDownload) | ||||
|             .setSmallIcon(R.drawable.ic_download) | ||||
|             .setOngoing(true) | ||||
|             .setContentTitle(getString(R.string.action_download)) | ||||
|         notificationBuilder.setContentIntent( | ||||
|             IntentHelp.servicePendingIntent<DownloadService>( | ||||
|                 this, | ||||
|                 IntentAction.play, | ||||
|                 bundleOf("downloadId" to downloadId) | ||||
|             ) | ||||
|         ) | ||||
|         notificationBuilder.addAction( | ||||
|             R.drawable.ic_stop_black_24dp, | ||||
|             getString(R.string.cancel), | ||||
|             IntentHelp.servicePendingIntent<DownloadService>( | ||||
|                 this, | ||||
|                 IntentAction.stop, | ||||
|                 bundleOf("downloadId" to downloadId) | ||||
|             ) | ||||
|         ) | ||||
|         notificationBuilder.setDeleteIntent( | ||||
|             IntentHelp.servicePendingIntent<DownloadService>( | ||||
|                 this, | ||||
|                 IntentAction.stop, | ||||
|                 bundleOf("downloadId" to downloadId) | ||||
|             ) | ||||
|         ) | ||||
|         notificationBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) | ||||
|         notificationBuilder.setContentText(content) | ||||
|         notificationBuilder.setProgress(max, progress, false) | ||||
|         notificationBuilder.setAutoCancel(true) | ||||
|         val notification = notificationBuilder.build() | ||||
|         startForeground(downloadId.toInt(), notification) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,26 @@ | ||||
| package io.legado.app.service.help | ||||
| 
 | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import io.legado.app.constant.IntentAction | ||||
| import io.legado.app.service.DownloadService | ||||
| 
 | ||||
| object Download { | ||||
| 
 | ||||
|     fun start(context: Context, downloadId: Long, fileName: String) { | ||||
|         Intent(context, DownloadService::class.java).let { | ||||
|             it.action = IntentAction.start | ||||
|             it.putExtra("downloadId", downloadId) | ||||
|             it.putExtra("fileName", fileName) | ||||
|             context.startService(it) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun stop(context: Context) { | ||||
|         Intent(context, DownloadService::class.java).let { | ||||
|             it.action = IntentAction.stop | ||||
|             context.startService(it) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,93 @@ | ||||
| package io.legado.app.ui.book.searchContent | ||||
| 
 | ||||
| import android.os.Bundle | ||||
| import android.view.Menu | ||||
| import androidx.appcompat.widget.SearchView | ||||
| import androidx.core.view.isGone | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.FragmentManager | ||||
| import androidx.fragment.app.FragmentPagerAdapter | ||||
| import io.legado.app.R | ||||
| import io.legado.app.base.VMBaseActivity | ||||
| import io.legado.app.lib.theme.ATH | ||||
| import io.legado.app.lib.theme.accentColor | ||||
| import io.legado.app.lib.theme.primaryTextColor | ||||
| import io.legado.app.utils.getViewModel | ||||
| import io.legado.app.utils.gone | ||||
| import io.legado.app.utils.visible | ||||
| import kotlinx.android.synthetic.main.activity_chapter_list.* | ||||
| import kotlinx.android.synthetic.main.view_tab_layout.* | ||||
| 
 | ||||
| 
 | ||||
| class SearchListActivity : VMBaseActivity<SearchListViewModel>(R.layout.activity_search_list) { | ||||
|     // todo: 完善搜索界面UI | ||||
|     override val viewModel: SearchListViewModel | ||||
|         get() = getViewModel(SearchListViewModel::class.java) | ||||
| 
 | ||||
|     private var searchView: SearchView? = null | ||||
| 
 | ||||
|     override fun onActivityCreated(savedInstanceState: Bundle?) { | ||||
|         tab_layout.isTabIndicatorFullWidth = false | ||||
|         tab_layout.setSelectedTabIndicatorColor(accentColor) | ||||
|         intent.getStringExtra("bookUrl")?.let { | ||||
|             viewModel.initBook(it) { | ||||
|                 view_pager.adapter = TabFragmentPageAdapter(supportFragmentManager) | ||||
|                 tab_layout.setupWithViewPager(view_pager) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { | ||||
|         menuInflater.inflate(R.menu.search_view, menu) | ||||
|         val search = menu.findItem(R.id.menu_search) | ||||
|         searchView = search.actionView as SearchView | ||||
|         ATH.setTint(searchView!!, primaryTextColor) | ||||
|         searchView?.maxWidth = resources.displayMetrics.widthPixels | ||||
|         searchView?.onActionViewCollapsed() | ||||
|         searchView?.setOnCloseListener { | ||||
|             tab_layout.visible() | ||||
|             //to do clean | ||||
|             false | ||||
|         } | ||||
|         searchView?.setOnSearchClickListener { tab_layout.gone() } | ||||
|         searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { | ||||
|             override fun onQueryTextSubmit(query: String): Boolean { | ||||
|                 if (viewModel.lastQuery != query){ | ||||
|                     viewModel.startContentSearch(query) | ||||
|                 } | ||||
|                 return false | ||||
|             } | ||||
| 
 | ||||
|             override fun onQueryTextChange(newText: String): Boolean { | ||||
| 
 | ||||
|                 return false | ||||
|             } | ||||
|         }) | ||||
|         return super.onCompatCreateOptionsMenu(menu) | ||||
|     } | ||||
| 
 | ||||
|     private inner class TabFragmentPageAdapter(fm: FragmentManager) : | ||||
|         FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { | ||||
|         override fun getItem(position: Int): Fragment { | ||||
|             return SearchListFragment() | ||||
|         } | ||||
| 
 | ||||
|         override fun getCount(): Int { | ||||
|             return 1 | ||||
|         } | ||||
| 
 | ||||
|         override fun getPageTitle(position: Int): CharSequence? { | ||||
|             return "Search" | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     override fun onBackPressed() { | ||||
|         if (tab_layout.isGone) { | ||||
|             searchView?.onActionViewCollapsed() | ||||
|             tab_layout.visible() | ||||
|         } else { | ||||
|             super.onBackPressed() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,51 @@ | ||||
| package io.legado.app.ui.book.searchContent | ||||
| 
 | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import android.text.Html | ||||
| import android.util.Log | ||||
| import android.view.View | ||||
| import androidx.annotation.RequiresApi | ||||
| import androidx.core.text.HtmlCompat | ||||
| import io.legado.app.R | ||||
| import io.legado.app.base.adapter.ItemViewHolder | ||||
| import io.legado.app.base.adapter.SimpleRecyclerAdapter | ||||
| import io.legado.app.data.entities.BookChapter | ||||
| import io.legado.app.help.BookHelp | ||||
| import io.legado.app.lib.theme.accentColor | ||||
| import io.legado.app.utils.getCompatColor | ||||
| import io.legado.app.utils.visible | ||||
| import kotlinx.android.synthetic.main.item_bookmark.view.* | ||||
| import kotlinx.android.synthetic.main.item_search_list.view.* | ||||
| import org.jetbrains.anko.sdk27.listeners.onClick | ||||
| 
 | ||||
| class SearchListAdapter(context: Context, val callback: Callback) : | ||||
|     SimpleRecyclerAdapter<SearchResult>(context, R.layout.item_search_list) { | ||||
| 
 | ||||
|     val cacheFileNames = hashSetOf<String>() | ||||
| 
 | ||||
|     override fun convert(holder: ItemViewHolder, item: SearchResult, payloads: MutableList<Any>) { | ||||
|         with(holder.itemView) { | ||||
|             val isDur = callback.durChapterIndex() == item.chapterIndex | ||||
|             if (payloads.isEmpty()) { | ||||
|                 tv_search_result.text = item.parseText(item.presentText) | ||||
|                 if (isDur){ | ||||
|                     tv_search_result.paint.isFakeBoldText = true | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun registerListener(holder: ItemViewHolder) { | ||||
|         holder.itemView.onClick { | ||||
|             getItem(holder.layoutPosition)?.let { | ||||
|                 callback.openSearchResult(it) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     interface Callback { | ||||
|         fun openSearchResult(searchResult: SearchResult) | ||||
|         fun durChapterIndex(): Int | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,239 @@ | ||||
| package io.legado.app.ui.book.searchContent | ||||
| 
 | ||||
| import android.annotation.SuppressLint | ||||
| import android.app.Activity.RESULT_OK | ||||
| import android.content.Intent | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.View | ||||
| import androidx.lifecycle.LiveData | ||||
| import com.hankcs.hanlp.HanLP | ||||
| import io.legado.app.App | ||||
| import io.legado.app.R | ||||
| import io.legado.app.base.VMBaseFragment | ||||
| import io.legado.app.constant.EventBus | ||||
| import io.legado.app.data.entities.Book | ||||
| import io.legado.app.data.entities.BookChapter | ||||
| import io.legado.app.help.AppConfig | ||||
| import io.legado.app.help.BookHelp | ||||
| import io.legado.app.lib.theme.bottomBackground | ||||
| import io.legado.app.lib.theme.getPrimaryTextColor | ||||
| import io.legado.app.service.help.ReadBook | ||||
| import io.legado.app.ui.book.read.page.entities.TextPage | ||||
| import io.legado.app.ui.book.read.page.provider.ChapterProvider | ||||
| import io.legado.app.ui.widget.recycler.UpLinearLayoutManager | ||||
| import io.legado.app.ui.widget.recycler.VerticalDivider | ||||
| import io.legado.app.utils.ColorUtils | ||||
| import io.legado.app.utils.getViewModelOfActivity | ||||
| import io.legado.app.utils.observeEvent | ||||
| import kotlinx.android.synthetic.main.fragment_search_list.* | ||||
| import kotlinx.coroutines.* | ||||
| import kotlinx.coroutines.Dispatchers.IO | ||||
| import kotlinx.coroutines.Dispatchers.Main | ||||
| import org.jetbrains.anko.sdk27.listeners.onClick | ||||
| import java.util.regex.Pattern | ||||
| 
 | ||||
| class SearchListFragment : VMBaseFragment<SearchListViewModel>(R.layout.fragment_search_list), | ||||
|     SearchListAdapter.Callback, | ||||
|     SearchListViewModel.SearchListCallBack{ | ||||
|     override val viewModel: SearchListViewModel | ||||
|         get() = getViewModelOfActivity(SearchListViewModel::class.java) | ||||
| 
 | ||||
|     lateinit var adapter: SearchListAdapter | ||||
|     private lateinit var mLayoutManager: UpLinearLayoutManager | ||||
|     private var searchResultCounts = 0 | ||||
|     private var durChapterIndex = 0 | ||||
|     private var searchResultList: MutableList<SearchResult> = mutableListOf() | ||||
| 
 | ||||
|     override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         viewModel.searchCallBack = this | ||||
|         val bbg = bottomBackground | ||||
|         val btc = requireContext().getPrimaryTextColor(ColorUtils.isColorLight(bbg)) | ||||
|         ll_search_base_info.setBackgroundColor(bbg) | ||||
|         tv_current_search_info.setTextColor(btc) | ||||
|         iv_search_content_top.setColorFilter(btc) | ||||
|         iv_search_content_bottom.setColorFilter(btc) | ||||
|         initRecyclerView() | ||||
|         initView() | ||||
|         initBook() | ||||
|     } | ||||
| 
 | ||||
|     private fun initRecyclerView() { | ||||
|         adapter = SearchListAdapter(requireContext(), this) | ||||
|         mLayoutManager = UpLinearLayoutManager(requireContext()) | ||||
|         recycler_view.layoutManager = mLayoutManager | ||||
|         recycler_view.addItemDecoration(VerticalDivider(requireContext())) | ||||
|         recycler_view.adapter = adapter | ||||
|     } | ||||
| 
 | ||||
|     private fun initView() { | ||||
|         iv_search_content_top.onClick { mLayoutManager.scrollToPositionWithOffset(0, 0) } | ||||
|         iv_search_content_bottom.onClick { | ||||
|             if (adapter.itemCount > 0) { | ||||
|                 mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @SuppressLint("SetTextI18n") | ||||
|     private fun initBook() { | ||||
|         launch { | ||||
| 
 | ||||
|             tv_current_search_info.text = "搜索结果:$searchResultCounts" | ||||
|             viewModel.book?.let { | ||||
|                 initCacheFileNames(it) | ||||
|                 durChapterIndex = it.durChapterIndex | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun initCacheFileNames(book: Book) { | ||||
|         launch(IO) { | ||||
|             adapter.cacheFileNames.addAll(BookHelp.getChapterFiles(book)) | ||||
|             withContext(Main) { | ||||
|                 adapter.notifyItemRangeChanged(0, adapter.getActualItemCount(), true) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun observeLiveBus() { | ||||
|         observeEvent<BookChapter>(EventBus.SAVE_CONTENT) { chapter -> | ||||
|             viewModel.book?.bookUrl?.let { bookUrl -> | ||||
|                 if (chapter.bookUrl == bookUrl) { | ||||
|                     adapter.cacheFileNames.add(BookHelp.formatChapterName(chapter)) | ||||
|                     adapter.notifyItemChanged(chapter.index, true) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @SuppressLint("SetTextI18n") | ||||
|     override fun startContentSearch(newText: String) { | ||||
|         // 按章节搜索内容 | ||||
|         if (!newText.isBlank()) { | ||||
|             adapter.clearItems() | ||||
|             searchResultList.clear() | ||||
|             searchResultCounts = 0 | ||||
|             viewModel.lastQuery = newText | ||||
|             var searchResults = listOf<SearchResult>() | ||||
|             launch(Main){ | ||||
|                 App.db.bookChapterDao().getChapterList(viewModel.bookUrl).map{ chapter -> | ||||
|                     val job = async(IO){ | ||||
|                         if (isLocalBook || adapter.cacheFileNames.contains(BookHelp.formatChapterName(chapter))) { | ||||
|                             searchResults = searchChapter(newText, chapter) | ||||
|                         } | ||||
|                     } | ||||
|                     job.await() | ||||
|                     if (searchResults.isNotEmpty()){ | ||||
|                         searchResultList.addAll(searchResults) | ||||
|                         tv_current_search_info.text = "搜索结果:$searchResultCounts" | ||||
|                         adapter.addItems(searchResults) | ||||
|                         searchResults = listOf<SearchResult>() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private suspend fun searchChapter(query: String, chapter: BookChapter?): List<SearchResult> { | ||||
|         val searchResults: MutableList<SearchResult> = mutableListOf() | ||||
|         var positions : List<Int> = listOf() | ||||
|         var replaceContents: List<String>? = null | ||||
|         var totalContents = "" | ||||
|         if (chapter != null){ | ||||
|             viewModel.book?.let { bookSource -> | ||||
|                 val bookContent = BookHelp.getContent(bookSource, chapter) | ||||
|                 if (bookContent != null){ | ||||
|                     //搜索替换后的正文 | ||||
|                     val job = async(IO) { | ||||
|                         chapter.title = when (AppConfig.chineseConverterType) { | ||||
|                             1 -> HanLP.convertToSimplifiedChinese(chapter.title) | ||||
|                             2 -> HanLP.convertToTraditionalChinese(chapter.title) | ||||
|                             else -> chapter.title | ||||
|                         } | ||||
|                         replaceContents = BookHelp.disposeContent( | ||||
|                             chapter.title, | ||||
|                             bookSource.name, | ||||
|                             bookSource.bookUrl, | ||||
|                             bookContent, | ||||
|                             bookSource.useReplaceRule | ||||
|                         ) | ||||
|                     } | ||||
|                     job.await() | ||||
|                     while (replaceContents == null){ | ||||
|                         delay(100L) | ||||
|                     } | ||||
|                     totalContents = replaceContents!!.joinToString("") | ||||
|                     positions = searchPosition(totalContents, query) | ||||
|                     var count = 1 | ||||
|                     positions.map{ | ||||
|                         val construct = constructText(totalContents, it, query) | ||||
|                         val result = SearchResult(index = searchResultCounts, | ||||
|                             indexWithinChapter = count, | ||||
|                             text = construct[1] as String, | ||||
|                             chapterTitle = chapter.title, | ||||
|                             query = query, | ||||
|                             chapterIndex = chapter.index, | ||||
|                             newPosition = construct[0] as Int, | ||||
|                             contentPosition = it | ||||
|                         ) | ||||
|                         count += 1 | ||||
|                         searchResultCounts += 1 | ||||
|                         searchResults.add(result) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return searchResults | ||||
|     } | ||||
| 
 | ||||
|     private fun searchPosition(content: String, pattern: String): List<Int> { | ||||
|         val position : MutableList<Int> = mutableListOf() | ||||
|         var index = content.indexOf(pattern) | ||||
|         while(index >= 0){ | ||||
|             position.add(index) | ||||
|             index = content.indexOf(pattern, index + 1); | ||||
|         } | ||||
|         return position | ||||
|     } | ||||
| 
 | ||||
|     private fun constructText(content: String, position: Int, query: String): Array<Any>{ | ||||
|         // 构建关键词周边文字,在搜索结果里显示 | ||||
|         // todo: 判断段落,只在关键词所在段落内分割 | ||||
|         // todo: 利用标点符号分割完整的句 | ||||
|         // todo: length和设置结合,自由调整周边文字长度 | ||||
|         val length = 20 | ||||
|         var po1 = position - length | ||||
|         var po2 = position + query.length + length | ||||
|         if (po1 <0) { | ||||
|             po1 = 0 | ||||
|         } | ||||
|         if (po2 > content.length){ | ||||
|             po2 = content.length | ||||
|         } | ||||
|         val newPosition = position - po1 | ||||
|         val newText = content.substring(po1, po2) | ||||
|         return arrayOf(newPosition, newText) | ||||
|     } | ||||
| 
 | ||||
|     val isLocalBook: Boolean | ||||
|         get() = viewModel.book?.isLocalBook() == true | ||||
| 
 | ||||
|     override fun openSearchResult(searchResult: SearchResult) { | ||||
| 
 | ||||
|         val searchData = Intent() | ||||
|         searchData.putExtra("index", searchResult.chapterIndex) | ||||
|         searchData.putExtra("contentPosition", searchResult.contentPosition) | ||||
|         searchData.putExtra("query", searchResult.query) | ||||
|         searchData.putExtra("indexWithinChapter", searchResult.indexWithinChapter) | ||||
|         activity?.setResult(RESULT_OK, searchData) | ||||
|         activity?.finish() | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     override fun durChapterIndex(): Int { | ||||
|         return durChapterIndex | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,33 @@ | ||||
| package io.legado.app.ui.book.searchContent | ||||
| 
 | ||||
| 
 | ||||
| import android.app.Application | ||||
| import io.legado.app.App | ||||
| import io.legado.app.base.BaseViewModel | ||||
| import io.legado.app.data.entities.Book | ||||
| 
 | ||||
| class SearchListViewModel(application: Application) : BaseViewModel(application) { | ||||
|     var bookUrl: String = "" | ||||
|     var book: Book? = null | ||||
|     var searchCallBack: SearchListCallBack? = null | ||||
|     var lastQuery: String = "" | ||||
| 
 | ||||
|     fun initBook(bookUrl: String, success: () -> Unit) { | ||||
|         this.bookUrl = bookUrl | ||||
|         execute { | ||||
|             book = App.db.bookDao().getBook(bookUrl) | ||||
|         }.onSuccess { | ||||
|             success.invoke() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun startContentSearch(newText: String) { | ||||
|         searchCallBack?.startContentSearch(newText) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     interface SearchListCallBack { | ||||
|         fun startContentSearch(newText: String) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,43 @@ | ||||
| package io.legado.app.ui.book.searchContent | ||||
| 
 | ||||
| import android.text.Spanned | ||||
| import androidx.core.text.HtmlCompat | ||||
| import io.legado.app.App | ||||
| import io.legado.app.R | ||||
| import io.legado.app.utils.getCompatColor | ||||
| import io.legado.app.utils.hexString | ||||
| 
 | ||||
| data class SearchResult( | ||||
|     var index: Int = 0, | ||||
|     var indexWithinChapter: Int = 0, | ||||
|     var text: String = "", | ||||
|     var chapterTitle: String = "", | ||||
|     val query: String, | ||||
|     var pageSize: Int = 0, | ||||
|     var chapterIndex: Int = 0, | ||||
|     var pageIndex: Int = 0, | ||||
|     var newPosition: Int = 0, | ||||
|     var contentPosition: Int =0 | ||||
| ) { | ||||
|     val presentText: String | ||||
|         get(){ | ||||
|             return colorPresentText(newPosition, query, text) + | ||||
|                     "<font color=#0000ff>($chapterTitle)</font>" | ||||
|         } | ||||
| 
 | ||||
|     fun colorPresentText(position: Int, center: String, targetText: String): String { | ||||
|         val sub1 = text.substring(0, position) | ||||
|         val sub2 = text.substring(position + center.length, targetText.length) | ||||
|         val textColor = App.INSTANCE.getCompatColor(R.color.primaryText).hexString | ||||
|         return "<font color=#${textColor}>$sub1</font>" + | ||||
|                 "<font color=#ff0000>$center</font>" + | ||||
|                 "<font color=#${textColor}>$sub2</font>" | ||||
|     } | ||||
| 
 | ||||
|     fun parseText(targetText: String): Spanned { | ||||
|         return HtmlCompat.fromHtml(targetText, HtmlCompat.FROM_HTML_MODE_LEGACY) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,19 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|               xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|               android:orientation="vertical" | ||||
|               android:layout_width="match_parent" | ||||
|               android:layout_height="match_parent"> | ||||
| 
 | ||||
|     <io.legado.app.ui.widget.TitleBar | ||||
|             android:id="@+id/title_bar" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             app:contentLayout="@layout/view_tab_layout"/> | ||||
| 
 | ||||
|     <androidx.viewpager.widget.ViewPager | ||||
|             android:id="@+id/view_pager" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="match_parent"/> | ||||
| 
 | ||||
| </LinearLayout> | ||||
| @ -0,0 +1,66 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:descendantFocusability="blocksDescendants"> | ||||
| 
 | ||||
|     <io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView | ||||
|         android:id="@+id/recycler_view" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="0dp" | ||||
|         android:overScrollMode="never" | ||||
|         app:layout_constraintTop_toTopOf="parent" | ||||
|         app:layout_constraintBottom_toTopOf="@+id/ll_search_base_info" /> | ||||
| 
 | ||||
|     <LinearLayout | ||||
|         android:id="@+id/ll_search_base_info" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="36dp" | ||||
|         android:background="@color/background" | ||||
|         android:paddingLeft="10dp" | ||||
|         android:paddingRight="10dp" | ||||
|         android:elevation="5dp" | ||||
|         android:gravity="center_vertical" | ||||
|         android:orientation="horizontal" | ||||
|         app:layout_constraintBottom_toBottomOf="parent"> | ||||
| 
 | ||||
|         <TextView | ||||
|             android:id="@+id/tv_current_search_info" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="match_parent" | ||||
|             android:layout_weight="1" | ||||
|             android:background="?android:attr/selectableItemBackgroundBorderless" | ||||
|             android:ellipsize="middle" | ||||
|             android:paddingLeft="10dp" | ||||
|             android:paddingRight="10dp" | ||||
|             android:singleLine="true" | ||||
|             android:gravity="center_vertical" | ||||
|             android:textColor="@color/primaryText" | ||||
|             android:textSize="12sp" /> | ||||
| 
 | ||||
|         <androidx.appcompat.widget.AppCompatImageView | ||||
|             android:id="@+id/iv_search_content_top" | ||||
|             android:layout_width="36dp" | ||||
|             android:layout_height="match_parent" | ||||
|             android:background="?android:attr/selectableItemBackgroundBorderless" | ||||
|             android:contentDescription="@string/go_to_top" | ||||
|             android:src="@drawable/ic_arrow_drop_up" | ||||
|             android:tooltipText="@string/go_to_top" | ||||
|             app:tint="@color/primaryText" | ||||
|             tools:ignore="UnusedAttribute" /> | ||||
| 
 | ||||
|         <androidx.appcompat.widget.AppCompatImageView | ||||
|             android:id="@+id/iv_search_content_bottom" | ||||
|             android:layout_width="36dp" | ||||
|             android:layout_height="match_parent" | ||||
|             android:background="?android:attr/selectableItemBackgroundBorderless" | ||||
|             android:contentDescription="@string/go_to_bottom" | ||||
|             android:src="@drawable/ic_arrow_drop_down" | ||||
|             android:tooltipText="@string/go_to_bottom" | ||||
|             app:tint="@color/primaryText" | ||||
|             tools:ignore="UnusedAttribute" /> | ||||
|     </LinearLayout> | ||||
| 
 | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| @ -0,0 +1,19 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     android:orientation="vertical" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="wrap_content" | ||||
|     android:background="?android:attr/selectableItemBackground" | ||||
|     android:padding="12dp"> | ||||
| 
 | ||||
|     <TextView | ||||
|         android:id="@+id/tv_search_result" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:singleLine="false" | ||||
|         app:layout_constraintTop_toTopOf="parent" | ||||
|         app:layout_constraintLeft_toLeftOf="parent" | ||||
|         app:layout_constraintRight_toLeftOf="parent" /> | ||||
| 
 | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
					Loading…
					
					
				
		Reference in new issue