Initial commit

This commit is contained in:
tom5079
2019-05-11 22:59:12 +09:00
commit 2a7814798b
134 changed files with 2735 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
package xyz.quaver.pupil
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_gallery.*
import kotlinx.coroutines.*
import xyz.quaver.hitomi.Reader
import xyz.quaver.hitomi.getReader
import xyz.quaver.hitomi.getReferer
import xyz.quaver.pupil.adapters.GalleryAdapter
import java.io.File
import java.io.FileOutputStream
import java.net.URL
import javax.net.ssl.HttpsURLConnection
class GalleryActivity : AppCompatActivity() {
private val images = ArrayList<String>()
private var galleryID = 0
private lateinit var reader: Deferred<Reader>
private var loadJob: Job? = null
private var screenMode = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gallery)
galleryID = intent.getIntExtra("GALLERY_ID", 0)
CoroutineScope(Dispatchers.Unconfined).launch {
reader = async(Dispatchers.IO) {
getReader(galleryID)
}
}
initView()
loadImages()
}
override fun onDestroy() {
super.onDestroy()
loadJob?.cancel()
}
private fun initView() {
gallery_recyclerview.adapter = GalleryAdapter(images).apply {
setOnClick {
val attrs = window.attributes
screenMode = (screenMode+1)%2
when(screenMode) {
0 -> {
attrs.flags = attrs.flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
supportActionBar?.show()
}
1 -> {
attrs.flags = attrs.flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
supportActionBar?.hide()
}
}
window.attributes = attrs
}
}
}
private fun loadImages() {
fun webpUrlFromUrl(url: URL) = URL(url.toString().replace("/galleries/", "/webp/") + ".webp")
loadJob = CoroutineScope(Dispatchers.Default).launch {
val reader = reader.await()
launch(Dispatchers.Main) {
supportActionBar?.title = reader.title
with(gallery_progressbar) {
max = reader.images.size
progress = 0
visibility = View.VISIBLE
}
}
reader.images.chunked(8).forEach { chunked ->
chunked.map {
async(Dispatchers.IO) {
val url = if (it.second?.haswebp == 1) webpUrlFromUrl(it.first) else it.first
val fileName: String
with(url.path) {
fileName = substring(lastIndexOf('/')+1)
}
val cache = File(cacheDir, "/imageCache/$galleryID/$fileName")
if (!cache.exists())
with(url.openConnection() as HttpsURLConnection) {
setRequestProperty("Referer", getReferer(galleryID))
if (!cache.parentFile.exists())
cache.parentFile.mkdirs()
inputStream.copyTo(FileOutputStream(cache))
}
cache.absolutePath
}
}.forEach {
val cache = it.await()
launch(Dispatchers.Main) {
images.add(cache)
gallery_recyclerview.adapter?.notifyItemInserted(images.size - 1)
gallery_progressbar.progress++
}
}
}
launch(Dispatchers.Main) {
gallery_progressbar.visibility = View.GONE
}
}
}
}

View File

