/* * Pupil, Hitomi.la viewer for Android * Copyright (C) 2019 tom5079 * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package xyz.quaver.pupil.ui import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.text.InputType import android.text.util.Linkify import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.view.animation.DecelerateInterpolator import android.widget.EditText import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import com.google.firebase.crashlytics.FirebaseCrashlytics import kotlinx.coroutines.* import xyz.quaver.floatingsearchview.FloatingSearchView import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.util.view.MenuView import xyz.quaver.floatingsearchview.util.view.SearchInputView import xyz.quaver.pupil.* import xyz.quaver.pupil.adapters.GalleryBlockAdapter import xyz.quaver.pupil.databinding.MainActivityBinding import xyz.quaver.pupil.hitomi.doSearch import xyz.quaver.pupil.hitomi.getGalleryIDsFromNozomi import xyz.quaver.pupil.hitomi.getSuggestionsForQuery import xyz.quaver.pupil.services.DownloadService import xyz.quaver.pupil.types.* import xyz.quaver.pupil.ui.dialog.DownloadLocationDialogFragment import xyz.quaver.pupil.ui.dialog.GalleryDialog import xyz.quaver.pupil.ui.view.MainView import xyz.quaver.pupil.ui.view.ProgressCard import xyz.quaver.pupil.util.ItemClickSupport import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.checkUpdate import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.requestNotificationPermission import xyz.quaver.pupil.util.restore import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog import java.util.regex.Pattern import kotlin.math.ceil import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { enum class Mode { SEARCH, HISTORY, DOWNLOAD, FAVORITE } enum class SortMode { NEWEST, POPULAR } private val galleries = ArrayList() private var query = "" set(value) { field = value with(findViewById(R.id.search_bar_text)) { if (text.toString() != value) setText(query, TextView.BufferType.EDITABLE) } } private var queryStack = mutableListOf() private var mode = Mode.SEARCH private var sortMode = SortMode.NEWEST private var galleryIDs: Deferred>? = null private var totalItems = 0 private var loadingJob: Job? = null private var currentPage = 0 private lateinit var binding: MainActivityBinding private val requestNotificationPermssionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (!isGranted) { showNotificationPermissionExplanationDialog(this) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = MainActivityBinding.inflate(layoutInflater) setContentView(binding.root) if (intent.action == Intent.ACTION_VIEW) { intent.dataString?.let { url -> restore(url, onFailure = { Snackbar.make(binding.contents.recyclerview, R.string.settings_backup_failed, Snackbar.LENGTH_LONG).show() }, onSuccess = { Snackbar.make(binding.contents.recyclerview, getString(R.string.settings_restore_success, it), Snackbar.LENGTH_LONG).show() } ) } } requestNotificationPermission(this, requestNotificationPermssionLauncher, false) {} if (Preferences["download_folder", ""].isEmpty()) DownloadLocationDialogFragment().show(supportFragmentManager, "Download Location Dialog") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Preferences["download_folder_ignore_warning", false] && ContextCompat.getExternalFilesDirs(this, null).filterNotNull().map { Uri.fromFile(it).toString() } .contains(Preferences["download_folder", ""]) ) { AlertDialog.Builder(this) .setTitle(R.string.warning) .setMessage(R.string.unaccessible_download_folder) .setPositiveButton(android.R.string.ok) { _, _ -> DownloadLocationDialogFragment().show(supportFragmentManager, "Download Location Dialog") }.setNegativeButton(R.string.ignore) { _, _ -> Preferences["download_folder_ignore_warning"] = true }.show() } initView() } override fun onResume() { super.onResume() checkUpdate(this) } @OptIn(ExperimentalStdlibApi::class) override fun onBackPressed() { when { binding.drawer.isDrawerOpen(GravityCompat.START) -> binding.drawer.closeDrawer(GravityCompat.START) queryStack.removeLastOrNull() != null && queryStack.isNotEmpty() -> runOnUiThread { query = queryStack.last() cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } else -> super.onBackPressed() } } override fun onDestroy() { super.onDestroy() (binding.contents.recyclerview.adapter as? GalleryBlockAdapter)?.updateAll = false } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { val perPage = Preferences["per_page", "25"].toInt() val maxPage = ceil(totalItems / perPage.toDouble()).roundToInt() return when(keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> { if (currentPage > 0) { runOnUiThread { currentPage-- cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } } true } KeyEvent.KEYCODE_VOLUME_DOWN -> { if (currentPage < maxPage) { runOnUiThread { currentPage++ cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } } true } else -> super.onKeyDown(keyCode, event) } } private fun initView() { binding.contents.recyclerview.addOnScrollListener(object: RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { // -height of the search view < translationY < 0 binding.contents.searchview.translationY = min( max( binding.contents.searchview.translationY - dy, -binding.contents.searchview.binding.querySection.root.height.toFloat() ), 0F) if (dy > 0) binding.contents.fab.hideMenuButton(true) else if (dy < 0) binding.contents.fab.showMenuButton(true) } }) Linkify.addLinks(binding.contents.noresult, Pattern.compile(getString(R.string.https_text)), null, null, { _, _ -> getString(R.string.https) }) //NavigationView binding.navView.setNavigationItemSelectedListener(this) with(binding.contents.cancelFab) { setImageResource(R.drawable.cancel) setOnClickListener { DownloadService.cancel(this@MainActivity) } } with(binding.contents.jumpFab) { setImageResource(R.drawable.ic_jump) setOnClickListener { val perPage = Preferences["per_page", "25"].toInt() val editText = EditText(context) AlertDialog.Builder(context).apply { setView(editText) setTitle(R.string.main_jump_title) setMessage(getString( R.string.main_jump_message, currentPage+1, ceil(totalItems / perPage.toDouble()).roundToInt() )) setPositiveButton(android.R.string.ok) { _, _ -> currentPage = (editText.text.toString().toIntOrNull() ?: return@setPositiveButton)-1 runOnUiThread { cancelFetch() clearGalleries() loadBlocks() } } }.show() } } with(binding.contents.randomFab) { setImageResource(R.drawable.shuffle_variant) setOnClickListener { runBlocking { withTimeoutOrNull(100) { galleryIDs?.await() } }.let { if (it?.isEmpty() == false) { val galleryID = it.random() GalleryDialog(this@MainActivity, galleryID).apply { onChipClickedHandler.add { runOnUiThread { query = it.toQuery() currentPage = 0 cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } dismiss() } }.show() } } } } with(binding.contents.idFab) { setImageResource(R.drawable.numeric) setOnClickListener { val editText = EditText(context).apply { inputType = InputType.TYPE_CLASS_NUMBER } AlertDialog.Builder(context).apply { setView(editText) setTitle(R.string.main_open_gallery_by_id) setPositiveButton(android.R.string.ok) { _, _ -> val galleryID = editText.text.toString().toIntOrNull() ?: return@setPositiveButton GalleryDialog(this@MainActivity, galleryID).apply { onChipClickedHandler.add { runOnUiThread { query = it.toQuery() currentPage = 0 cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } dismiss() } }.show() } }.show() } } with(binding.contents.view) { setOnPageTurnListener(object: MainView.OnPageTurnListener { override fun onPrev(page: Int) { currentPage-- // disable pageturn until the contents are loaded setCurrentPage(1, false) ViewCompat.animate(binding.contents.searchview) .setDuration(100) .setInterpolator(DecelerateInterpolator()) .translationY(0F) cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } override fun onNext(page: Int) { currentPage++ // disable pageturn until the contents are loaded setCurrentPage(1, false) ViewCompat.animate(binding.contents.searchview) .setDuration(100) .setInterpolator(DecelerateInterpolator()) .translationY(0F) cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } }) } setupSearchBar() setupRecyclerView() fetchGalleries(query, sortMode) loadBlocks() } @SuppressLint("ClickableViewAccessibility") private fun setupRecyclerView() { with(binding.contents.recyclerview) { adapter = GalleryBlockAdapter(galleries).apply { onChipClickedHandler.add { runOnUiThread { query = it.toQuery() currentPage = 0 cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } } onDownloadClickedHandler = { position -> val galleryID = galleries[position] requestNotificationPermission( this@MainActivity, requestNotificationPermssionLauncher ) { if (DownloadManager.getInstance(context).isDownloading(galleryID)) { //download in progress DownloadService.cancel(this@MainActivity, galleryID) } else { DownloadManager.getInstance(context).addDownloadFolder(galleryID) DownloadService.download(this@MainActivity, galleryID) } } closeAllItems() } onDeleteClickedHandler = { position -> val galleryID = galleries[position] DownloadService.delete(this@MainActivity, galleryID) histories.remove(galleryID) if (this@MainActivity.mode != Mode.SEARCH) runOnUiThread { cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } closeAllItems() } } ItemClickSupport.addTo(this).apply { onItemClickListener = listener@{ _, position, v -> if (v !is ProgressCard) return@listener val intent = Intent(this@MainActivity, ReaderActivity::class.java) intent.putExtra("galleryID", galleries[position]) //TODO: Maybe sprinkling some transitions will be nice :D startActivity(intent) } onItemLongClickListener = listener@{ _, position, v -> if (v !is ProgressCard) return@listener false val galleryID = galleries.getOrNull(position) ?: return@listener true GalleryDialog(this@MainActivity, galleryID).apply { onChipClickedHandler.add { runOnUiThread { query = it.toQuery() currentPage = 0 cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } dismiss() } }.show() true } } } } private var isFavorite = false private val defaultSuggestions: List get() = when { isFavorite -> { favoriteTags.map { TagSuggestion(it.tag, -1, "", it.area ?: "tag") } + FavoriteHistorySwitch(getString(R.string.search_show_histories)) } else -> { searchHistory.map { Suggestion(it) }.takeLast(10) + FavoriteHistorySwitch(getString(R.string.search_show_tags)) } }.reversed() private var suggestionJob : Job? = null private fun setupSearchBar() { with(binding.contents.searchview) { val scrollSuggestionToTop = { with(binding.suggestionSection.suggestionsList) { MainScope().launch { withTimeout(1000) { val layoutManager = layoutManager as LinearLayoutManager while (layoutManager.findLastVisibleItemPosition() != adapter?.itemCount?.minus(1)) { layoutManager.scrollToPosition(adapter?.itemCount?.minus(1) ?: 0) delay(100) } } } } } onMenuStatusChangeListener = object: FloatingSearchView.OnMenuStatusChangeListener { override fun onMenuOpened() { (this@MainActivity.binding.contents.recyclerview.adapter as GalleryBlockAdapter).closeAllItems() } override fun onMenuClosed() { //Do Nothing } } post { findViewById(R.id.menu_view).menuItems.firstOrNull { (it as MenuItem).itemId == R.id.main_menu_thin }?.let { (it as MenuItem).isChecked = Preferences["thin"] } } onHistoryDeleteClickedListener = { searchHistory.remove(it) swapSuggestions(defaultSuggestions) } onFavoriteHistorySwitchClickListener = { isFavorite = !isFavorite swapSuggestions(defaultSuggestions) scrollSuggestionToTop() } onMenuItemClickListener = { onActionMenuItemSelected(it) } onQueryChangeListener = lambda@{ _, query -> this@MainActivity.query = query suggestionJob?.cancel() if (query.isEmpty() or query.endsWith(' ')) { swapSuggestions(defaultSuggestions) scrollSuggestionToTop() return@lambda } swapSuggestions(listOf(LoadingSuggestion(getText(R.string.reader_loading).toString()))) val currentQuery = query.split(" ").last() .replace(Regex("^-"), "") .replace('_', ' ') suggestionJob = CoroutineScope(Dispatchers.IO).launch { val suggestions = kotlin.runCatching { getSuggestionsForQuery(currentQuery).map { TagSuggestion(it) }.toMutableList() }.getOrElse { mutableListOf() } suggestions.filter { val tag = "${it.n}:${it.s.replace(Regex("\\s"), "_")}" favoriteTags.contains(Tag.parse(tag)) }.reversed().forEach { suggestions.remove(it) suggestions.add(0, it) } withContext(Dispatchers.Main) { swapSuggestions(if (suggestions.isNotEmpty()) suggestions else listOf(NoResultSuggestion(getText(R.string.main_no_result).toString()))) } } } onFocusChangeListener = object: FloatingSearchView.OnFocusChangeListener { override fun onFocus() { if (query.isEmpty() or query.endsWith(' ')) { swapSuggestions(defaultSuggestions) scrollSuggestionToTop() } } override fun onFocusCleared() { suggestionJob?.cancel() runOnUiThread { cancelFetch() clearGalleries() currentPage = 0 fetchGalleries(query, sortMode) loadBlocks() } } } attachNavigationDrawerToMenuButton(this@MainActivity.binding.drawer) } } fun onActionMenuItemSelected(item: MenuItem?) { when(item?.itemId) { R.id.main_menu_settings -> startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) R.id.main_menu_thin -> { val thin = !item.isChecked item.isChecked = thin binding.contents.recyclerview.apply { (adapter as GalleryBlockAdapter).apply { this.thin = thin Preferences["thin"] = thin } adapter = adapter // Force to redraw } } R.id.main_menu_sort_newest -> { sortMode = SortMode.NEWEST item.isChecked = true runOnUiThread { currentPage = 0 cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } } R.id.main_menu_sort_popular -> { sortMode = SortMode.POPULAR item.isChecked = true runOnUiThread { currentPage = 0 cancelFetch() clearGalleries() fetchGalleries(query, sortMode) loadBlocks() } } } } override fun onNavigationItemSelected(item: MenuItem): Boolean { runOnUiThread { binding.drawer.closeDrawers() when(item.itemId) { R.id.main_drawer_home -> { cancelFetch() clearGalleries() currentPage = 0 query = "" queryStack.clear() mode = Mode.SEARCH fetchGalleries(query, sortMode) loadBlocks() } R.id.main_drawer_history -> { cancelFetch() clearGalleries() currentPage = 0 query = "" queryStack.clear() mode = Mode.HISTORY fetchGalleries(query, sortMode) loadBlocks() } R.id.main_drawer_downloads -> { cancelFetch() clearGalleries() currentPage = 0 query = "" queryStack.clear() mode = Mode.DOWNLOAD fetchGalleries(query, sortMode) loadBlocks() } R.id.main_drawer_favorite -> { cancelFetch() clearGalleries() currentPage = 0 query = "" queryStack.clear() mode = Mode.FAVORITE fetchGalleries(query, sortMode) loadBlocks() } R.id.main_drawer_help -> { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.help)))) } R.id.main_drawer_github -> { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.github)))) } R.id.main_drawer_homepage -> { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.home_page)))) } R.id.main_drawer_email -> { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.email)))) } R.id.main_drawer_kakaotalk -> { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.discord)))) } } } return true } private fun cancelFetch() { galleryIDs?.cancel() loadingJob?.cancel() } private fun clearGalleries() = CoroutineScope(Dispatchers.Main).launch { galleries.clear() with(binding.contents.recyclerview.adapter as GalleryBlockAdapter?) { this ?: return@with this.notifyDataSetChanged() } binding.contents.noresult.visibility = View.INVISIBLE binding.contents.progressbar.show() } private fun fetchGalleries(query: String, sortMode: SortMode) { val defaultQuery: String = Preferences["default_query"] if (query.isNotBlank()) searchHistory.add(query) if (query != queryStack.lastOrNull()) { queryStack.remove(query) queryStack.add(query) } if (query.isNotEmpty() && mode != Mode.SEARCH) { Snackbar.make(binding.contents.recyclerview, R.string.search_all, Snackbar.LENGTH_SHORT).apply { setAction(android.R.string.ok) { cancelFetch() clearGalleries() currentPage = 0 mode = Mode.SEARCH queryStack.clear() fetchGalleries(query, sortMode) loadBlocks() } }.show() } galleryIDs = null if (galleryIDs?.isActive == true) return galleryIDs = CoroutineScope(Dispatchers.IO).async { when(mode) { Mode.SEARCH -> { when { query.isEmpty() and defaultQuery.isEmpty() -> { when(sortMode) { SortMode.POPULAR -> getGalleryIDsFromNozomi(null, "popular", "all") else -> getGalleryIDsFromNozomi(null, "index", "all") }.also { totalItems = it.size } } else -> doSearch("$defaultQuery $query", sortMode == SortMode.POPULAR).also { totalItems = it.size } } } Mode.HISTORY -> { when { query.isEmpty() -> { histories.reversed().also { totalItems = it.size } } else -> { val result = doSearch(query).sorted() histories.reversed().filter { result.binarySearch(it) >= 0 }.also { totalItems = it.size } } } } Mode.DOWNLOAD -> { val downloads = DownloadManager.getInstance(this@MainActivity).downloadFolderMap.keys.toList() when { query.isEmpty() -> downloads.reversed().also { totalItems = it.size } else -> { val result = doSearch(query).sorted() downloads.reversed().filter { result.binarySearch(it) >= 0 }.also { totalItems = it.size } } } } Mode.FAVORITE -> { when { query.isEmpty() -> favorites.reversed().also { totalItems = it.size } else -> { val result = doSearch(query).sorted() favorites.reversed().filter { result.binarySearch(it) >= 0 }.also { totalItems = it.size } } } } }.toList() } } private fun loadBlocks() { val perPage = Preferences["per_page", "25"].toInt() loadingJob = CoroutineScope(Dispatchers.IO).launch { val galleryIDs = try { galleryIDs!!.await().also { if (it.isEmpty()) throw Exception("No result") } } catch (e: Exception) { if (e !is CancellationException) FirebaseCrashlytics.getInstance().recordException(e) withContext(Dispatchers.Main) { binding.contents.noresult.visibility = View.VISIBLE binding.contents.progressbar.hide() } return@launch } launch(Dispatchers.Main) { binding.contents.view.setCurrentPage(currentPage + 1, galleryIDs.size > (currentPage+1)*perPage) } galleryIDs.slice(currentPage*perPage until min(currentPage*perPage+perPage, galleryIDs.size)).chunked(5).let { chunks -> for (chunk in chunks) chunk.map { galleryID -> async { Cache.getInstance(this@MainActivity, galleryID).getGalleryBlock()?.let { galleryID } } }.forEach { it.await()?.also { withContext(Dispatchers.Main) { binding.contents.progressbar.hide() galleries.add(it) binding.contents.recyclerview.adapter!!.notifyItemInserted(galleries.size - 1) } } } } } } }