From d69dc375a3f222e72dac39031eac40519cb5b794 Mon Sep 17 00:00:00 2001 From: NerdNumber9 Date: Fri, 9 Aug 2019 04:40:30 -0400 Subject: [PATCH] Add 8muses --- .../tachiyomi/network/OkHttpExtensions.kt | 6 +- .../kanade/tachiyomi/source/SourceManager.kt | 2 + .../source/online/english/EightMuses.kt | 381 ++++++++++++++++++ app/src/main/java/exh/EHSourceHelpers.kt | 1 + .../metadata/EightMusesSearchMetadata.kt | 50 +++ app/src/main/java/exh/util/CachedField.kt | 24 ++ app/src/main/java/exh/util/FakeMutables.kt | 174 ++++++++ app/src/main/java/exh/util/NakedTrie.kt | 345 ++++++++++++++++ .../java/exh/util/SparseArrayCollection.kt | 73 ++++ 9 files changed, 1055 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt create mode 100644 app/src/main/java/exh/metadata/metadata/EightMusesSearchMetadata.kt create mode 100644 app/src/main/java/exh/util/CachedField.kt create mode 100644 app/src/main/java/exh/util/FakeMutables.kt create mode 100644 app/src/main/java/exh/util/NakedTrie.kt create mode 100644 app/src/main/java/exh/util/SparseArrayCollection.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 476d058ad..98355a653 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -21,11 +21,14 @@ fun Call.asObservableWithAsyncStacktrace(): Observable // Wrap the call in a helper which handles both unsubscription and backpressure. val requestArbiter = object : AtomicBoolean(), Producer, Subscription { + val executed = AtomicBoolean(false) + override fun request(n: Long) { if (n == 0L || !compareAndSet(false, true)) return try { val response = call.execute() + executed.set(true) if (!subscriber.isUnsubscribed) { subscriber.onNext(asyncStackTrace to response) subscriber.onCompleted() @@ -38,7 +41,8 @@ fun Call.asObservableWithAsyncStacktrace(): Observable } override fun unsubscribe() { - call.cancel() + if(!executed.get()) + call.cancel() } override fun isUnsubscribed(): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index ea650fbac..52d2dd8e7 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.all.* +import eu.kanade.tachiyomi.source.online.english.EightMuses import eu.kanade.tachiyomi.source.online.english.HentaiCafe import eu.kanade.tachiyomi.source.online.english.Pururin import eu.kanade.tachiyomi.source.online.english.Tsumino @@ -114,6 +115,7 @@ open class SourceManager(private val context: Context) { exSrcs += NHentai(context) exSrcs += Tsumino(context) exSrcs += Hitomi() + exSrcs += EightMuses() return exSrcs } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt new file mode 100644 index 000000000..fc88e9ff1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/EightMuses.kt @@ -0,0 +1,381 @@ +package eu.kanade.tachiyomi.source.online.english + +import android.net.Uri +import com.kizitonwose.time.hours +import com.lvla.rxjava.interopkt.toV1Single +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.LewdSource +import eu.kanade.tachiyomi.source.online.UrlImportableSource +import eu.kanade.tachiyomi.util.asJsoup +import exh.EIGHTMUSES_SOURCE_ID +import exh.metadata.metadata.EightMusesSearchMetadata +import exh.metadata.metadata.base.RaisedTag +import exh.util.CachedField +import exh.util.NakedTrie +import exh.util.await +import exh.util.urlImportFetchSearchManga +import kotlinx.coroutines.* +import kotlinx.coroutines.rx2.asSingle +import okhttp3.* +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import rx.schedulers.Schedulers + +typealias SiteMap = NakedTrie + +class EightMuses: HttpSource(), + LewdSource, + UrlImportableSource { + override val id = EIGHTMUSES_SOURCE_ID + + /** + * Name of the source. + */ + override val name = "8muses" + /** + * Whether the source has support for latest updates. + */ + override val supportsLatest = true + /** + * An ISO 639-1 compliant language code (two letters in lower case). + */ + override val lang: String = "en" + + override val metaClass = EightMusesSearchMetadata::class + + /** + * Base url of the website without the trailing slash, like: http://mysite.com + */ + override val baseUrl = EightMusesSearchMetadata.BASE_URL + + private val siteMapCache = CachedField(1.hours.inMilliseconds.longValue) + + override val client: OkHttpClient + get() = network.cloudflareClient + + private suspend fun obtainSiteMap() = siteMapCache.obtain { + withContext(Dispatchers.IO) { + val result = client.newCall(eightMusesGet("$baseUrl/sitemap/1.xml")) + .asObservableSuccess() + .toSingle() + .await(Schedulers.io()) + .body()!!.string() + + val parsed = Jsoup.parse(result) + + val seen = NakedTrie() + + parsed.getElementsByTag("loc").forEach { item -> + seen[item.text().substring(22)] = Unit + } + + seen + } + } + + override fun headersBuilder(): Headers.Builder { + return Headers.Builder() + .add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;") + .add("Accept-Language", "en-GB,en-US;q=0.9,en;q=0.8") + .add("Referer", "https://www.8muses.com") + .add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36") + } + + private fun eightMusesGet(url: String): Request { + return GET(url, headers = headersBuilder().build()) + } + + /** + * Returns the request for the popular manga given the page. + * + * @param page the page number to retrieve. + */ + override fun popularMangaRequest(page: Int) = eightMusesGet("$baseUrl/comics/$page") + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun popularMangaParse(response: Response): MangasPage { + throw UnsupportedOperationException("Should not be called!") + } + + /** + * Returns the request for the search manga given the page. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val urlBuilder = if(!query.isBlank()) { + HttpUrl.parse("$baseUrl/search")!! + .newBuilder() + .addQueryParameter("q", query) + } else { + HttpUrl.parse("$baseUrl/comics")!! + .newBuilder() + } + + urlBuilder.addQueryParameter("page", page.toString()) + + filters.filterIsInstance().map { + it.addToUri(urlBuilder) + } + + return eightMusesGet(urlBuilder.toString()) + } + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun searchMangaParse(response: Response): MangasPage { + throw UnsupportedOperationException("Should not be called!") + } + + /** + * Returns the request for latest manga given the page. + * + * @param page the page number to retrieve. + */ + override fun latestUpdatesRequest(page: Int) = eightMusesGet("$baseUrl/comics/lastupdate?page=$page") + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun latestUpdatesParse(response: Response): MangasPage { + throw UnsupportedOperationException("Should not be called!") + } + + override fun fetchLatestUpdates(page: Int) + = fetchListing(latestUpdatesRequest(page), false) + + override fun fetchPopularManga(page: Int) + = fetchListing(popularMangaRequest(page), false) // TODO Dig + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return urlImportFetchSearchManga(query) { + fetchListing(searchMangaRequest(page, query, filters), false) + } + } + + private fun fetchListing(request: Request, dig: Boolean): Observable { + return client.newCall(request) + .asObservableSuccess() + .flatMapSingle { response -> + GlobalScope.async(Dispatchers.IO) { + parseResultsPage(response, dig) + }.asSingle(GlobalScope.coroutineContext).toV1Single() + } + } + + private suspend fun parseResultsPage(response: Response, dig: Boolean): MangasPage { + val doc = response.asJsoup() + val contents = parseSelf(doc) + + val onLastPage = doc.selectFirst(".current:nth-last-child(2)") != null + + return MangasPage( + if(dig) { + contents.albums.flatMap { + val href = it.attr("href") + val splitHref = href.split('/') + obtainSiteMap().subMap(href).filter { + it.key.split('/').size - splitHref.size == 1 + }.map { (key, _) -> + SManga.create().apply { + url = key + + title = key.substringAfterLast('/').replace('-', ' ') + } + } + } + } else { + contents.albums.map { + SManga.create().apply { + url = it.attr("href") + + title = it.select(".title-text").text() + + thumbnail_url = baseUrl + it.select(".lazyload").attr("data-src") + } + } + }, + !onLastPage + ) + } + + /** + * Parses the response from the site and returns the details of a manga. + * + * @param response the response from the site. + */ + override fun mangaDetailsParse(response: Response): SManga { + throw UnsupportedOperationException("Should not be called!") + } + + /** + * Returns an observable with the updated details for a manga. Normally it's not needed to + * override this method. + * + * @param manga the manga to be updated. + */ + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsRequest(manga)) + .asObservableSuccess() + .flatMap { + parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga)) + } + } + + /** + * Parses the response from the site and returns a list of chapters. + * + * @param response the response from the site. + */ + override fun chapterListParse(response: Response): List { + throw UnsupportedOperationException("Should not be called!") + } + + override fun fetchChapterList(manga: SManga): Observable> { + return GlobalScope.async(Dispatchers.IO) { + fetchAndParseChapterList("", manga.url) + }.asSingle(GlobalScope.coroutineContext).toV1Single().toObservable() + } + + private suspend fun fetchAndParseChapterList(prefix: String, url: String): List { + // Request + val req = eightMusesGet(baseUrl + url) + + return client.newCall(req).asObservableSuccess().toSingle().await(Schedulers.io()).use { response -> + val contents = parseSelf(response.asJsoup()) + + val out = mutableListOf() + if(contents.images.isNotEmpty()) { + out += SChapter.create().apply { + this.url = url + this.name = if(prefix.isBlank()) ">" else prefix + } + } + + val builtPrefix = if(prefix.isBlank()) "> " else "$prefix > " + + out + contents.albums.flatMap { ele -> + fetchAndParseChapterList(builtPrefix + ele.selectFirst(".title-text").text(), ele.attr("href")) + } + } + } + + data class SelfContents(val albums: List, val images: List) + private fun parseSelf(doc: Document): SelfContents { + // Parse self + val gc = doc.select(".gallery .c-tile") + + // Check if any in self + val selfAlbums = gc.filter { it.attr("href").startsWith("/comics/album") } + val selfImages = gc.filter { it.attr("href").startsWith("/comics/picture") } + + return SelfContents(selfAlbums, selfImages) + } + + /** + * Parses the response from the site and returns a list of pages. + * + * @param response the response from the site. + */ + override fun pageListParse(response: Response): List { + val contents = parseSelf(response.asJsoup()) + return contents.images.mapIndexed { index, element -> + Page( + index, + element.attr("href"), + "$baseUrl/image/fl" + element.select(".lazyload").attr("data-src").substring(9) + ) + } + } + + override fun parseIntoMetadata(metadata: EightMusesSearchMetadata, input: Document) { + with(metadata) { + path = Uri.parse(input.location()).pathSegments + + val breadcrumbs = input.selectFirst(".top-menu-breadcrumb > ol") + + title = breadcrumbs.selectFirst("li:nth-last-child(1) > a").text() + + thumbnailUrl = parseSelf(input).let { it.albums + it.images }.firstOrNull() + ?.selectFirst(".lazyload") + ?.attr("data-src")?.let { + baseUrl + it + } + + tags.clear() + tags += RaisedTag( + EightMusesSearchMetadata.ARTIST_NAMESPACE, + breadcrumbs.selectFirst("li:nth-child(2) > a").text(), + EightMusesSearchMetadata.TAG_TYPE_DEFAULT + ) + tags += input.select(".album-tags a").map { + RaisedTag( + EightMusesSearchMetadata.TAGS_NAMESPACE, + it.text(), + EightMusesSearchMetadata.TAG_TYPE_DEFAULT + ) + } + } + } + + class SortFilter : Filter.Select( + "Sort", + SORT_OPTIONS.map { it.second }.toTypedArray() + ) { + fun addToUri(url: HttpUrl.Builder) { + url.addQueryParameter("sort", SORT_OPTIONS[state].first) + } + + companion object { + // + private val SORT_OPTIONS = listOf( + "" to "Views", + "like" to "Likes", + "date" to "Date", + "az" to "A-Z" + ) + } + } + + override fun getFilterList() = FilterList( + SortFilter() + ) + + /** + * Parses the response from the site and returns the absolute url to the source image. + * + * @param response the response from the site. + */ + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException("Should not be called!") + } + + override val matchingHosts = listOf( + "www.8muses.com", + "8muses.com" + ) + + override fun mapUrlToMangaUrl(uri: Uri): String? { + var path = uri.pathSegments.drop(2) + if(uri.pathSegments[1].toLowerCase() == "picture") { + path = path.dropLast(1) + } + return "/comics/album/${path.joinToString("/")}" + } +} \ No newline at end of file diff --git a/app/src/main/java/exh/EHSourceHelpers.kt b/app/src/main/java/exh/EHSourceHelpers.kt index 8f056b469..3d72b0c9b 100755 --- a/app/src/main/java/exh/EHSourceHelpers.kt +++ b/app/src/main/java/exh/EHSourceHelpers.kt @@ -20,6 +20,7 @@ val HENTAI_CAFE_SOURCE_ID = delegatedSourceId() val PURURIN_SOURCE_ID = delegatedSourceId() const val TSUMINO_SOURCE_ID = LEWD_SOURCE_SERIES + 9 const val HITOMI_SOURCE_ID = LEWD_SOURCE_SERIES + 10 +const val EIGHTMUSES_SOURCE_ID = LEWD_SOURCE_SERIES + 11 const val MERGED_SOURCE_ID = LEWD_SOURCE_SERIES + 69 private val DELEGATED_LEWD_SOURCES = listOf( diff --git a/app/src/main/java/exh/metadata/metadata/EightMusesSearchMetadata.kt b/app/src/main/java/exh/metadata/metadata/EightMusesSearchMetadata.kt new file mode 100644 index 000000000..fb35708b9 --- /dev/null +++ b/app/src/main/java/exh/metadata/metadata/EightMusesSearchMetadata.kt @@ -0,0 +1,50 @@ +package exh.metadata.metadata + +import eu.kanade.tachiyomi.source.model.SManga +import exh.metadata.metadata.base.RaisedSearchMetadata +import exh.plusAssign + +class EightMusesSearchMetadata : RaisedSearchMetadata() { + var path: List = emptyList() + + var title by titleDelegate(TITLE_TYPE_MAIN) + + var thumbnailUrl: String? = null + + override fun copyTo(manga: SManga) { + manga.url = path.joinToString("/", prefix = "/") + + title?.let { + manga.title = it + } + + thumbnailUrl?.let { + manga.thumbnail_url = it + } + + manga.artist = tags.ofNamespace(ARTIST_NAMESPACE).joinToString { it.name } + + manga.genre = tagsToGenreString() + + val titleDesc = StringBuilder() + title?.let { titleDesc += "Title: $it\n" } + + val tagsDesc = tagsToDescription() + + manga.description = listOf(titleDesc.toString(), tagsDesc.toString()) + .filter(String::isNotBlank) + .joinToString(separator = "\n") + + } + + companion object { + private const val TITLE_TYPE_MAIN = 0 + + const val TAG_TYPE_DEFAULT = 0 + + const val BASE_URL = "https://www.8muses.com" + + const val TAGS_NAMESPACE = "tags" + const val ARTIST_NAMESPACE = "artist" + } +} \ No newline at end of file diff --git a/app/src/main/java/exh/util/CachedField.kt b/app/src/main/java/exh/util/CachedField.kt new file mode 100644 index 000000000..8b4fa1fc4 --- /dev/null +++ b/app/src/main/java/exh/util/CachedField.kt @@ -0,0 +1,24 @@ +package exh.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class CachedField(private val expiresAfterMs: Long) { + @Volatile + private var initTime: Long = -1 + + @Volatile + private var content: T? = null + + private val mutex = Mutex() + + suspend fun obtain(producer: suspend () -> T): T { + return mutex.withLock { + if(initTime < 0 || System.currentTimeMillis() - initTime > expiresAfterMs) { + content = producer() + } + + content as T + } + } +} diff --git a/app/src/main/java/exh/util/FakeMutables.kt b/app/src/main/java/exh/util/FakeMutables.kt new file mode 100644 index 000000000..23b4fd215 --- /dev/null +++ b/app/src/main/java/exh/util/FakeMutables.kt @@ -0,0 +1,174 @@ +package exh.util + +// Zero-allocation-overhead mutable collection shims + +private inline class CollectionShim(private val coll: Collection) : FakeMutableCollection { + override val size: Int get() = coll.size + + override fun contains(element: E) = coll.contains(element) + + override fun containsAll(elements: Collection) = coll.containsAll(elements) + + override fun isEmpty() = coll.isEmpty() + + override fun fakeIterator() = coll.iterator() +} + +interface FakeMutableCollection : MutableCollection, FakeMutableIterable { + override fun add(element: E): Boolean { + throw UnsupportedOperationException("This collection is immutable!") + } + + override fun addAll(elements: Collection): Boolean { + throw UnsupportedOperationException("This collection is immutable!") + } + + override fun clear() { + throw UnsupportedOperationException("This collection is immutable!") + } + + override fun remove(element: E): Boolean { + throw UnsupportedOperationException("This collection is immutable!") + } + + override fun removeAll(elements: Collection): Boolean { + throw UnsupportedOperationException("This collection is immutable!") + } + + override fun retainAll(elements: Collection): Boolean { + throw UnsupportedOperationException("This collection is immutable!") + } + + override fun iterator(): MutableIterator = super.iterator() + + companion object { + fun fromCollection(coll: Collection): FakeMutableCollection = CollectionShim(coll) + } +} + +private inline class SetShim(private val set: Set) : FakeMutableSet { + override val size: Int get() = set.size + + override fun contains(element: E) = set.contains(element) + + override fun containsAll(elements: Collection) = set.containsAll(elements) + + override fun isEmpty() = set.isEmpty() + + override fun fakeIterator() = set.iterator() +} + +interface FakeMutableSet : MutableSet, FakeMutableCollection { + /** + * Adds the specified element to the set. + * + * @return `true` if the element has been added, `false` if the element is already contained in the set. + */ + override fun add(element: E): Boolean = super.add(element) + + override fun addAll(elements: Collection): Boolean = super.addAll(elements) + + override fun clear() = super.clear() + + override fun remove(element: E): Boolean = super.remove(element) + + override fun removeAll(elements: Collection): Boolean = super.removeAll(elements) + + override fun retainAll(elements: Collection): Boolean = super.retainAll(elements) + + override fun iterator(): MutableIterator = super.iterator() + + companion object { + fun fromSet(set: Set): FakeMutableSet = SetShim(set) + } +} + +private inline class IterableShim(private val iterable: Iterable) : FakeMutableIterable { + override fun fakeIterator() = iterable.iterator() +} + +interface FakeMutableIterable : MutableIterable { + /** + * Returns an iterator over the elements of this sequence that supports removing elements during iteration. + */ + override fun iterator(): MutableIterator = FakeMutableIterator.fromIterator(fakeIterator()) + + fun fakeIterator(): Iterator + + companion object { + fun fromIterable(iterable: Iterable): FakeMutableIterable = IterableShim(iterable) + } +} + +private inline class IteratorShim(private val iterator: Iterator) : FakeMutableIterator { + /** + * Returns `true` if the iteration has more elements. + */ + override fun hasNext() = iterator.hasNext() + + /** + * Returns the next element in the iteration. + */ + override fun next() = iterator.next() +} + + +interface FakeMutableIterator : MutableIterator { + /** + * Removes from the underlying collection the last element returned by this iterator. + */ + override fun remove() { + throw UnsupportedOperationException("This set is immutable!") + } + + companion object { + fun fromIterator(iterator: Iterator) : FakeMutableIterator = IteratorShim(iterator) + } +} + +private inline class EntryShim(private val entry: Map.Entry) : FakeMutableEntry { + /** + * Returns the key of this key/value pair. + */ + override val key: K + get() = entry.key + /** + * Returns the value of this key/value pair. + */ + override val value: V + get() = entry.value +} + +private inline class PairShim(private val pair: Pair) : FakeMutableEntry { + /** + * Returns the key of this key/value pair. + */ + override val key: K get() = pair.first + /** + * Returns the value of this key/value pair. + */ + override val value: V get() = pair.second +} + +interface FakeMutableEntry : MutableMap.MutableEntry { + override fun setValue(newValue: V): V { + throw UnsupportedOperationException("This entry is immutable!") + } + + companion object { + fun fromEntry(entry: Map.Entry): FakeMutableEntry = EntryShim(entry) + + fun fromPair(pair: Pair): FakeMutableEntry = PairShim(pair) + + fun fromPair(key: K, value: V) = object : FakeMutableEntry { + /** + * Returns the key of this key/value pair. + */ + override val key: K = key + /** + * Returns the value of this key/value pair. + */ + override val value: V = value + } + } +} \ No newline at end of file diff --git a/app/src/main/java/exh/util/NakedTrie.kt b/app/src/main/java/exh/util/NakedTrie.kt new file mode 100644 index 000000000..f6f2e5523 --- /dev/null +++ b/app/src/main/java/exh/util/NakedTrie.kt @@ -0,0 +1,345 @@ +package exh.util + +import android.util.SparseArray +import java.util.* + +class NakedTrieNode(val key: Int, var parent: NakedTrieNode?) { + val children = SparseArray>(1) + var hasData: Boolean = false + var data: T? = null + + // Walks in ascending order + // Consumer should return true to continue walking, false to stop walking + inline fun walk(prefix: String, consumer: (String, T) -> Boolean, leavesOnly: Boolean) { + // Special case root + if(hasData && (!leavesOnly || children.size() <= 0)) { + if(!consumer(prefix, data as T)) return + } + + val stack = LinkedList>>() + SparseArrayValueCollection(children, true).forEach { + stack += prefix + it.key.toChar() to it + } + while(!stack.isEmpty()) { + val (key, bottom) = stack.removeLast() + SparseArrayValueCollection(bottom.children, true).forEach { + stack += key + it.key.toChar() to it + } + if(bottom.hasData && (!leavesOnly || bottom.children.size() <= 0)) { + if(!consumer(key, bottom.data as T)) return + } + } + } + + fun getAsNode(key: String): NakedTrieNode? { + var current = this + for(c in key) { + current = current.children.get(c.toInt()) ?: return null + if(!current.hasData) return null + } + return current + } +} + +/** + * Fast, memory efficient and flexible trie implementation with implementation details exposed + */ +class NakedTrie : MutableMap { + /** + * Returns the number of key/value pairs in the map. + */ + override var size: Int = 0 + private set + + /** + * Returns `true` if the map is empty (contains no elements), `false` otherwise. + */ + override fun isEmpty() = size <= 0 + + /** + * Removes all elements from this map. + */ + override fun clear() { + root.children.clear() + root.hasData = false + root.data = null + size = 0 + } + + val root = NakedTrieNode(-1, null) + private var version: Long = 0 + + override fun put(key: String, value: T): T? { + // Traverse to node location in tree, making parent nodes if required + var current = root + for(c in key) { + val castedC = c.toInt() + var node = current.children.get(castedC) + if(node == null) { + node = NakedTrieNode(castedC, current) + current.children.put(castedC, node) + } + current = node + } + + // Add data to node or replace existing data + val previous = if(current.hasData) { + current.data + } else { + current.hasData = true + size++ + null + } + current.data = value + + version++ + + return previous + } + + override fun get(key: String): T? { + val current = getAsNode(key) ?: return null + return if(current.hasData) current.data else null + } + + fun getAsNode(key: String): NakedTrieNode? { + return root.getAsNode(key) + } + + override fun containsKey(key: String): Boolean { + var current = root + for(c in key) { + current = current.children.get(c.toInt()) ?: return false + if(!current.hasData) return false + } + return current.hasData + } + + /** + * Removes the specified key and its corresponding value from this map. + * + * @return the previous value associated with the key, or `null` if the key was not present in the map. + */ + override fun remove(key: String): T? { + // Traverse node tree while keeping track of the nodes we have visited + val nodeStack = LinkedList>() + for(c in key) { + val bottomOfStack = nodeStack.last + val current = bottomOfStack.children.get(c.toInt()) ?: return null + if(!current.hasData) return null + nodeStack.add(bottomOfStack) + } + + // Mark node as having no data + val bottomOfStack = nodeStack.last + bottomOfStack.hasData = false + val oldData = bottomOfStack.data + bottomOfStack.data = null // Clear data field for GC + + // Remove nodes that we visited that are useless + for(curBottom in nodeStack.descendingIterator()) { + val parent = curBottom.parent ?: break + if(!curBottom.hasData && curBottom.children.size() <= 0) { + // No data or child nodes, this node is useless, discard + parent.children.remove(curBottom.key) + } else break + } + + version++ + size-- + + return oldData + } + + /** + * Updates this map with key/value pairs from the specified map [from]. + */ + override fun putAll(from: Map) { + // No way to optimize this so yeah... + from.forEach { (s, u) -> + put(s, u) + } + } + + // Walks in ascending order + // Consumer should return true to continue walking, false to stop walking + inline fun walk(consumer: (String, T) -> Boolean) { + walk(consumer, false) + } + + // Walks in ascending order + // Consumer should return true to continue walking, false to stop walking + inline fun walk(consumer: (String, T) -> Boolean, leavesOnly: Boolean) { + root.walk("", consumer, leavesOnly) + } + + fun getOrPut(key: String, producer: () -> T): T { + // Traverse to node location in tree, making parent nodes if required + var current = root + for(c in key) { + val castedC = c.toInt() + var node = current.children.get(castedC) + if(node == null) { + node = NakedTrieNode(castedC, current) + current.children.put(castedC, node) + } + current = node + } + + // Add data to node or replace existing data + if(!current.hasData) { + current.hasData = true + current.data = producer() + size++ + version++ + } + + return current.data as T + } + + // Includes root + fun subMap(prefix: String, leavesOnly: Boolean = false): Map { + val node = getAsNode(prefix) ?: return emptyMap() + + return object : Map { + /** + * Returns a read-only [Set] of all key/value pairs in this map. + */ + override val entries: Set> + get() { + val out = mutableSetOf>() + node.walk("", { k, v -> + out.add(AbstractMap.SimpleImmutableEntry(k, v)) + true + }, leavesOnly) + return out + } + /** + * Returns a read-only [Set] of all keys in this map. + */ + override val keys: Set + get() { + val out = mutableSetOf() + node.walk("", { k, _ -> + out.add(k) + true + }, leavesOnly) + return out + } + + /** + * Returns the number of key/value pairs in the map. + */ + override val size: Int get() { + var s = 0 + node.walk("", { _, _ -> s++; true }, leavesOnly) + return s + } + + /** + * Returns a read-only [Collection] of all values in this map. Note that this collection may contain duplicate values. + */ + override val values: Collection + get() { + val out = mutableSetOf() + node.walk("", { _, v -> + out.add(v) + true + }, leavesOnly) + return out + } + + /** + * Returns `true` if the map contains the specified [key]. + */ + override fun containsKey(key: String): Boolean { + if(!key.startsWith(prefix)) return false + + val childNode = node.getAsNode(key.removePrefix(prefix)) ?: return false + return childNode.hasData && (!leavesOnly || childNode.children.size() <= 0) + } + + /** + * Returns `true` if the map maps one or more keys to the specified [value]. + */ + override fun containsValue(value: T): Boolean { + node.walk("", { _, v -> + if(v == value) return true + true + }, leavesOnly) + return false + } + + /** + * Returns the value corresponding to the given [key], or `null` if such a key is not present in the map. + */ + override fun get(key: String): T? { + if(!key.startsWith(prefix)) return null + + val childNode = node.getAsNode(key.removePrefix(prefix)) ?: return null + if(!childNode.hasData || (leavesOnly && childNode.children.size() > 0)) return null + return childNode.data + } + + /** + * Returns `true` if the map is empty (contains no elements), `false` otherwise. + */ + override fun isEmpty(): Boolean { + if(node.children.size() <= 0 && !root.hasData) return true + if(!leavesOnly) return false + node.walk("", { _, _ -> return false }, leavesOnly) + return true + } + } + } + + // Slow methods below + + /** + * Returns `true` if the map maps one or more keys to the specified [value]. + */ + override fun containsValue(value: T): Boolean { + walk { _, t -> + if(t == value) { + return true + } + + true + } + + return false + } + + /** + * Returns a [MutableSet] of all key/value pairs in this map. + */ + override val entries: MutableSet> + get() = FakeMutableSet.fromSet(mutableSetOf>().apply { + walk { k, v -> + this += FakeMutableEntry.fromPair(k, v) + true + } + }) + + /** + * Returns a [MutableSet] of all keys in this map. + */ + override val keys: MutableSet + get() = FakeMutableSet.fromSet(mutableSetOf().apply { + walk { k, _ -> + this += k + true + } + }) + + /** + * Returns a [MutableCollection] of all values in this map. Note that this collection may contain duplicate values. + */ + override val values: MutableCollection + get() = FakeMutableCollection.fromCollection(mutableListOf().apply { + walk { _, v -> + this += v + true + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/exh/util/SparseArrayCollection.kt b/app/src/main/java/exh/util/SparseArrayCollection.kt new file mode 100644 index 000000000..cad104ab3 --- /dev/null +++ b/app/src/main/java/exh/util/SparseArrayCollection.kt @@ -0,0 +1,73 @@ +package exh.util + +import android.util.SparseArray +import java.util.AbstractMap + +class SparseArrayKeyCollection(val sparseArray: SparseArray, var reverse: Boolean = false): AbstractCollection() { + override val size get() = sparseArray.size() + + override fun iterator() = object : Iterator { + private var index: Int = 0 + + /** + * Returns `true` if the iteration has more elements. + */ + override fun hasNext() = index < sparseArray.size() + + /** + * Returns the next element in the iteration. + */ + override fun next(): Int { + var idx = index++ + if(reverse) idx = sparseArray.size() - 1 - idx + return sparseArray.keyAt(idx) + } + } +} + +class SparseArrayValueCollection(val sparseArray: SparseArray, var reverse: Boolean = false): AbstractCollection() { + override val size get() = sparseArray.size() + + override fun iterator() = object : Iterator { + private var index: Int = 0 + + /** + * Returns `true` if the iteration has more elements. + */ + override fun hasNext() = index < sparseArray.size() + + /** + * Returns the next element in the iteration. + */ + override fun next(): E { + var idx = index++ + if(reverse) idx = sparseArray.size() - 1 - idx + return sparseArray.valueAt(idx) + } + } +} + +class SparseArrayCollection(val sparseArray: SparseArray, var reverse: Boolean = false): AbstractCollection>() { + override val size get() = sparseArray.size() + + override fun iterator() = object : Iterator> { + private var index: Int = 0 + + /** + * Returns `true` if the iteration has more elements. + */ + override fun hasNext() = index < sparseArray.size() + + /** + * Returns the next element in the iteration. + */ + override fun next(): Map.Entry { + var idx = index++ + if(reverse) idx = sparseArray.size() - 1 - idx + return AbstractMap.SimpleImmutableEntry( + sparseArray.keyAt(idx), + sparseArray.valueAt(idx) + ) + } + } +}