@@ -0,0 +1,296 @@
package xyz.quaver.pupil
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.preference.PreferenceManager
import android.text.*
import android.text.style.AlignmentSpan
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.arlib.floatingsearchview.FloatingSearchView
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
import com.arlib.floatingsearchview.util.view.SearchInputView
import com.google.android.material.appbar.AppBarLayout
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
import xyz.quaver.hitomi.*
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
import xyz.quaver.pupil.types.TagSuggestion
import xyz.quaver.pupil.util.SetLineOverlap
import javax.net.ssl.HttpsURLConnection
class MainActivity : AppCompatActivity() {
private val galleries = ArrayList<Pair<GalleryBlock, Bitmap?>>()
private var isLoading = false
private var query = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
main_appbar_layout.addOnOffsetChangedListener(
AppBarLayout.OnOffsetChangedListener { _, p1 ->
main_searchview.translationY = p1.toFloat()
main_recyclerview.translationY = p1.toFloat()
}
)
with(main_swipe_layout) {
setProgressViewOffset(false, 0, resources.getDimensionPixelSize(R.dimen.progress_view_offset))
setOnRefreshListener {
runBlocking {
cleanJob?.join()
}
fetchGalleries(query, true)
}
}
setupRecyclerView()
setupSearchBar()
fetchGalleries(query)
}
private fun setupRecyclerView() {
with(main_recyclerview) {
adapter = GalleryBlockAdapter(galleries).apply {
setClickListener {
val intent = Intent(this@MainActivity, GalleryActivity::class.java)
intent.putExtra("GALLERY_ID", it)
//TODO: Maybe sprinke some transitions will be nice :D
startActivity(intent)
}
}
addOnScrollListener(
object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
if (!isLoading)
if (layoutManager.findLastCompletelyVisibleItemPosition() == galleries.size)
fetchGalleries(query)
}
}
)
}
}
private var suggestionJob : Job? = null
private fun setupSearchBar() {
val searchInputView = findViewById<SearchInputView>(R.id.search_bar_text)
//Change upper case letters to lower case
searchInputView.addTextChangedListener(object: TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
override fun afterTextChanged(s: Editable?) {
s ?: return
if (s.any { it.isUpperCase() })
s.replace(0, s.length, s.toString().toLowerCase())
}
})
with(main_searchview as FloatingSearchView) {
setOnMenuItemClickListener {
when(it.itemId) {
R.id.main_menu_settings -> startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
R.id.main_menu_search -> setSearchFocused(true)
}
}
setOnQueryChangeListener { _, query ->
clearSuggestions()
if (query.isEmpty() or query.endsWith(' '))
return@setOnQueryChangeListener
val currentQuery = query.split(" ").last().replace('_', ' ')
suggestionJob?.cancel()
suggestionJob = CoroutineScope(Dispatchers.IO).launch {
val suggestions = getSuggestionsForQuery(currentQuery).map { TagSuggestion(it) }
withContext(Dispatchers.Main) {
swapSuggestions(suggestions)
}
}
}
setOnBindSuggestionCallback { _, leftIcon, textView, item, _ ->
val suggestion = item as TagSuggestion
leftIcon.setImageDrawable(
ResourcesCompat.getDrawable(
resources,
when(suggestion.n) {
"female" -> R.drawable.ic_gender_female
"male" -> R.drawable.ic_gender_male
"language" -> R.drawable.ic_translate
"group" -> R.drawable.ic_account_group
"character" -> R.drawable.ic_account_star
"series" -> R.drawable.ic_book_open
"artist" -> R.drawable.ic_brush
else -> R.drawable.ic_tag
},
null)
)
val text = "${suggestion.s}\n ${suggestion.t}"
val len = text.length
val left = suggestion.s.length
textView.text = SpannableString(text).apply {
val s = AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE)
setSpan(s, left, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(SetLineOverlap(true), 1, len-2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(SetLineOverlap(false), len-1, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
setOnSearchListener(object : FloatingSearchView.OnSearchListener {
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
val suggestion = searchSuggestion as TagSuggestion
with(searchInputView.text) {
delete(if (lastIndexOf(' ') == -1) 0 else lastIndexOf(' ')+1, length)
append("${suggestion.n}:${suggestion.s.replace(Regex("\\s"), "_")} ")
}
clearSuggestions()
}
override fun onSearchAction(currentQuery: String?) {
//Do search on onFocusCleared()
}
})
setOnFocusChangeListener(object: FloatingSearchView.OnFocusChangeListener {
override fun onFocus() {
//Do Nothing
}
override fun onFocusCleared() {
suggestionJob?.cancel()
val query = searchInputView.text.toString()
if (query != this@MainActivity.query) {
this@MainActivity.query = query
fetchGalleries(query, true)
}
}
})
}
}
private val cache = ArrayList<Int>()
private var currentFetchingJob: Job? = null
private var cleanJob: Job? = null
private fun cancelFetch() {
isLoading = false
runBlocking {
cleanJob?.join()
currentFetchingJob?.cancelAndJoin()
}
}
private fun fetchGalleries(query: String, clear: Boolean = false) {
val preference = PreferenceManager.getDefaultSharedPreferences(this)
val perPage = preference.getString("per_page", "25")?.toInt() ?: 25
val defaultQuery = preference.getString("default_query", "")!!
if (clear) {
cancelFetch()
cleanJob = CoroutineScope(Dispatchers.Main).launch {
cache.clear()
galleries.clear()
main_recyclerview.adapter?.notifyDataSetChanged()
main_noresult.visibility = View.INVISIBLE
main_progressbar.show()
main_swipe_layout.isRefreshing = false
}
}
if (isLoading)
return
isLoading = true
currentFetchingJob = CoroutineScope(Dispatchers.IO).launch {
try {
val galleryIDs: List<Int>
cleanJob?.join()
if (query.isEmpty() && defaultQuery.isEmpty())
galleryIDs = fetchNozomi(start = galleries.size, count = perPage)
else {
if (cache.isEmpty())
cache.addAll(doSearch("$defaultQuery $query"))
galleryIDs = cache.slice(galleries.size until Math.min(galleries.size + perPage, cache.size))
with(main_recyclerview.adapter as GalleryBlockAdapter) {
noMore = galleries.size + perPage >= cache.size
}
}
if (query.isNotEmpty() and defaultQuery.isNotEmpty() and cache.isNullOrEmpty()) {
withContext(Dispatchers.Main) {
main_noresult.visibility = View.VISIBLE
main_progressbar.hide()
}
}
galleryIDs.chunked(4).forEach { chunked ->
chunked.map {
async {
val galleryBlock = getGalleryBlock(it)
val thumbnail: Bitmap
with(galleryBlock.thumbnails[0].openConnection() as HttpsURLConnection) {
thumbnail = BitmapFactory.decodeStream(inputStream)
}
Pair(galleryBlock, thumbnail)
}
}.forEach {
val galleryBlock = it.await()
withContext(Dispatchers.Main) {
main_progressbar.hide()
galleries.add(galleryBlock)
main_recyclerview.adapter?.notifyItemInserted(galleries.size - 1)
}
}
}
} finally {
isLoading = false
}
}
}
}

View File

@@ -0,0 +1,80 @@
package xyz.quaver.pupil
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.settings_activity)
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, SettingsFragment())
.commit()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
class SettingsFragment : PreferenceFragmentCompat() {
private val suffix = listOf(
"B",
"kB",
"MB",
"GB",
"TB" //really?
)
private fun getCacheSize() : String {
var size = context!!.cacheDir.walk().map { it.length() }.sum()
var suffixIndex = 0
while (size >= 1024) {
size /= 1024
suffixIndex++
}
return getString(R.string.settings_delete_cache_summary, size, suffix[suffixIndex])
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
with(findPreference<Preference>("delete_cache")) {
this ?: return@with
summary = getCacheSize()
setOnPreferenceClickListener {
AlertDialog.Builder(context).apply {
setTitle(getString(R.string.settings_delete_cache_alert_title))
setMessage(getString(R.string.settings_delete_cache_alert_message))
setPositiveButton(android.R.string.yes) { _, _ ->
with(context.cacheDir) {
if (exists())
deleteRecursively()
}
summary = getCacheSize()
}
setNegativeButton(android.R.string.no) { _, _ -> }
}.show()
true
}
}
}
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
when (item?.itemId) {
android.R.id.home -> onBackPressed()
}
return true
}
}

