Rewrite search suggestion with kotlin flow, remove suggestion limit

This commit is contained in:
tarsin committed 2022-12-17 12:47:37 +08:00
1 parent 8b1723e19b
commit 0485d52e3e
2 files changed
+298 -309

No files matched your search

@@ -13,312 +13,280 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client
package com.hippo.ehviewer.client;
import android.content.Context
import androidx.core.util.Pair
import com.hippo.ehviewer.AppConfig
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
import com.hippo.ehviewer.R
import com.hippo.util.ExceptionUtils
import com.hippo.util.HashCodeUtils
import com.hippo.util.IoThreadPoolExecutor
import com.hippo.yorozuya.FileUtils
import com.hippo.yorozuya.IOUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.BufferedSource
import okio.buffer
import okio.source
import org.json.JSONException
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock
import android.content.Context;
class EhTagDatabase(private val name: String, source: BufferedSource) {
private val tags: JSONObject?
private var tagList: ArrayList<Pair<String, String>>? = null
import androidx.annotation.Nullable;
import androidx.core.util.Pair;
import com.hippo.ehviewer.AppConfig;
import com.hippo.ehviewer.EhApplication;
import com.hippo.ehviewer.R;
import com.hippo.util.ExceptionUtils;
import com.hippo.util.HashCodeUtils;
import com.hippo.util.IoThreadPoolExecutor;
import com.hippo.yorozuya.FileUtils;
import com.hippo.yorozuya.IOUtils;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSource;
import okio.Okio;
public class EhTagDatabase {
private static final Map<String, String> NAMESPACE_TO_PREFIX = new HashMap<>();
// TODO more lock for different language
private static final Lock lock = new ReentrantLock();
private static volatile EhTagDatabase instance;
static {
NAMESPACE_TO_PREFIX.put("artist", "a:");
NAMESPACE_TO_PREFIX.put("cosplayer", "cos:");
NAMESPACE_TO_PREFIX.put("character", "c:");
NAMESPACE_TO_PREFIX.put("female", "f:");
NAMESPACE_TO_PREFIX.put("group", "g:");
NAMESPACE_TO_PREFIX.put("language", "l:");
NAMESPACE_TO_PREFIX.put("male", "m:");
NAMESPACE_TO_PREFIX.put("mixed", "x:");
NAMESPACE_TO_PREFIX.put("other", "o:");
NAMESPACE_TO_PREFIX.put("parody", "p:");
NAMESPACE_TO_PREFIX.put("reclass", "r:");
}
private final String name;
private final JSONObject tags;
private final ArrayList<Pair<String, String>> tagList;
public EhTagDatabase(String name, BufferedSource source) throws IOException {
this.name = name;
JSONObject tmpTags = null;
init {
var tmpTags: JSONObject? = null
try {
tmpTags = new JSONObject(source.readString(StandardCharsets.UTF_8));
} catch (JSONException e) {
e.printStackTrace();
tmpTags = JSONObject(source.readString(StandardCharsets.UTF_8))
} catch (e: JSONException) {
e.printStackTrace()
}
tags = tmpTags;
tags = tmpTags
if (tags != null) {
tagList = new ArrayList<>();
tags.keys().forEachRemaining(k -> {
tagList = ArrayList()
tags.keys().forEachRemaining { k: String ->
try {
tagList.add(new Pair<>(k, tags.getString(k)));
} catch (JSONException e) {
e.printStackTrace();
tagList!!.add(Pair(k, tags.getString(k)))
} catch (e: JSONException) {
e.printStackTrace()
}
});
}
} else {
tagList = null;
tagList = null
}
}
@Nullable
public static EhTagDatabase getInstance() {
return instance;
}
@Nullable
public static String namespaceToPrefix(String namespace) {
return NAMESPACE_TO_PREFIX.get(namespace);
}
private static String[] getMetadata(Context context) {
String[] metadata = context.getResources().getStringArray(R.array.tag_translation_metadata);
if (metadata.length == 4) {
return metadata;
} else {
return null;
}
}
public static boolean isTranslatable(Context context) {
return context.getResources().getBoolean(R.bool.tag_translatable);
}
@Nullable
private static String getFileContent(File file) {
try (BufferedSource source = Okio.buffer(Okio.source(file))) {
return source.readString(StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
@Nullable
private static String getFileSha1(File file) {
try (InputStream is = new FileInputStream(file)) {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
int n;
byte[] buffer = new byte[4 * 1024];
while ((n = is.read(buffer)) != -1) {
digest.update(buffer, 0, n);
}
return HashCodeUtils.bytesToHexString(digest.digest());
} catch (IOException | NoSuchAlgorithmException e) {
return null;
}
}
private static boolean checkData(File sha1File, File dataFile) {
var s1 = getFileContent(sha1File);
if (s1 == null) {
return false;
}
var s2 = getFileSha1(dataFile);
if (s2 == null) {
return false;
}
return s1.equals(s2);
}
private static boolean save(OkHttpClient client, String url, File file) {
Request request = new Request.Builder().url(url).build();
Call call = client.newCall(request);
try (Response response = call.execute()) {
if (!response.isSuccessful()) {
return false;
}
ResponseBody body = response.body();
try (InputStream is = body.byteStream(); OutputStream os = new FileOutputStream(file)) {
IOUtils.copy(is, os);
}
return true;
} catch (Throwable t) {
t.printStackTrace();
ExceptionUtils.throwIfFatal(t);
return false;
}
}
public static void update(Context context) {
String[] urls = getMetadata(context);
if (urls == null || urls.length != 4) {
// Clear tags if it's not possible
instance = null;
return;
}
String sha1Name = urls[0];
String sha1Url = urls[1];
String dataName = urls[2];
String dataUrl = urls[3];
// Clear tags if name if different
EhTagDatabase tmp = instance;
if (tmp != null && !tmp.name.equals(dataName)) {
instance = null;
}
IoThreadPoolExecutor.getInstance().execute(() -> {
if (!lock.tryLock()) {
return;
}
try {
File dir = AppConfig.getFilesDir("tag-translations");
if (dir == null) {
return;
}
// Check current sha1 and current data
File sha1File = new File(dir, sha1Name);
File dataFile = new File(dir, dataName);
if (!checkData(sha1File, dataFile)) {
FileUtils.delete(sha1File);
FileUtils.delete(dataFile);
}
// Read current EhTagDatabase
if (instance == null && dataFile.exists()) {
try (BufferedSource source = Okio.buffer(Okio.source(dataFile))) {
instance = new EhTagDatabase(dataName, source);
} catch (IOException e) {
FileUtils.delete(sha1File);
FileUtils.delete(dataFile);
}
}
OkHttpClient client = EhApplication.getOkHttpClient();
// Save new sha1
File tempSha1File = new File(dir, sha1Name + ".tmp");
if (!save(client, sha1Url, tempSha1File)) {
FileUtils.delete(tempSha1File);
return;
}
// Check new sha1 and current data
if (checkData(tempSha1File, dataFile)) {
// The data is the same
FileUtils.delete(tempSha1File);
return;
}
// Save new data
File tempDataFile = new File(dir, dataName + ".tmp");
if (!save(client, dataUrl, tempDataFile)) {
FileUtils.delete(tempDataFile);
return;
}
// Check new sha1 and new data
if (!checkData(tempSha1File, tempDataFile)) {
FileUtils.delete(tempSha1File);
FileUtils.delete(tempDataFile);
return;
}
// Replace current sha1 and current data with new sha1 and new data
FileUtils.delete(sha1File);
FileUtils.delete(dataFile);
tempSha1File.renameTo(sha1File);
tempDataFile.renameTo(dataFile);
// Read new EhTagDatabase
try (BufferedSource source = Okio.buffer(Okio.source(dataFile))) {
instance = new EhTagDatabase(dataName, source);
} catch (IOException e) {
// Ignore
}
} finally {
lock.unlock();
}
});
}
public String getTranslation(String tag) {
fun getTranslation(tag: String?): String? {
try {
return tags.getString(tag);
} catch (JSONException e) {
e.printStackTrace();
return tag?.let { tags!!.getString(it) }
} catch (e: JSONException) {
e.printStackTrace()
}
return null;
return null
}
public ArrayList<Pair<String, String>> suggest(String keyword, boolean translate) {
ArrayList<Pair<String, String>> searchHints = new ArrayList<>();
for (int i = 0; i < tagList.size(); i++) {
var tmp = tagList.get(i);
String tag = tmp.first;
String hint = translate ? tmp.second : null;
int index = tag.indexOf(':');
boolean keywordMatches;
if (index == -1 || index >= tag.length() - 1 || keyword.length() > 2) {
keywordMatches = containsIgnoreSpace(tag, keyword);
} else {
keywordMatches = containsIgnoreSpace(tag.substring(index + 1), keyword);
}
/* Construct a cold flow for tag database suggestions */
fun suggestFlow(keyword: String, translate: Boolean): Flow<Pair<String?, String>> = flow {
tagList?.forEach {
val tag = it.first
val hint = if (translate) it.second else null
val index = tag.indexOf(':')
val keywordMatches: Boolean =
if (index == -1 || index >= tag.length - 1 || keyword.length > 2) {
containsIgnoreSpace(tag, keyword)
} else {
containsIgnoreSpace(tag.substring(index + 1), keyword)
}
if (keywordMatches || containsIgnoreSpace(hint, keyword)) {
Pair<String, String> pair = new Pair<>(hint, tag);
if (!searchHints.contains(pair)) {
searchHints.add(pair);
emit(Pair(hint, tag))
}
}
}
private fun containsIgnoreSpace(text: String?, key: String): Boolean {
return text != null && text.replace(" ", "").contains(key.replace(" ", ""))
}
companion object {
private val NAMESPACE_TO_PREFIX: MutableMap<String, String> = HashMap()
// TODO more lock for different language
private val lock: Lock = ReentrantLock()
@JvmStatic
@Volatile
var instance: EhTagDatabase? = null
private set
init {
NAMESPACE_TO_PREFIX["artist"] = "a:"
NAMESPACE_TO_PREFIX["cosplayer"] = "cos:"
NAMESPACE_TO_PREFIX["character"] = "c:"
NAMESPACE_TO_PREFIX["female"] = "f:"
NAMESPACE_TO_PREFIX["group"] = "g:"
NAMESPACE_TO_PREFIX["language"] = "l:"
NAMESPACE_TO_PREFIX["male"] = "m:"
NAMESPACE_TO_PREFIX["mixed"] = "x:"
NAMESPACE_TO_PREFIX["other"] = "o:"
NAMESPACE_TO_PREFIX["parody"] = "p:"
NAMESPACE_TO_PREFIX["reclass"] = "r:"
}
@JvmStatic
fun namespaceToPrefix(namespace: String): String? {
return NAMESPACE_TO_PREFIX[namespace]
}
private fun getMetadata(context: Context): Array<String>? {
val metadata = context.resources.getStringArray(R.array.tag_translation_metadata)
return if (metadata.size == 4) {
metadata
} else {
null
}
}
fun isTranslatable(context: Context): Boolean {
return context.resources.getBoolean(R.bool.tag_translatable)
}
private fun getFileContent(file: File): String? {
try {
file.source().buffer()
.use { source -> return source.readString(StandardCharsets.UTF_8) }
} catch (e: IOException) {
return null
}
}
private fun getFileSha1(file: File): String? {
try {
FileInputStream(file).use { `is` ->
val digest = MessageDigest.getInstance("SHA-1")
var n: Int
val buffer = ByteArray(4 * 1024)
while (`is`.read(buffer).also { n = it } != -1) {
digest.update(buffer, 0, n)
}
return HashCodeUtils.bytesToHexString(digest.digest())
}
} catch (e: IOException) {
return null
} catch (e: NoSuchAlgorithmException) {
return null
}
}
private fun checkData(sha1File: File, dataFile: File): Boolean {
val s1 = getFileContent(sha1File) ?: return false
val s2 = getFileSha1(dataFile) ?: return false
return s1 == s2
}
private fun save(client: OkHttpClient, url: String, file: File): Boolean {
val request: Request = Request.Builder().url(url).build()
val call = client.newCall(request)
try {
call.execute().use { response ->
if (!response.isSuccessful) {
return false
}
val body = response.body
body.byteStream()
.use { `is` -> FileOutputStream(file).use { os -> IOUtils.copy(`is`, os) } }
return true
}
} catch (t: Throwable) {
t.printStackTrace()
ExceptionUtils.throwIfFatal(t)
return false
}
}
@JvmStatic
fun update(context: Context) {
val urls = getMetadata(context)
if (urls == null || urls.size != 4) {
// Clear tags if it's not possible
instance = null
return
}
val sha1Name = urls[0]
val sha1Url = urls[1]
val dataName = urls[2]
val dataUrl = urls[3]
// Clear tags if name if different
val tmp = instance
if (tmp != null && tmp.name != dataName) {
instance = null
}
IoThreadPoolExecutor.getInstance().execute {
if (!lock.tryLock()) {
return@execute
}
try {
val dir = AppConfig.getFilesDir("tag-translations") ?: return@execute
// Check current sha1 and current data
val sha1File = File(dir, sha1Name)
val dataFile = File(dir, dataName)
if (!checkData(sha1File, dataFile)) {
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
}
// Read current EhTagDatabase
if (instance == null && dataFile.exists()) {
try {
dataFile.source().buffer()
.use { source -> instance = EhTagDatabase(dataName, source) }
} catch (e: IOException) {
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
}
}
val client = okHttpClient
// Save new sha1
val tempSha1File = File(dir, "$sha1Name.tmp")
if (!save(client, sha1Url, tempSha1File)) {
FileUtils.delete(tempSha1File)
return@execute
}
// Check new sha1 and current data
if (checkData(tempSha1File, dataFile)) {
// The data is the same
FileUtils.delete(tempSha1File)
return@execute
}
// Save new data
val tempDataFile = File(dir, "$dataName.tmp")
if (!save(client, dataUrl, tempDataFile)) {
FileUtils.delete(tempDataFile)
return@execute
}
// Check new sha1 and new data
if (!checkData(tempSha1File, tempDataFile)) {
FileUtils.delete(tempSha1File)
FileUtils.delete(tempDataFile)
return@execute
}
// Replace current sha1 and current data with new sha1 and new data
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
tempSha1File.renameTo(sha1File)
tempDataFile.renameTo(dataFile)
// Read new EhTagDatabase
try {
dataFile.source().buffer()
.use { source -> instance = EhTagDatabase(dataName, source) }
} catch (e: IOException) {
// Ignore
}
} finally {
lock.unlock()
}
}
if (searchHints.size() > 20) {
break;
}
}
return searchHints;
}
private boolean containsIgnoreSpace(String text, String key) {
return text != null && text.replace(" ", "").contains(key.replace(" ", ""));
}
}
}
@@ -11,6 +11,7 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.search.SearchBar
@@ -22,6 +23,11 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.widget.SearchDatabase
import com.hippo.scene.StageActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
abstract class SearchBarScene : ToolbarScene() {
@@ -221,10 +227,13 @@ abstract class SearchBarScene : ToolbarScene() {
}
inner class TagSuggestion constructor(private var mHint: String, private var mKeyword: String) :
inner class TagSuggestion constructor(
private var mHint: String?,
private var mKeyword: String
) :
Suggestion() {
override fun getText(textView: TextView): CharSequence {
override fun getText(textView: TextView): CharSequence? {
return if (textView.id == android.R.id.text1) {
mKeyword
} else {
@@ -278,33 +287,45 @@ abstract class SearchBarScene : ToolbarScene() {
@SuppressLint("NotifyDataSetChanged")
private fun updateSuggestions(scrollToTop: Boolean = true) {
val suggestions = arrayListOf<Suggestion>()
val text = mSearchView?.editText?.text?.toString() ?: return
mSuggestionProvider?.run {
providerSuggestions(text)?.let { suggestions.addAll(it) }
}
mSearchDatabase.getSuggestions(text, 128).forEach { suggestions.add(KeywordSuggestion(it)) }
val ehTagDatabase = EhTagDatabase.getInstance()
if (!TextUtils.isEmpty(text) && ehTagDatabase != null && !text.endsWith(" ")) {
val s = text.split(" ".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (s.isNotEmpty()) {
val keyword = s[s.size - 1]
val translate = Settings.getShowTagTranslations() && EhTagDatabase.isTranslatable(context)
val searchHints = ehTagDatabase.suggest(keyword, translate)
for (searchHint in searchHints) {
suggestions.add(TagSuggestion(searchHint.first, searchHint.second))
viewLifecycleOwner.lifecycleScope.launch {
val suggestions = mutableListOf<Suggestion>()
withContext(Dispatchers.Default) {
mergedSuggestionFlow().collect {
suggestions.add(it)
}
}
withContext(Dispatchers.Main) {
mSuggestionList = suggestions
mSuggestionAdapter?.notifyDataSetChanged()
}
}
mSuggestionList = suggestions
mSuggestionAdapter?.notifyDataSetChanged()
if (scrollToTop) {
mRecyclerView?.scrollToPosition(0)
}
}
private fun mergedSuggestionFlow(): Flow<Suggestion> = flow {
mSearchView?.editText?.text?.toString()?.let { text ->
mSuggestionProvider?.run { providerSuggestions(text)?.forEach { emit(it) } }
mSearchDatabase.getSuggestions(text, 128).forEach { emit(KeywordSuggestion(it)) }
EhTagDatabase.instance?.run {
if (!TextUtils.isEmpty(text) && !text.endsWith(" ")) {
val s = text.split(" ".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (s.isNotEmpty()) {
val keyword = s[s.size - 1]
val translate =
Settings.getShowTagTranslations() && EhTagDatabase.isTranslatable(
requireContext()
)
suggestFlow(keyword, translate).collect {
emit(TagSuggestion(it.first, it.second))
}
}
}
}
}
}
fun setSuggestionProvider(suggestionProvider: SuggestionProvider) {
mSuggestionProvider = suggestionProvider
}