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