View File

@@ -0,0 +1,59 @@
package xyz.quaver.pupil.adapters
import android.graphics.BitmapFactory
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
class GalleryAdapter(private val images: List<String>) : RecyclerView.Adapter<GalleryAdapter.ViewHolder>() {
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
private var onClick: (() -> Unit)? = null
fun setOnClick(callback: (() -> Unit)?) {
this.onClick = callback
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
LayoutInflater.from(parent.context).inflate(
R.layout.item_gallery, parent, false
).let {
return ViewHolder(it as ImageView)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
with(holder.view) {
setOnClickListener {
onClick?.invoke()
}
CoroutineScope(Dispatchers.Default).launch {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(images[position], options)
options.inSampleSize = options.outWidth /
context.resources.displayMetrics.widthPixels
options.inJustDecodeBounds = false
val image = BitmapFactory.decodeFile(images[position], options)
launch(Dispatchers.Main) {
setImageBitmap(image)
}
}
}
}
override fun getItemCount() = images.size
}

View File

@@ -0,0 +1,109 @@
package xyz.quaver.pupil.adapters
import android.graphics.Bitmap
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.cardview.widget.CardView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import kotlinx.android.synthetic.main.item_galleryblock.view.*
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.toTag
import xyz.quaver.pupil.R
class GalleryBlockAdapter(private val galleries: List<Pair<GalleryBlock, Bitmap?>>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private enum class ViewType {
VIEW_ITEM,
VIEW_PROG
}
private fun String.wordCapitalize() : String {
val result = ArrayList<String>()
for (word in this.split(" "))
result.add(word.capitalize())
return result.joinToString(" ")
}
var noMore = false
class ViewHolder(val view: CardView) : RecyclerView.ViewHolder(view)
class ProgressViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
private var callback: ((Int) -> Unit)? = null
fun setClickListener(callback: ((Int) -> Unit)?) {
this.callback = callback
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
when(viewType) {
ViewType.VIEW_ITEM.ordinal -> {
val view = LayoutInflater.from(parent.context).inflate(
R.layout.item_galleryblock, parent, false
) as CardView
return ViewHolder(view)
}
ViewType.VIEW_PROG.ordinal -> {
val view = LayoutInflater.from(parent.context).inflate(
R.layout.item_progressbar, parent, false
) as LinearLayout
return ProgressViewHolder(view)
}
}
throw Exception("Unexpected ViewType")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
with(holder.view) {
val resources = context.resources
val languages = resources.getStringArray(R.array.languages).map {
it.split("|").let { split ->
Pair(split[0], split[1])
}
}.toMap()
val (gallery, thumbnail) = galleries[position]
val artists = gallery.artists.ifEmpty { listOf("N/A") }
val series = gallery.series.ifEmpty { listOf("N/A") }
setOnClickListener {
callback?.invoke(gallery.id)
}
galleryblock_thumbnail.setImageBitmap(thumbnail)
galleryblock_title.text = gallery.title
galleryblock_artist.text = artists.joinToString(", ") { it.wordCapitalize() }
galleryblock_series.text =
resources.getString(R.string.galleryblock_series, series.joinToString(", ") { it.wordCapitalize() })
galleryblock_type.text = resources.getString(R.string.galleryblock_type, gallery.type).wordCapitalize()
galleryblock_language.text =
resources.getString(R.string.galleryblock_language, languages[gallery.language])
galleryblock_tag_group.removeAllViews()
gallery.relatedTags.forEach {
galleryblock_tag_group.addView(
Chip(context).apply {
text = it.toTag().wordCapitalize()
}
)
}
}
}
}
override fun getItemCount() = if (galleries.isEmpty()) 0 else galleries.size+(if (noMore) 0 else 1)
override fun getItemViewType(position: Int): Int {
return when {
galleries.getOrNull(position) == null -> ViewType.VIEW_PROG.ordinal
else -> ViewType.VIEW_ITEM.ordinal
}
}
}

View File

@@ -0,0 +1,14 @@
package xyz.quaver.pupil.types
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
import kotlinx.android.parcel.Parcelize
import xyz.quaver.hitomi.Suggestion
@Parcelize
data class TagSuggestion constructor(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
override fun getBody(): String {
return s
}
}

View File

@@ -0,0 +1,37 @@
package xyz.quaver.pupil.util
import android.graphics.Paint
import android.text.style.LineHeightSpan
class SetLineOverlap(private val overlap: Boolean) : LineHeightSpan {
companion object {
private var originalBottom = 15
private var originalDescent = 13
private var overlapSaved = false
}
override fun chooseHeight(
text: CharSequence?,
start: Int,
end: Int,
spanstartv: Int,
lineHeight: Int,
fm: Paint.FontMetricsInt?
) {
fm ?: return
if (overlap) {
if (overlapSaved) {
originalBottom = fm.bottom
originalDescent = fm.descent
overlapSaved = true
}
fm.bottom += fm.top
fm.descent += fm.top
} else {
fm.bottom = originalBottom
fm.descent = originalDescent
overlapSaved = false
}
}
}