diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhTagDatabase.kt b/app/src/main/java/com/hippo/ehviewer/client/EhTagDatabase.kt index 30799a850..6d7f6b25c 100644 --- a/app/src/main/java/com/hippo/ehviewer/client/EhTagDatabase.kt +++ b/app/src/main/java/com/hippo/ehviewer/client/EhTagDatabase.kt @@ -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>? = 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 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> 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> suggest(String keyword, boolean translate) { - ArrayList> 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> = 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 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 = 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? { + 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(" ", "")); - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/SearchBarScene.kt b/app/src/main/java/com/hippo/ehviewer/ui/scene/SearchBarScene.kt index 42723372e..96efcf777 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/scene/SearchBarScene.kt +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/SearchBarScene.kt @@ -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() - 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() + 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 = 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 }