diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..71fe10d
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,27 @@
+# .github/workflows/publish.yml
+# from https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-publish-libraries.html#publish-to-maven-central-using-continuous-integration
+
+name: Publish
+on:
+ release:
+ types: [released, prereleased]
+jobs:
+ publish:
+ name: Release build and publish
+ runs-on: macOS-latest
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: 21
+ - name: Publish to MavenCentral
+ run: ./gradlew publishToMavenCentral --no-configuration-cache
+ env:
+ ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
+ ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY_CONTENTS }}
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dictionaries/fumiama.xml b/.idea/dictionaries/fumiama.xml
index 30df73b..1efdb65 100644
--- a/.idea/dictionaries/fumiama.xml
+++ b/.idea/dictionaries/fumiama.xml
@@ -1,7 +1,14 @@
+ eujuno
+ karakionisi
+ posena
+ rjimj
+ sdict
+ succ
+ zenbi
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index ae388c2..d8c7525 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,16 +4,16 @@
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 4097191..dfc8d66 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,9 +9,10 @@ android {
compileSdk 34
applicationId "top.fumiama.simpledict"
minSdkVersion 26
+ //noinspection OldTargetApi
targetSdkVersion 34
- versionCode 22
- versionName '5.0.2'
+ versionCode 23
+ versionName '5.1.0'
resConfigs "zh", "zh-rCN"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -44,14 +45,14 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.12.0'
- implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'com.google.android.material:material:1.10.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
- implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5'
- implementation 'androidx.navigation:navigation-ui-ktx:2.7.5'
+ implementation 'androidx.appcompat:appcompat:1.7.1'
+ implementation 'com.google.android.material:material:1.12.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
+ implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
+ implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
+ implementation project(':sdict')
testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- implementation 'com.lapism:search:2.4.1@aar'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
+ implementation files('libs/com.lapism/search-2.4.1.aar') // https://stackoverflow.com/a/63029110/28801553
}
\ No newline at end of file
diff --git a/app/libs/com.lapism/search-2.4.1.aar b/app/libs/com.lapism/search-2.4.1.aar
new file mode 100644
index 0000000..158ddca
Binary files /dev/null and b/app/libs/com.lapism/search-2.4.1.aar differ
diff --git a/app/src/androidTest/java/top/fumiama/simpledict/ExampleInstrumentedTest.kt b/app/src/androidTest/java/top/fumiama/simpledict/ExampleInstrumentedTest.kt
deleted file mode 100644
index c4365a1..0000000
--- a/app/src/androidTest/java/top/fumiama/simpledict/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package top.fumiama.simpledict
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("top.fumiama.simpledict", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/top/fumiama/simpledict/ByteArrayQueue.kt b/app/src/main/java/top/fumiama/simpledict/ByteArrayQueue.kt
deleted file mode 100644
index 3b4fe0a..0000000
--- a/app/src/main/java/top/fumiama/simpledict/ByteArrayQueue.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package top.fumiama.simpledict
-//Fumiama 20210601
-//ByteArrayQueue.kt
-//FIFO队列
-class ByteArrayQueue {
- private var elements = byteArrayOf()
- val size get() = elements.size
- fun append(items: ByteArray) {
- elements += items
- }
- fun pop(num: Int = 1): ByteArray? {
- return if(num <= elements.size) {
- val re = elements.copyOfRange(0, num)
- elements = elements.copyOfRange(num, elements.size)
- re
- } else null
- }
- fun clear() {
- elements = byteArrayOf()
- }
- fun popAll(): ByteArray {
- val re = elements
- clear()
- return re
- }
- operator fun plusAssign(items: ByteArray) = append(items)
-}
\ No newline at end of file
diff --git a/app/src/main/java/top/fumiama/simpledict/Client.kt b/app/src/main/java/top/fumiama/simpledict/Client.kt
deleted file mode 100644
index 30f5157..0000000
--- a/app/src/main/java/top/fumiama/simpledict/Client.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-package top.fumiama.simpledict
-//Fumiama 20210601
-//Client.kt
-import android.util.Log
-import top.fumiama.simpledict.Utils.toHexStr
-import java.io.*
-import java.lang.Thread.sleep
-import java.net.Socket
-
-class Client(private val ip: String, private val port: Int) {
- //普通数据交互接口
- private var sc: Socket? = null
-
- //普通交互流
- private var dout: OutputStream? = null
- private var din: InputStream? = null
-
- //已连接标记
- private val isConnect get() = sc != null && din != null && dout != null
-
- /**
- * 初始化普通交互连接
- */
- fun initConnect(depth: Int = 0): Boolean{
- if(depth > 3) Log.d("MyC", "connect server failed after $depth tries")
- else try {
- sc = Socket(ip, port) //通过socket连接服务器
- din = sc?.getInputStream() //获取输入流并转换为StreamReader,约定编码格式
- dout = sc?.getOutputStream() //获取输出流
- sc?.soTimeout = 10000 //设置连接超时限制
- return if (isConnect) {
- Log.d("MyC", "connect server successful")
- true
- } else {
- Log.d("MyC", "connect server failed, now retry...")
- initConnect(depth + 1)
- }
- } catch (e: IOException) { //获取输入输出流是可能报IOException的,所以必须try-catch
- e.printStackTrace()
- }
- return false
- }
-
- /**
- * 发送数据至服务器
- * @param message 要发送至服务器的字符串
- */
- fun sendMessage(message: String?): Boolean = sendMessage(message?.toByteArray())
-
- fun sendMessage(message: ByteArray?): Boolean {
- try {
- if (isConnect) {
- if (message != null) { //判断输出流或者消息是否为空,为空的话会产生null pointer错误
- dout?.write(message)
- dout?.flush()
- Log.d("MyC", "Send msg: ${toHexStr(message)}")
- return true
- } else Log.d("MyC", "The message to be sent is empty")
- Log.d("MyC", "send message succeed")
- } else Log.d("MyC", "send message failed: no connect")
- } catch (e: IOException) {
- Log.d("MyC", "send message failed: crash")
- e.printStackTrace()
- }
- return false
- }
-
- fun read(): Char? = din?.read()?.toChar()
-
- private var buffer = ByteArrayQueue()
- private val receiveBuffer = ByteArray(65536)
-
- fun receiveRawMessage(totalSize: Int, setProgress: Boolean = false) : ByteArray {
- if(totalSize == buffer.size) return buffer.popAll()
- else {
- try {
- if (isConnect) {
- Log.d("MyC", "开始接收服务端信息")
- while(totalSize > buffer.size) {
- val count = din?.read(receiveBuffer)?:0
- if(count > 0) {
- buffer += receiveBuffer.copyOfRange(0, count)
- Log.d("MyC", "reply length:$count")
- if(setProgress && totalSize > 0) progress?.notify(100 * buffer.size / totalSize)
- } else sleep(10)
- }
- } else Log.d("MyC", "no connect to receive message")
- } catch (e: IOException) {
- Log.d("MyC", "receive message failed")
- e.printStackTrace()
- }
- return if(totalSize > 0) buffer.pop(totalSize)?:byteArrayOf() else buffer.popAll()
- }
- }
-
- //fun receiveMessage(totalSize: Int) = receiveRawMessage(totalSize).decodeToString()
-
- /**
- * 关闭连接
- */
- fun closeConnect() = try {
- din?.close()
- dout?.close()
- sc?.close()
- sc = null
- din = null
- dout = null
- true
- } catch (e: IOException) {
- e.printStackTrace()
- false
- }
-
- var progress: Progress? = null
-
- interface Progress {
- fun notify(progressPercentage: Int)
- }
-}
diff --git a/app/src/main/java/top/fumiama/simpledict/CmdPacket.java b/app/src/main/java/top/fumiama/simpledict/CmdPacket.java
deleted file mode 100644
index 4ec44b6..0000000
--- a/app/src/main/java/top/fumiama/simpledict/CmdPacket.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package top.fumiama.simpledict;
-
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
-
-public class CmdPacket {
- private final byte cmd;
- private final byte[] data;
- private final byte[] md5;
- private final Tea t;
-
- public CmdPacket(byte cmd, @NonNull byte[] data, @NonNull Tea t) throws NoSuchAlgorithmException {
- this.cmd = cmd;
- this.data = data;
- this.t = t;
- md5 = MessageDigest.getInstance("MD5").digest(data);
- Log.d("MyCP", "md5: "+Utils.INSTANCE.toHexStr(md5));
- }
-
- public CmdPacket(@NonNull byte[] raw, @NonNull Tea t) {
- this.cmd = raw[0];
- this.t = t;
- md5 = new byte[16];
- Log.d("MyCP", "build from raw packet: "+Utils.INSTANCE.toHexStr(raw));
- System.arraycopy(raw, 2, md5, 0, 16);
- Log.d("MyCP", "md5: "+Utils.INSTANCE.toHexStr(md5));
- data = new byte[raw.length-1-1-16];
- System.arraycopy(raw, 1+1+16, data, 0, data.length);
- Log.d("MyCP", "data length: "+data.length);
- }
-
- public @NonNull byte[] encrypt(byte seq) {
- byte[] dat = t.encryptLittleEndian(data, seq);
- byte[] d = new byte[1+1+16+dat.length];
- d[0] = cmd;
- d[1] = (byte) dat.length;
- System.arraycopy(md5, 0, d, 2, 16);
- System.arraycopy(dat, 0, d, 1+1+16, dat.length);
- return d;
- }
-
- public byte[] decrypt(byte seq) throws NoSuchAlgorithmException {
- byte[] dat = t.decryptLittleEndian(data, seq);
- if (dat != null && Arrays.equals(MessageDigest.getInstance("MD5").digest(dat), md5)) {
- return dat;
- }
- return null;
- }
-
- public final static byte CMDGET = 0, CMDCAT = 1, CMDMD5 = 2, CMDACK = 3, CMDEND = 4, CMDSET = 5, CMDDEL = 6, CMDDAT = 7;
-}
diff --git a/app/src/main/java/top/fumiama/simpledict/MainActivity.kt b/app/src/main/java/top/fumiama/simpledict/MainActivity.kt
index cd78cd2..3646122 100644
--- a/app/src/main/java/top/fumiama/simpledict/MainActivity.kt
+++ b/app/src/main/java/top/fumiama/simpledict/MainActivity.kt
@@ -23,17 +23,31 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import androidx.core.view.children
import androidx.fragment.app.commit
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager.widget.ViewPager
import com.lapism.search.internal.SearchLayout
-import kotlinx.android.synthetic.main.activity_main.*
-import kotlinx.android.synthetic.main.activity_main.view.*
+import kotlinx.android.synthetic.main.activity_main.cctrl
+import kotlinx.android.synthetic.main.activity_main.ffms
+import kotlinx.android.synthetic.main.activity_main.ffsw
import kotlinx.android.synthetic.main.card_bottom.cbcard
-import kotlinx.android.synthetic.main.dialog_input.view.*
+import kotlinx.android.synthetic.main.dialog_input.view.diet
+import kotlinx.android.synthetic.main.dialog_input.view.dis
+import kotlinx.android.synthetic.main.dialog_input.view.dit
import kotlinx.android.synthetic.main.fragment_main.fmvp
-import kotlinx.android.synthetic.main.line_bottom.view.*
-import kotlinx.android.synthetic.main.line_word.view.*
+import kotlinx.android.synthetic.main.line_bottom.view.lbtindex
+import kotlinx.android.synthetic.main.line_bottom.view.lbttotal
+import kotlinx.android.synthetic.main.line_bottom.view.sb
+import kotlinx.android.synthetic.main.line_word.view.ta
+import kotlinx.android.synthetic.main.line_word.view.tb
+import kotlinx.android.synthetic.main.line_word.view.tn
+import kotlinx.android.synthetic.main.line_word.view.vl
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import top.fumiama.sdict.io.Client
+import top.fumiama.sdict.SimpleDict
import java.io.FileNotFoundException
class MainActivity : AppCompatActivity() {
@@ -42,7 +56,7 @@ class MainActivity : AppCompatActivity() {
private var port = 80
private var pwd = "demo"
private var spwd: String? = null
- private var dict: SimpleDict? = null
+ private val dict: SimpleDict by lazy { SimpleDict(Client(host, port), pwd, externalCacheDir, spwd) }
private var cm: ClipboardManager? = null
private var noShowNisi = false
private var mViewPagerPosition = 0
@@ -61,8 +75,7 @@ class MainActivity : AppCompatActivity() {
if(contains("spwd")) getString("spwd", spwd)?.apply { spwd = this }
if(contains("noNisi")) getBoolean("noNisi", noShowNisi).apply { noShowNisi = this }
}
- Log.d("MyMain", "noNisi: $noShowNisi")
- dict = SimpleDict(Client(host, port), pwd, externalCacheDir, spwd)
+ Log.d("MyMain", "server: $host:$port, noNisi: $noShowNisi")
cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@@ -77,13 +90,17 @@ class MainActivity : AppCompatActivity() {
ffsw.apply {
setOnRefreshListener {
- fetchThread {
- updateSize()
+ lifecycleScope.launch {
+ fetch {
+ updateSize()
+ }
}
}
isRefreshing = true
- fetchThread {
- updateSize()
+ lifecycleScope.launch {
+ fetch {
+ updateSize()
+ }
}
}
@@ -98,8 +115,10 @@ class MainActivity : AppCompatActivity() {
val a = lm.findFirstVisibleItemPosition()
val b = lm.findLastVisibleItemPosition()
val total = lm.itemCount
- if(a <= 0) adapter.scrollUp(1)
- else if(b >= total-1) adapter.scrollDown(1)
+ lifecycleScope.launch {
+ if(a <= 0) adapter.scrollUp(1)
+ else if(b >= total-1) adapter.scrollDown(1)
+ }
}
})
setAdapter(adapter)
@@ -130,8 +149,7 @@ class MainActivity : AppCompatActivity() {
override fun onQueryTextSubmit(query: CharSequence): Boolean {
if(query.isNotEmpty()) {
val key = query.toString()
- val data = dict?.get(key)
- showDictAlert(key, data, recyclerView.children.toList().let { children ->
+ showDictAlert(key, dict[key], recyclerView.children.toList().let { children ->
val i = children.map { it.ta.text }.indexOf(key)
if(i >= 0) children[i] else null
})
@@ -207,7 +225,7 @@ class MainActivity : AppCompatActivity() {
if(isSeeking) {
val bar = mControlBarStates[mViewPagerPosition]
bar.index = bar.getPosition(p)
- updateSize(false)
+ lifecycleScope.launch { updateSize(false) }
}
}
@@ -221,7 +239,7 @@ class MainActivity : AppCompatActivity() {
Log.d("MyMain", "onStopTrackingTouch")
s?.progress?.let {
val ad = mVPAdapter.views[mViewPagerPosition]?.recyclerView?.adapter as? ListViewHolder.RecyclerViewAdapter ?: return
- ad.setProgress(it)
+ lifecycleScope.launch { ad.setProgress(it) }
}
}
})
@@ -252,7 +270,7 @@ class MainActivity : AppCompatActivity() {
}
}
- private fun updateSize(updateSeekbar: Boolean = true) = runOnUiThread {
+ private suspend fun updateSize(updateSeekbar: Boolean = true) = withContext(Dispatchers.Main) {
Log.d("MyMain", "update size, updateSeekbar: $updateSeekbar")
val bar = mControlBarStates[mViewPagerPosition]
cctrl?.lbtindex?.text = bar.formatRange(getString(R.string.info_index_meter))
@@ -260,25 +278,25 @@ class MainActivity : AppCompatActivity() {
if (updateSeekbar) cctrl?.sb?.progress = bar.getPercentage()
}
- private fun fetchThread(doWhenFinish: (()->Unit)? = null) {
- Thread{
- dict?.fetchDict({
- runOnUiThread {
+ private suspend fun fetch(doWhenFinish: (suspend ()->Unit)? = null) {
+ withContext(Dispatchers.IO) {
+ dict.fetch({
+ withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, R.string.toast_refresh_failed, Toast.LENGTH_SHORT).show()
}
}, {
- runOnUiThread {
+ withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, R.string.toast_refresh_succeeded, Toast.LENGTH_SHORT).show()
}
}) {
- runOnUiThread {
+ withContext(Dispatchers.Main) {
ffsw.isRefreshing = false
(mVPAdapter.views[mViewPagerPosition]?.recyclerView?.adapter as? ListViewHolder.RecyclerViewAdapter)?.refresh()
updateSize()
- doWhenFinish?.apply { this() }
+ doWhenFinish?.invoke()
}
}
- }.start()
+ }
}
private fun showDictAlert(key: String, data: String?, line: View?) {
@@ -296,34 +314,40 @@ class MainActivity : AppCompatActivity() {
.setView(t)
.setPositiveButton(android.R.string.ok) { _, _ ->
val newText = t.diet.text.toString().trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() }
- if (t.diet.text.isNotEmpty() && newText != data) Thread {
- val k = key.trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() }
- if(dict?.set(k, newText) == true) {
- line?.tb?.text = newText
- } else runOnUiThread {
- Toast.makeText(this, R.string.toast_failed, Toast.LENGTH_SHORT).show()
+ if (t.diet.text.isNotEmpty() && newText != data) lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ val k = key.trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() }
+ if(dict.set(k, newText)) withContext(Dispatchers.Main) {
+ line?.tb?.text = newText
+ } else withContext(Dispatchers.Main) {
+ Toast.makeText(this@MainActivity, R.string.toast_failed, Toast.LENGTH_SHORT).show()
+ }
}
- }.start()
+ }
else Toast.makeText(this, R.string.toast_unchanged, Toast.LENGTH_SHORT).show()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
.setNeutralButton(R.string.alert_word_button_delete) { _, _ ->
- Thread{
- if(dict?.del(key) == true) line?.apply {
- val delKey = SpannableString(key)
- val delData = SpannableString(data)
- delKey.setSpan(StrikethroughSpan(), 0, key.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
- delData.setSpan(StrikethroughSpan(), 0, (data?.length?:0), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
- ta.text = delKey
- tn.text = delKey
- tb.text = delData
+ lifecycleScope.launch{
+ withContext(Dispatchers.IO) {
+ if(dict.del(key)) line?.apply {
+ val delKey = SpannableString(key)
+ val delData = SpannableString(data)
+ delKey.setSpan(StrikethroughSpan(), 0, key.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ delData.setSpan(StrikethroughSpan(), 0, (data?.length?:0), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ withContext(Dispatchers.Main) {
+ ta.text = delKey
+ tn.text = delKey
+ tb.text = delData
+ }
+ }
+ else withContext(Dispatchers.Main) {
+ Toast.makeText(this@MainActivity, R.string.toast_failed, Toast.LENGTH_SHORT).show()
+ }
}
- else runOnUiThread {
- Toast.makeText(this, R.string.toast_failed, Toast.LENGTH_SHORT).show()
- }
- }.start()
+ }
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
@@ -354,21 +378,21 @@ class MainActivity : AppCompatActivity() {
inner class SearchViewHolder(itemView: View) : ListViewHolder(itemView) {
inner class RecyclerViewAdapter : ListViewHolder.RecyclerViewAdapter(visibleThreshold) {
override fun getKeys(filterText: CharSequence?) = filterText?.let { filter(it) }
- override fun getValue(key: String) = dict?.get(key)
+ override fun getValue(key: String) = dict[key]
private fun filter(text: CharSequence): List {
- return dict?.keys?.filter {
- it.contains(text, true)
- }?.toSet()?.plus(
- dict?.filterValues {
+ return dict.keys.filter {
+ it.contains(text, true)
+ }.toSet().plus(
+ dict.filterValues {
it?.contains(text, true) ?: false
}.let {
val newSet = mutableSetOf()
- it?.keys?.forEach { k ->
+ it.keys.forEach { k ->
newSet += k
}
newSet
}
- )?.toList()?: emptyList()
+ ).toList()
}
}
}
@@ -383,7 +407,7 @@ class MainActivity : AppCompatActivity() {
bar.sort(keys.toList())
}
}
- else dict?.latestKeys?.let { keys ->
+ else dict.latestKeys.let { keys ->
Log.d("MyMain", "LikeViewHolder getKeys all, set size: ${keys.size}")
mControlBarStates[0].let { bar ->
bar.total = keys.size
@@ -391,7 +415,7 @@ class MainActivity : AppCompatActivity() {
}
}
)?: emptyList()
- override fun getValue(key: String) = dict?.get(key)?:dictPreferences?.getString(key, "null")?:"N/A"
+ override fun getValue(key: String) = dict[key] ?:dictPreferences?.getString(key, "null")?:"N/A"
}
}
@@ -417,15 +441,15 @@ class MainActivity : AppCompatActivity() {
override fun onBindViewHolder(holder: ListViewHolder, p: Int) {
val position = p + index
Log.d("MyMain", "Bind open at $p($position)")
- Thread{
+ lifecycleScope.launch { withContext(Dispatchers.IO) {
listKeys?.apply {
- if (position >= size) return@Thread
+ if (position >= size) return@withContext
val key = get(position)
val data = getValue(key)
val like = dictPreferences?.contains(key) == true
//Log.d("MyMain", "Like status of $key is $like")
- holder.itemView.apply {
- runOnUiThread {
+ holder.itemView.apply line@ {
+ withContext(Dispatchers.Main) {
if (!noShowNisi) {
tn.visibility = View.VISIBLE
tn.text = key
@@ -435,13 +459,11 @@ class MainActivity : AppCompatActivity() {
vl.setBackgroundResource(if(like) R.drawable.ic_like_filled else R.drawable.ic_like)
//Log.d("MyMain", "Set like of $key: $like")
setOnClickListener {
- showDictAlert(key, data, this)
+ showDictAlert(key, data, this@line)
}
setOnLongClickListener {
cm?.setPrimaryClip(ClipData.newPlainText("SimpleDict", "$key\n$data"))
- runOnUiThread {
- Toast.makeText(this@MainActivity, R.string.toast_copied, Toast.LENGTH_SHORT).show()
- }
+ Toast.makeText(this@MainActivity, R.string.toast_copied, Toast.LENGTH_SHORT).show()
true
}
vl.setOnClickListener {
@@ -464,7 +486,7 @@ class MainActivity : AppCompatActivity() {
if(p >= itemCount-1) scrollDown(if(p < renderLinesCount) 4 else 1)
else if(p <= 1) scrollUp(if(p < renderLinesCount) 4 else 1)
}
- }.start()
+ } }
}
override fun getItemCount() = (listKeys?.size?:0).let { if(it > renderLinesCount) renderLinesCount else it }
@@ -478,7 +500,7 @@ class MainActivity : AppCompatActivity() {
}
@SuppressLint("NotifyDataSetChanged")
- fun scrollDown(n: Int) {
+ suspend fun scrollDown(n: Int) {
if((listKeys?.size ?: 0) <= renderLinesCount) return
val oldIndex = index
val nextIndex = if(oldIndex + n + renderLinesCount > (listKeys?.size ?: 0)) (listKeys?.size ?: 0) - renderLinesCount else oldIndex + n
@@ -486,7 +508,7 @@ class MainActivity : AppCompatActivity() {
if(nextIndex < 0) return
index = nextIndex
if(n >= renderLinesCount) {
- runOnUiThread { notifyDataSetChanged() }
+ withContext(Dispatchers.Main) { notifyDataSetChanged() }
return
}
// index next index
@@ -495,25 +517,25 @@ class MainActivity : AppCompatActivity() {
// ---remain--- ↑
// ----delete---- → → → → → ↗
val insert = nextIndex - oldIndex
- runOnUiThread {
+ withContext(Dispatchers.Main) {
notifyItemRangeInserted(renderLinesCount, insert)
notifyItemRangeRemoved(0, insert)
}
}
@SuppressLint("NotifyDataSetChanged")
- fun scrollUp(n: Int) {
+ suspend fun scrollUp(n: Int) {
if((listKeys?.size ?: 0) <= renderLinesCount) return
val oldIndex = index
val nextIndex = if(oldIndex-n >= 0) oldIndex-n else 0
if(oldIndex == nextIndex) return
index = nextIndex
if(n >= renderLinesCount) {
- runOnUiThread { notifyDataSetChanged() }
+ withContext(Dispatchers.Main) { notifyDataSetChanged() }
return
}
val insert = oldIndex - nextIndex
- runOnUiThread {
+ withContext(Dispatchers.Main) {
notifyItemRangeInserted(0, insert)
notifyItemRangeRemoved(renderLinesCount, insert)
}
@@ -522,7 +544,7 @@ class MainActivity : AppCompatActivity() {
fun getPosition() = index
@SuppressLint("NotifyDataSetChanged")
- fun setProgress(p: Int) {
+ suspend fun setProgress(p: Int) {
if(p > 100 || p < 0) return
var newIndex = p * (listKeys?.size?:0) / 100
if(newIndex + renderLinesCount > (listKeys?.size?:0)) {
@@ -534,7 +556,7 @@ class MainActivity : AppCompatActivity() {
val n = newIndex - oldIndex
if(n >= renderLinesCount || n <= -renderLinesCount) {
index = newIndex
- runOnUiThread { notifyDataSetChanged() }
+ withContext(Dispatchers.Main) { notifyDataSetChanged() }
return
}
if(n > 0) scrollDown(n)
@@ -550,7 +572,7 @@ class MainActivity : AppCompatActivity() {
if(ad?.hasRefreshed == false) {
ad.refresh()
}
- updateSize()
+ lifecycleScope.launch { updateSize() }
}
override fun onPageScrollStateChanged(state: Int) {
@@ -583,7 +605,7 @@ class MainActivity : AppCompatActivity() {
Log.d("MyMain", "new start: $newStart, index: ${bar.index}")
if (newStart != bar.index) {
bar.index = newStart
- updateSize()
+ lifecycleScope.launch { updateSize() }
}
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
@@ -593,8 +615,10 @@ class MainActivity : AppCompatActivity() {
Log.d("MyMain", "new scroll state: $newState, a: $a, b: $b")
this@MainActivity.ffsw.isEnabled = newState == 0 && a == 0
val total = lm.itemCount
- if(a <= 0) ad.scrollUp(1)
- else if(b >= total-1) ad.scrollDown(1)
+ lifecycleScope.launch {
+ if(a <= 0) ad.scrollUp(1)
+ else if(b >= total-1) ad.scrollDown(1)
+ }
}
})
}
diff --git a/app/src/main/java/top/fumiama/simpledict/SimpleDict.kt b/app/src/main/java/top/fumiama/simpledict/SimpleDict.kt
deleted file mode 100644
index e038149..0000000
--- a/app/src/main/java/top/fumiama/simpledict/SimpleDict.kt
+++ /dev/null
@@ -1,170 +0,0 @@
-package top.fumiama.simpledict
-
-import android.util.Log
-import java.io.File
-import java.lang.Thread.sleep
-import java.security.MessageDigest
-
-class SimpleDict(private val client: Client, pwd: String, private val externalCacheDir: File?, spwd: String?) { //must run in thread
- private var dict = HashMap()
- val size get() = dict.size
- val keys get() = dict.keys
- var latestKeys = arrayOf()
- private var seq: Byte = 0
- private val ptea = Tea(pwd.toByteArray())
- private val stea = spwd?.let { Tea(it.toByteArray()) }
- private val md5File = File(externalCacheDir, "md5")
- private val dspFile = File(externalCacheDir, "dsp")
- private val filler = "fill".toByteArray()
- private val raw: ByteArray?
- get() {
- var times = 3
- var re: ByteArray? = null
- var exit = false
- while(times-- > 0 && !exit) {
- if(initDict()) {
- client.sendMessage(CmdPacket(CmdPacket.CMDCAT, filler, ptea).encrypt(seq))
- try {
- var length = ""
- var c = client.read()
- while (c?.isDigit() == true) {
- length += c
- c = client.read()
- }
- Log.d("MySD", "length: $length")
- re = ptea.decryptLittleEndian(client.receiveRawMessage(length.toInt()), (seq+1).toByte())
- if(re != null) seq = (seq + 2).toByte()
- exit = true
- } catch (e: Exception){
- e.printStackTrace()
- }
- closeDict()
- } else sleep(233)
- }
- return re
- }
- private val ack: ByteArray?
- get() {
- var re = client.receiveRawMessage(1+1+16)
- re += client.receiveRawMessage(re[1].toInt())
- val r = CmdPacket(re, ptea).decrypt(seq)
- if (r != null) seq++
- Log.d("MySD", "ack: ${r?.decodeToString()}")
- return r
- }
-
- private fun initDict() = client.initConnect()
-
- private fun closeDict(): Boolean {
- client.sendMessage(CmdPacket(CmdPacket.CMDEND, filler, ptea).encrypt(seq))
- seq = 0
- return client.closeConnect()
- }
-
- private fun saveDict(data: ByteArray) {
- if(externalCacheDir?.exists() != true) externalCacheDir?.mkdirs()
- if(externalCacheDir?.exists() == true) {
- dspFile.writeBytes(data)
- md5File.writeBytes(MessageDigest.getInstance("md5").digest(data))
- }
- }
-
- private fun hasNewItem(md5: ByteArray): Boolean =
- if(initDict()) {
- client.sendMessage(CmdPacket(CmdPacket.CMDMD5, md5, ptea).encrypt(seq++))
- val cp = ack
- Log.d("MySD", "Check md5: ${cp?.decodeToString()}")
- closeDict()
- cp?.decodeToString() == "nequ"
- } else false
-
- private fun analyzeDict(datas: ByteArray, saveDict: Boolean) {
- SimpleProtobuf.getDictArray(datas).forEach { d ->
- d?.apply {
- val k = key.decodeToString()
- if(saveDict) {
- if(k.toByteArray().contentEquals(key)) {
- dict[k] = data.decodeToString()
- latestKeys += k
- } else {
- sendel(key) // 去错
- }
- } else if(!dict.containsKey(k)){
- dict[k] = data.decodeToString()
- latestKeys += k
- } else {
- sendel(key) // 去重
- }
- }
- }
- if(saveDict) saveDict(datas)
- }
-
- fun filterValues(predicate: (String?) -> Boolean) = dict.filterValues(predicate)
-
- fun fetchDict(doOnLoadFailure: ()->Unit, doOnLoadSuccess: ()->Unit, doCommon: (() -> Unit)? = null) {
- val noChange = md5File.exists() && dspFile.exists() && !hasNewItem(md5File.readBytes())
- val data = if(noChange) dspFile.readBytes() else raw
- dict.clear()
- latestKeys = arrayOf()
- if(data == null) doOnLoadFailure()
- else {
- analyzeDict(data, !noChange)
- doOnLoadSuccess()
- }
- doCommon?.let { it() }
- }
-
- fun del(key: String): Boolean {
- if(stea == null) return false
- else if(initDict()) {
- client.sendMessage(CmdPacket(CmdPacket.CMDDEL, key.toByteArray(), stea).encrypt(seq++))
- if(ack?.decodeToString() == "succ") {
- if(closeDict()) {
- dict.remove(key)
- val end = latestKeys.size-1
- if(end > 0) latestKeys = latestKeys.let { oldArr ->
- var index = -1
- Array(end) {
- if(oldArr[it] == key) index = it
- return@Array if(index < 0 || (index > 0 && it < index)) oldArr[it] else oldArr[it+1]
- }
- }
- return true
- }
- } else closeDict()
- }
- return false
- }
-
- private fun sendel(key: ByteArray): Boolean {
- if(stea == null) return false
- else if(initDict()) {
- client.sendMessage(CmdPacket(CmdPacket.CMDDEL, key, stea).encrypt(seq++))
- if(ack?.decodeToString() == "succ") {
- return closeDict()
- } else closeDict()
- }
- return false
- }
-
- operator fun get(key: String) = dict[key]
-
- fun set(key: String, value: String): Boolean {
- //if(spwd == null) return false
- if(stea == null) return false
- val contain = dict.containsKey(key)
- if((contain && sendel(key.toByteArray())) || !contain) {
- if(initDict()) {
- client.sendMessage(CmdPacket(CmdPacket.CMDSET, key.toByteArray(), stea).encrypt(seq++))
- if(ack?.decodeToString() == "data") {
- client.sendMessage(CmdPacket(CmdPacket.CMDDAT, value.toByteArray(), stea).encrypt(seq++))
- val s = ack?.decodeToString() == "succ"
- if(s) dict[key] = value
- return closeDict() && s
- } else closeDict()
- }
- return false
- } else return false
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/top/fumiama/simpledict/SimpleProtobuf.java b/app/src/main/java/top/fumiama/simpledict/SimpleProtobuf.java
deleted file mode 100644
index f0671de..0000000
--- a/app/src/main/java/top/fumiama/simpledict/SimpleProtobuf.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package top.fumiama.simpledict;
-
-import org.jetbrains.annotations.NotNull;
-import java.util.Stack;
-
-public class SimpleProtobuf {
- public static class Dict {
- public byte[] key;
- public byte[] data;
- }
-
- private static final DictStack ds = new DictStack();
-
- public static Dict[] getDictArray(@NotNull byte[] raw) {
- int offset = 0;
- SLLE s;
- while (offset < raw.length) {
- offset += getSLLE(raw, offset).len; //struct_len
- offset += getSLLE(raw, offset).len; //type
- s = getSLLE(raw, offset); //data len
- //Log.d("MySPB", "Data len:" + s.value);
- Dict d = new Dict();
- d.key = new byte[s.value];
- offset += s.len;
- System.arraycopy(raw, offset, d.key, 0, s.value);
- offset += s.value;
- offset += getSLLE(raw, offset).len; //type
- s = getSLLE(raw, offset); //data len
- //Log.d("MySPB", "Data len:" + s.value);
- d.data = new byte[s.value];
- offset += s.len;
- System.arraycopy(raw, offset, d.data, 0, s.value);
- offset += s.value;
- ds.push(d);
- }
- return ds.popAllData();
- }
-
- @NotNull
- private static SLLE getSLLE(byte[] p, int start) {
- SLLE s = new SLLE();
- s.value = 0;
- for (int i = 0; i < 4; i++) {
- s.value += (p[start + i] & 0x7f) << (i * 7);
- if ((p[start + i] & 0x80) == 0) { //无更高位
- s.len = i + 1;
- break;
- }
- }
- return s;
- }
-
- private static class SLLE {
- int value;
- int len;
- }
-
- private static class DictStack extends PopAllStack {
- public Dict[] popAllData() {
- Object[] t = popAll();
- if (t != null) {
- Dict[] d = new Dict[t.length];
- for (int i = 0; i < t.length; i++) {
- d[i] = (Dict) t[i];
- }
- return d;
- } else return null;
- }
- }
-
- private static class PopAllStack extends Stack {
- public Object[] popAll() {
- if (size() > 0) {
- Object[] t = new Object[size()];
- System.arraycopy(elementData, 0, t, 0, size());
- setSize(0);
- return t;
- } else return null;
- }
- }
-}
diff --git a/app/src/main/java/top/fumiama/simpledict/Tea.java b/app/src/main/java/top/fumiama/simpledict/Tea.java
deleted file mode 100644
index 7d207f8..0000000
--- a/app/src/main/java/top/fumiama/simpledict/Tea.java
+++ /dev/null
@@ -1,126 +0,0 @@
-package top.fumiama.simpledict;
-
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.util.Random;
-
-public class Tea {
- private final int[] t = new int[4];
- private final Random r;
- public Tea(@NonNull byte[] tea) {
- byte[] tea16 = new byte[16];
- System.arraycopy(tea, 0, tea16, 0, Math.min(tea.length, 15));
- tea16[15] = 0;
- ByteBuffer bf = ByteBuffer.wrap(tea16).order(ByteOrder.LITTLE_ENDIAN);
- t[0] = bf.getInt(0);
- t[1] = bf.getInt(4);
- t[2] = bf.getInt(8);
- t[3] = bf.getInt(12) & 0x00ffffff;
- r = new Random();
- //Log.d("MyTEA", "t: "+ Arrays.toString(t));
- }
-
- public @NonNull byte[] encryptLittleEndian(@NonNull byte[] src, byte seq) {
- int lens = src.length;
- int fill = 10 - (lens+1)%8;
- int dstlen = fill+lens+7;
- byte[] dst = new byte[dstlen];
- byte[] randfill = new byte[fill-1];
- t[3] = ((int)seq)<<24 | (t[3]&0x00ffffff);
- Log.d("MyTEA", "encrypt seq: "+ seq);
- r.nextBytes(randfill);
- //Log.d("MyTEA", "rand fill: "+ Utils.INSTANCE.toHexStr(randfill));
- System.arraycopy(randfill, 0, dst, 1, fill-1);
- dst[0] = (byte)((fill-3)|0xF8); // 存储pad长度
- System.arraycopy(src, 0, dst, fill, lens);
- //Log.d("MyTEA", "dst before enc: "+Utils.INSTANCE.toHexStr(dst));
-
- long iv1 = 0, iv2 = 0, holder;
- ByteBuffer bf = ByteBuffer.wrap(dst).order(ByteOrder.LITTLE_ENDIAN);
- for(int i = 0; i < dstlen; i += 8) {
- long block = bf.getLong(i);
- holder = block ^ iv1;
-
- int v0 = (int)(holder>>32);
- int v1 = (int)holder;
- for (int j = 0; j < 0x10; j++) {
- v0 += (v1 + sumtable[j]) ^ ((int)(((long) v1 << 4)&0x00000000fffffff0L) + t[0]) ^ ((int)((v1 >> 5)&0x07ffffff) + t[1]);
- v1 += (v0 + sumtable[j]) ^ ((int)(((long) v0 << 4)&0x00000000fffffff0L) + t[2]) ^ ((int)((v0 >> 5)&0x07ffffff) + t[3]);
- }
- //Log.d("MyTEA", "v0: "+Integer.toHexString(v0)+", v1: "+Integer.toHexString(v1));
- iv1 = (((long)v0)<<32) | (((long)v1)&0x00000000ffffffffL);
- //Log.d("MyTEA", "iv1: "+Long.toHexString(iv1));
-
- iv1 = iv1 ^ iv2;
- iv2 = holder;
- //Log.d("MyTEA", "put: "+Long.toHexString(iv1));
- bf.putLong(i, iv1);
- }
-
- //Log.d("MyTEA", "dst after enc: "+Utils.INSTANCE.toHexStr(dst));
- return dst;
- }
-
- public byte[] decryptLittleEndian(@NonNull byte[] src, byte seq) {
- if (src.length < 16 || (src.length)%8 != 0) {
- return null;
- }
- byte[] dst = new byte[src.length];
-
- long iv1, iv2 = 0, holder = 0;
- t[3] = ((int)seq)<<24 | (t[3]&0x00ffffff);
- Log.d("MyTEA", "decrypt seq: "+ seq);
- ByteBuffer sbf = ByteBuffer.wrap(src).order(ByteOrder.LITTLE_ENDIAN);
- ByteBuffer dbf = ByteBuffer.wrap(dst).order(ByteOrder.LITTLE_ENDIAN);
- for(int i = 0; i < src.length; i += 8) {
- iv1 = sbf.getLong(i);
-
- iv2 ^= iv1;
-
- int v0 = (int)(iv2>>32);
- int v1 = (int)iv2;
- for (int j = 0x0f; j >= 0; j--) {
- v1 -= (v0 + sumtable[j]) ^ ((int)(((long) v0 << 4)&0x00000000fffffff0L) + t[2]) ^ ((int)((v0 >> 5)&0x07ffffff) + t[3]);
- v0 -= (v1 + sumtable[j]) ^ ((int)(((long) v1 << 4)&0x00000000fffffff0L) + t[0]) ^ ((int)((v1 >> 5)&0x07ffffff) + t[1]);
- }
- iv2 = (((long)v0)<<32) | (((long)v1)&0x00000000ffffffffL);
-
- dbf.putLong(i, iv2^holder);
-
- holder = iv1;
- }
-
- int start = (dst[0]&7)+3;
- Log.d("MyTEA", "decrypt start: "+ start);
- int datlen = src.length-7-start;
- if(datlen <= 0) return null;
- byte[] dat = new byte[datlen];
- Log.d("MyTEA", "decrypt data length: "+datlen);
- System.arraycopy(dst, start, dat, 0, datlen);
- return dat;
- }
-
- // TEA encoding sumtable
- private static final int[] sumtable = {
- 0x9e3579b9,
- 0x3c6ef172,
- 0xd2a66d2b,
- 0x78dd36e4,
- 0x17e5609d,
- 0xb54fda56,
- 0x5384560f,
- 0xf1bb77c8,
- 0x8ff24781,
- 0x2e4ac13a,
- 0xcc653af3,
- 0x6a9964ac,
- 0x08d12965,
- 0xa708081e,
- 0x451221d7,
- 0xe37793d0,
- };
-}
diff --git a/app/src/main/java/top/fumiama/simpledict/Utils.kt b/app/src/main/java/top/fumiama/simpledict/Utils.kt
deleted file mode 100644
index cddc50d..0000000
--- a/app/src/main/java/top/fumiama/simpledict/Utils.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package top.fumiama.simpledict
-
-object Utils {
- fun toHexStr(byteArray: ByteArray) =
- with(StringBuilder()) {
- byteArray.forEach {
- val hex = it.toInt() and (0xFF)
- val hexStr = Integer.toHexString(hex)
- if (hexStr.length == 1) append("0").append(hexStr)
- else append(hexStr)
- }
- toString()
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/top/fumiama/simpledict/ExampleUnitTest.kt b/app/src/test/java/top/fumiama/simpledict/ExampleUnitTest.kt
deleted file mode 100644
index ffbb95f..0000000
--- a/app/src/test/java/top/fumiama/simpledict/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package top.fumiama.simpledict
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 652c379..0830f17 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,16 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = '1.7.10'
+ ext.kotlin_version = "$cm_kotlin_version"
repositories {
google()
- jcenter()
- mavenCentral()
mavenCentral()
maven { url 'https://maven.google.com' }
maven { url "https://jitpack.io" }
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.1.4'
+ classpath 'com.android.tools.build:gradle:8.3.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@@ -21,12 +19,11 @@ buildscript {
allprojects {
repositories {
google()
- jcenter()
mavenCentral()
maven { url "https://jitpack.io" }
}
}
-task clean(type: Delete) {
+tasks.register('clean', Delete) {
delete rootProject.buildDir
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 56f00af..5df869d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -19,4 +19,5 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-android.enableR8.fullMode=true
\ No newline at end of file
+android.enableR8.fullMode=true
+cm_kotlin_version=1.7.10
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 9802d6d..00d1857 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,5 +3,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
diff --git a/sdict/.gitignore b/sdict/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/sdict/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/sdict/build.gradle.kts b/sdict/build.gradle.kts
new file mode 100644
index 0000000..b74477a
--- /dev/null
+++ b/sdict/build.gradle.kts
@@ -0,0 +1,78 @@
+import com.vanniktech.maven.publish.SonatypeHost
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+
+ id("com.vanniktech.maven.publish") version "0.29.0"
+}
+
+android {
+ namespace = "top.fumiama.sdict"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 23
+
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ group = "top.fumiama"
+ version = "0.1.0"
+
+ mavenPublishing {
+ publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
+
+ signAllPublications()
+
+ coordinates(group.toString(), "sdict", version.toString())
+
+ pom {
+ name = "SimpleDict Library"
+ description = "A simple protocal database[\"key\"]=\"value\" with tea encryption."
+ inceptionYear = "2025"
+ url = "https://github.com/fumiama/simple-dict-android"
+ licenses {
+ license {
+ name = "GNU General Public License v3.0"
+ url = "https://www.gnu.org/licenses/gpl-3.0.txt"
+ distribution = "https://www.gnu.org/licenses/gpl-3.0.txt"
+ }
+ }
+ developers {
+ developer {
+ id = "fumiama"
+ name = "源文雨"
+ url = "https://github.com/fumiama"
+ }
+ }
+ scm {
+ url = "https://github.com/fumiama/simple-dict-android"
+ connection = "scm:git:git://github.com/fumiama/simple-dict-android.git"
+ developerConnection = "scm:git:ssh://git@github.com/fumiama/simple-dict-android.git"
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.22"))
+}
\ No newline at end of file
diff --git a/sdict/consumer-rules.pro b/sdict/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/sdict/proguard-rules.pro b/sdict/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/sdict/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/sdict/src/main/AndroidManifest.xml b/sdict/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..44008a4
--- /dev/null
+++ b/sdict/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt b/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt
new file mode 100644
index 0000000..d33a49d
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt
@@ -0,0 +1,321 @@
+/*
+ * SimpleDict.kt
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict
+
+import java.io.File
+import java.lang.Thread.sleep
+import java.security.MessageDigest
+
+import android.util.Log
+
+import top.fumiama.sdict.io.Client
+import top.fumiama.sdict.protocol.CmdPacket
+import top.fumiama.sdict.protocol.SimpleProtobuf
+import top.fumiama.sdict.protocol.Tea
+
+/**
+ * A high-level dictionary manager that communicates with a remote server over a custom protocol via [Client].
+ *
+ * This class supports fetching, storing, deleting, and checking remote dictionary entries. It maintains a local cache,
+ * synchronizes updates, and verifies integrity using MD5.
+ *
+ * @param client the network client used to communicate with the remote dictionary service
+ * @param password used to encrypt/decrypt data (query operations)
+ * @param externalCacheDir directory used to store persistent cache files
+ * @param setPassword optional password used for modifying the dictionary (set/delete)
+ *
+ * **NOTE:** All operations are blocking and must be run in a background thread.
+ */
+class SimpleDict(
+ private val client: Client,
+ password: String,
+ private val externalCacheDir: File?,
+ setPassword: String?
+) {
+ /** In-memory map of the dictionary data. */
+ private var dict = HashMap()
+
+ /** Number of keys in the dictionary. */
+ val size get() = dict.size
+
+ /** All keys in the dictionary. */
+ val keys get() = dict.keys
+
+ /** Keys by last-update-time order. */
+ var latestKeys = arrayOf()
+
+ /** Current TEA encryption sequence number. */
+ private var seq: Byte = 0
+
+ /** TEA cipher for read-only operations. */
+ private val teaPassword = Tea(password.toByteArray())
+
+ /** TEA cipher for modification operations. May be null if not permitted. */
+ private val teaSetPassword = setPassword?.let { Tea(it.toByteArray()) }
+
+ /** Cache file storing the latest simple-protobuf data. */
+ private val dspFile = File(externalCacheDir, "dsp")
+
+ /** Cache file storing the MD5 of the simple-protobuf data snapshot. */
+ private val md5File = File(externalCacheDir, "md5")
+
+ /** Dummy payload used when sending control packets. */
+ private val filler = "fill".toByteArray()
+
+ /**
+ * Retrieves and decrypts the dictionary snapshot from the server.
+ * Retries up to 3 times on failure. Sequence is incremented by 2 if successful.
+ */
+ private val raw: ByteArray?
+ get() {
+ var times = 3
+ var re: ByteArray? = null
+ var exit = false
+ while (times-- > 0 && !exit) {
+ if (initDict()) {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_CAT, filler, teaPassword).encrypt(seq)
+ )
+ try {
+ var length = ""
+ var c = client.read()
+ while (c?.isDigit() == true) {
+ length += c
+ c = client.read()
+ }
+ Log.d("SimpleDict", "length: $length")
+ re = teaPassword.decryptLittleEndian(
+ client.receiveRawMessage(length.toInt()),
+ (seq + 1).toByte()
+ )
+ if (re != null) seq = (seq + 2).toByte()
+ exit = true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ closeDict()
+ } else sleep(233)
+ }
+ return re
+ }
+
+ /**
+ * Receives and verifies an ack packet. If valid, increments [seq] and returns decrypted payload.
+ */
+ private val ack: String?
+ get() {
+ var re = client.receiveRawMessage(1 + 1 + 16)
+ re += client.receiveRawMessage(re[1].toInt())
+ val r = CmdPacket(re, teaPassword).decrypt(seq)
+ if (r != null) seq++
+ Log.d("SimpleDict", "ack: ${r?.decodeToString()}")
+ return r?.decodeToString()
+ }
+
+ /** Establishes connection to the remote dictionary service. */
+ private fun initDict() = client.initConnect()
+
+ /**
+ * Sends termination packet and closes the connection.
+ * Resets [seq] to 0.
+ */
+ private fun closeDict(): Boolean {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_END, filler, teaPassword).encrypt(seq)
+ )
+ seq = 0
+ return client.closeConnect()
+ }
+
+ /**
+ * Saves the given dictionary data to cache, along with its MD5 hash.
+ *
+ * @param data the dictionary data to persist
+ */
+ private fun saveDict(data: ByteArray) {
+ if (externalCacheDir?.exists() != true) externalCacheDir?.mkdirs()
+ if (externalCacheDir?.exists() == true) {
+ dspFile.writeBytes(data)
+ md5File.writeBytes(MessageDigest.getInstance("md5").digest(data))
+ }
+ }
+
+ /**
+ * Compares local MD5 against server MD5 to determine whether new data is available.
+ *
+ * @param md5 the locally stored MD5
+ * @return true if server indicates the data is newer
+ */
+ private fun hasNewItem(md5: ByteArray): Boolean =
+ if (initDict()) {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_MD5, md5, teaPassword).encrypt(seq++)
+ )
+ val cp = ack
+ Log.d("SimpleDict", "Check md5: $cp")
+ closeDict()
+ cp == "nequ"
+ } else false
+
+ /**
+ * Parses raw dictionary entries and stores them in memory.
+ *
+ * @param dictData the raw protobuf dictionary byte array
+ * @param saveDict whether to persist the dictionary locally
+ */
+ private fun analyzeDict(dictData: ByteArray, saveDict: Boolean) {
+ SimpleProtobuf.getDictArray(dictData).forEach { d ->
+ d?.apply {
+ val k = key.decodeToString()
+ if (saveDict) {
+ if (k.toByteArray().contentEquals(key)) {
+ dict[k] = data.decodeToString()
+ latestKeys += k
+ } else {
+ sendDel(key) // purge invalid
+ }
+ } else if (!dict.containsKey(k)) {
+ dict[k] = data.decodeToString()
+ latestKeys += k
+ } else {
+ sendDel(key) // deduplicate
+ }
+ }
+ }
+ if (saveDict) saveDict(dictData)
+ }
+
+ /**
+ * Filters current dictionary values by a predicate.
+ *
+ * @param predicate the predicate to apply
+ * @return a map of matching entries
+ */
+ fun filterValues(predicate: (String?) -> Boolean) = dict.filterValues(predicate)
+
+ /**
+ * Loads the dictionary from cache or server, applies update logic, and calls user-defined callbacks.
+ *
+ * @param doOnLoadFailure called if loading fails
+ * @param doOnLoadSuccess called if loading succeeds
+ * @param doCommon always called after attempt
+ */
+ suspend fun fetch(
+ doOnLoadFailure: suspend () -> Unit,
+ doOnLoadSuccess: suspend () -> Unit,
+ doCommon: (suspend () -> Unit)? = null
+ ) {
+ val noChange = md5File.exists() && dspFile.exists() &&
+ !hasNewItem(md5File.readBytes())
+ val data = if (noChange) dspFile.readBytes() else raw
+ dict.clear()
+ latestKeys = arrayOf()
+ if (data == null) doOnLoadFailure()
+ else {
+ analyzeDict(data, !noChange)
+ doOnLoadSuccess()
+ }
+ doCommon?.invoke()
+ }
+
+ /**
+ * Deletes an entry from the dictionary both remotely and locally.
+ *
+ * @param key the key to delete
+ * @return true if successful
+ */
+ fun del(key: String): Boolean {
+ if (teaSetPassword == null) return false
+ else if (initDict()) {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_DEL, key.toByteArray(), teaSetPassword).encrypt(seq++)
+ )
+ if (ack == "succ") {
+ if (closeDict()) {
+ dict.remove(key)
+ val end = latestKeys.size - 1
+ if (end > 0) latestKeys = latestKeys.let { oldArr ->
+ var index = -1
+ Array(end) {
+ if (oldArr[it] == key) index = it
+ return@Array if (index < 0 || (index > 0 && it < index)) oldArr[it] else oldArr[it + 1]
+ }
+ }
+ return true
+ }
+ } else closeDict()
+ }
+ return false
+ }
+
+ /**
+ * Sends a deletion request for the given key (as bytes), without updating local state.
+ *
+ * @param key raw byte representation of the key
+ * @return true if deletion and disconnect succeed
+ */
+ private fun sendDel(key: ByteArray): Boolean {
+ if (teaSetPassword == null) return false
+ else if (initDict()) {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_DEL, key, teaSetPassword).encrypt(seq++)
+ )
+ if (ack == "succ") {
+ return closeDict()
+ } else closeDict()
+ }
+ return false
+ }
+
+ /**
+ * Gets the value of a key.
+ *
+ * @param key the dictionary key
+ * @return the value or null
+ */
+ operator fun get(key: String) = dict[key]
+
+ /**
+ * Sets or updates a key-value pair on the remote server.
+ * Will delete existing key before inserting new one.
+ *
+ * @param key the dictionary key
+ * @param value the string value to set
+ * @return true if the operation succeeds
+ */
+ fun set(key: String, value: String): Boolean {
+ if (teaSetPassword == null) return false
+ val contain = dict.containsKey(key)
+ if ((contain && sendDel(key.toByteArray())) || !contain) {
+ if (initDict()) {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_SET, key.toByteArray(), teaSetPassword).encrypt(seq++)
+ )
+ if (ack == "data") {
+ client.sendMessage(
+ CmdPacket(CmdPacket.CMD_DAT, value.toByteArray(), teaSetPassword).encrypt(seq++)
+ )
+ val s = ack == "succ"
+ if (s) dict[key] = value
+ return closeDict() && s
+ } else closeDict()
+ }
+ return false
+ } else return false
+ }
+}
\ No newline at end of file
diff --git a/sdict/src/main/java/top/fumiama/sdict/io/ByteArrayQueue.kt b/sdict/src/main/java/top/fumiama/sdict/io/ByteArrayQueue.kt
new file mode 100644
index 0000000..abd240c
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/io/ByteArrayQueue.kt
@@ -0,0 +1,65 @@
+/*
+ * ByteArrayQueue.kt
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict.io
+
+/**
+ * A simple FIFO queue for byte arrays.
+ * Internally stores all data in a single [ByteArray] and supports popping and appending operations.
+ */
+class ByteArrayQueue {
+ /** Internal storage for all queued bytes. */
+ private var elements = byteArrayOf()
+
+ /** Current number of bytes in the queue. */
+ val size get() = elements.size
+
+ /**
+ * Removes and returns the first [num] bytes from the queue, if available.
+ *
+ * @param num the number of bytes to dequeue; defaults to 1
+ * @return a [ByteArray] of the requested length, or `null` if no enough data is available
+ */
+ fun dequeue(num: Int = 1): ByteArray? {
+ return if (num <= elements.size) {
+ val re = elements.copyOfRange(0, num)
+ elements = elements.copyOfRange(num, elements.size)
+ re
+ } else null
+ }
+
+ /**
+ * Removes and returns all remaining bytes in the queue.
+ * After this call, the queue will be empty.
+ *
+ * @return a [ByteArray] containing all bytes that were in the queue
+ */
+ fun drain(): ByteArray {
+ val re = elements
+ elements = byteArrayOf()
+ return re
+ }
+
+ /**
+ * Appends the given [items] to the end of the queue.
+ *
+ * @param items the [ByteArray] to append
+ */
+ operator fun plusAssign(items: ByteArray) {
+ elements += items
+ }
+}
\ No newline at end of file
diff --git a/sdict/src/main/java/top/fumiama/sdict/io/Client.kt b/sdict/src/main/java/top/fumiama/sdict/io/Client.kt
new file mode 100644
index 0000000..bf757f9
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/io/Client.kt
@@ -0,0 +1,194 @@
+/*
+ * Client.kt
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict.io
+
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.lang.Thread.sleep
+import java.net.Socket
+
+import android.util.Log
+
+import top.fumiama.sdict.utils.Utils.toHexStr
+
+/**
+ * A simple TCP client that connects to a server, sends/receives messages, and optionally reports progress.
+ *
+ * @param ip the server IP address
+ * @param port the server port
+ */
+class Client(private val ip: String, private val port: Int) {
+ private var sc: Socket? = null
+ private var dout: OutputStream? = null
+ private var din: InputStream? = null
+
+ private val isConnect get() = sc != null && din != null && dout != null
+
+ /**
+ * Attempts to establish a TCP connection to the server.
+ * Retries up to 3 times before giving up.
+ *
+ * @param depth current retry count, no need to fill this value when call it
+ * @return true if connection is successful, false otherwise
+ */
+ fun initConnect(depth: Int = 0): Boolean {
+ if (depth > 3) {
+ Log.d("Client", "connect server failed after $depth tries")
+ } else try {
+ sc = Socket(ip, port)
+ din = sc?.getInputStream()
+ dout = sc?.getOutputStream()
+ sc?.soTimeout = 10000
+ return if (isConnect) {
+ Log.d("Client", "connect server successful")
+ true
+ } else {
+ Log.d("Client", "connect server failed, now retry...")
+ initConnect(depth + 1)
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ return false
+ }
+
+ /**
+ * Sends a UTF-8 encoded string message to the server.
+ *
+ * @param message the string to send
+ * @return true if the message was sent successfully, false otherwise
+ */
+ fun sendMessage(message: String?): Boolean = sendMessage(message?.toByteArray())
+
+ /**
+ * Sends a byte array message to the server.
+ *
+ * @param message the raw byte array to send
+ * @return true if the message was sent successfully, false otherwise
+ */
+ fun sendMessage(message: ByteArray?): Boolean {
+ try {
+ if (isConnect) {
+ if (message != null) {
+ dout?.write(message)
+ dout?.flush()
+ Log.d("Client", "send msg: ${toHexStr(message)}")
+ return true
+ } else {
+ Log.d("Client", "skip empty message")
+ }
+ } else {
+ Log.d("Client", "send message failed: no connect")
+ }
+ } catch (e: IOException) {
+ Log.d("Client", "send message failed: crash")
+ e.printStackTrace()
+ }
+ return false
+ }
+
+ /**
+ * Reads one character from the input stream.
+ *
+ * @return the character read, or null if disconnected
+ */
+ fun read(): Char? = din?.read()?.toChar()
+
+ private var buffer = ByteArrayQueue()
+ private val receiveBuffer = ByteArray(65536)
+
+ /**
+ * Receives a raw byte array of the specified total size from the server.
+ *
+ * @param totalSize expected size in bytes
+ * @param setProgress whether to report progress via [progress] listener
+ * @return the byte array received, or an empty array on failure
+ */
+ fun receiveRawMessage(totalSize: Int, setProgress: Boolean = false): ByteArray {
+ if (totalSize == buffer.size) return buffer.drain()
+ try {
+ if (isConnect) {
+ Log.d("Client", "Start receiving from server")
+ var prevP = 0
+ while (totalSize > buffer.size) {
+ val count = din?.read(receiveBuffer) ?: 0
+ if (count > 0) {
+ buffer += receiveBuffer.copyOfRange(0, count)
+ Log.d("Client", "reply length: $count")
+ if (setProgress && totalSize > 0) {
+ val p = 100 * buffer.size / totalSize
+ if (prevP != p) {
+ progress?.notify(p)
+ prevP = p
+ }
+ }
+ } else {
+ sleep(10)
+ }
+ }
+ } else {
+ Log.d("Client", "no connect to receive message")
+ }
+ } catch (e: IOException) {
+ Log.d("Client", "receive message failed")
+ e.printStackTrace()
+ }
+ return if (totalSize > 0) buffer.dequeue(totalSize) ?: byteArrayOf() else buffer.drain()
+ }
+
+ /**
+ * Receives a message from the server and decodes it as UTF-8 text.
+ *
+ * @param totalSize expected size in bytes
+ * @return the decoded string
+ */
+ fun receiveMessage(totalSize: Int): String = receiveRawMessage(totalSize).decodeToString()
+
+ /**
+ * Closes the connection and all related resources.
+ *
+ * @return true if closed successfully, false otherwise
+ */
+ fun closeConnect(): Boolean = try {
+ din?.close()
+ dout?.close()
+ sc?.close()
+ sc = null
+ din = null
+ dout = null
+ true
+ } catch (e: IOException) {
+ e.printStackTrace()
+ false
+ }
+
+ /**
+ * Optional interface for reporting progress while receiving messages.
+ */
+ var progress: Progress? = null
+
+ interface Progress {
+ /**
+ * Called to report percentage of received data.
+ *
+ * @param progressPercentage an integer between 0 and 100
+ */
+ fun notify(progressPercentage: Int)
+ }
+}
diff --git a/sdict/src/main/java/top/fumiama/sdict/protocol/CmdPacket.java b/sdict/src/main/java/top/fumiama/sdict/protocol/CmdPacket.java
new file mode 100644
index 0000000..96f4bf7
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/protocol/CmdPacket.java
@@ -0,0 +1,134 @@
+/*
+ * CmdPacket.java
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict.protocol;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+import org.jetbrains.annotations.NotNull;
+import android.util.Log;
+
+import top.fumiama.sdict.utils.Utils;
+
+/**
+ * Represents a command packet in the sdict protocol.
+ * A CmdPacket contains:
+ *
+ *
a command byte
+ *
raw data
+ *
an MD5 checksum of the data
+ *
+ * It supports encryption and decryption using a TEA cipher with an embedded sequence number.
+ *
+ *
+ * Packet layout when encrypted:
+ *
+ * [0] cmd (1 byte)
+ * [1] encrypted data length (1 byte)
+ * [2–17] MD5 hash of original data (16 bytes)
+ * [18–N] encrypted data payload
+ *
+ *
+ */
+public class CmdPacket {
+ private final byte cmd;
+ private final byte[] data;
+ private final byte[] md5;
+ private final Tea t;
+
+ /**
+ * Constructs a command packet from command and data.
+ * Calculates the MD5 digest of the data and stores it.
+ *
+ * @param cmd the command identifier
+ * @param data the unencrypted payload
+ * @param t the TEA cipher to use for encryption
+ * @throws NoSuchAlgorithmException if MD5 digest is unavailable
+ */
+ public CmdPacket(byte cmd, @NotNull byte[] data, @NotNull Tea t) throws NoSuchAlgorithmException {
+ this.cmd = cmd;
+ this.data = data;
+ this.t = t;
+ md5 = MessageDigest.getInstance("MD5").digest(data);
+ Log.d("CmdPacket", "md5: " + Utils.INSTANCE.toHexStr(md5));
+ }
+
+ /**
+ * Constructs a command packet from an already encrypted byte array.
+ * Extracts the command, MD5 hash, and encrypted data segment.
+ *
+ * @param raw the full encrypted packet
+ * @param t the TEA cipher for later decryption
+ */
+ public CmdPacket(@NotNull byte[] raw, @NotNull Tea t) {
+ this.cmd = raw[0];
+ this.t = t;
+ md5 = new byte[16];
+ Log.d("CmdPacket", "build from raw packet: " + Utils.INSTANCE.toHexStr(raw));
+ System.arraycopy(raw, 2, md5, 0, 16);
+ Log.d("CmdPacket", "md5: " + Utils.INSTANCE.toHexStr(md5));
+ data = new byte[raw.length - 1 - 1 - 16];
+ System.arraycopy(raw, 1 + 1 + 16, data, 0, data.length);
+ Log.d("CmdPacket", "data length: " + data.length);
+ }
+
+ /**
+ * Encrypts the data and formats the full command packet.
+ *
+ * @param seq the sequence ID to inject into TEA cipher
+ * @return the complete encrypted command packet
+ */
+ public @NotNull byte[] encrypt(byte seq) {
+ byte[] dat = t.encryptLittleEndian(data, seq);
+ byte[] d = new byte[1 + 1 + 16 + dat.length];
+ d[0] = cmd;
+ d[1] = (byte) dat.length;
+ System.arraycopy(md5, 0, d, 2, 16);
+ System.arraycopy(dat, 0, d, 1 + 1 + 16, dat.length);
+ return d;
+ }
+
+ /**
+ * Decrypts the embedded data and verifies its MD5 hash.
+ *
+ * @param seq the sequence ID that must match the encryption phase
+ * @return the original data if hash verification passes; null otherwise
+ * @throws NoSuchAlgorithmException if MD5 digest is unavailable
+ */
+ public byte[] decrypt(byte seq) throws NoSuchAlgorithmException {
+ byte[] dat = t.decryptLittleEndian(data, seq);
+ if (dat != null && Arrays.equals(MessageDigest.getInstance("MD5").digest(dat), md5)) {
+ return dat;
+ }
+ return null;
+ }
+
+ /**
+ * Command type enums for use with {@link CmdPacket}.
+ */
+ public final static byte
+ CMD_GET = 0, // Request value by key
+ CMD_CAT = 1, // Request all raw dictionary data
+ CMD_MD5 = 2, // Request MD5 of the raw dictionary data
+ CMD_ACK = 3, // Acknowledge reception
+ CMD_END = 4, // End of transmission
+ CMD_SET = 5, // Start to set key-value pair
+ CMD_DEL = 6, // Delete key-value
+ CMD_DAT = 7; // Push value data after CMD_SET
+}
diff --git a/sdict/src/main/java/top/fumiama/sdict/protocol/SimpleProtobuf.java b/sdict/src/main/java/top/fumiama/sdict/protocol/SimpleProtobuf.java
new file mode 100644
index 0000000..90b6248
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/protocol/SimpleProtobuf.java
@@ -0,0 +1,166 @@
+/*
+ * SimpleProtobuf.java
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict.protocol;
+
+import java.util.Stack;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * SimpleProtobuf is a minimalist decoder for a compact binary key-value format using custom SLLE (Simple Length-Length Encoding).
+ * Each record consists of encoded key and data lengths, their values, and optional type tags.
+ *
+ * The format is optimized for space and fast sequential deserialization, suitable for lightweight struct serialize/deserialize.
+ */
+public class SimpleProtobuf {
+
+ /**
+ * Represents a parsed dictionary entry structure with raw binary key and value.
+ */
+ public static class Dict {
+ /** Key as raw bytes. */
+ public byte[] key;
+
+ /** Value associated with the key, as raw bytes. */
+ public byte[] data;
+ }
+
+ /** Internal stack used to collect Dict entries before returning. */
+ private static final DictStack ds = new DictStack();
+
+ /**
+ * Parses a raw SLLE (Simple Length-Length Encoding, LEB128-like)-encoded byte array into
+ * an array of {@link Dict} entries. Expected layout per entry:
+ *
+ * Lengths are SLLE-encoded (1–4 bytes), values are raw.
+ *
+ * @param raw the simple-protobuf encoded byte array of {@link Dict} entries
+ * @return an array of parsed {@link Dict} entries
+ */
+ public static Dict[] getDictArray(@NotNull byte[] raw) {
+ int offset = 0;
+ SLLE s;
+ while (offset < raw.length) {
+ // Skip structure length and type
+ offset += getSLLE(raw, offset).len;
+ offset += getSLLE(raw, offset).len;
+
+ // Parse key
+ s = getSLLE(raw, offset); // key length
+ Dict d = new Dict();
+ d.key = new byte[s.value];
+ offset += s.len;
+ System.arraycopy(raw, offset, d.key, 0, s.value);
+ offset += s.value;
+
+ // Skip value type
+ offset += getSLLE(raw, offset).len;
+
+ // Parse data
+ s = getSLLE(raw, offset); // data length
+ d.data = new byte[s.value];
+ offset += s.len;
+ System.arraycopy(raw, offset, d.data, 0, s.value);
+ offset += s.value;
+
+ ds.push(d);
+ }
+ return ds.popAllData();
+ }
+
+ /**
+ * Decodes a SLLE (Simple Length-Length Encoding, LEB128-like) value from the byte stream.
+ * SLLE is similar to LEB128: each byte's 7 lower bits are value, and MSB=1 means "continue".
+ *
+ * @param p the byte array to read from
+ * @param start the starting offset
+ * @return an {@link SLLE} object containing decoded value and byte length
+ */
+ @NotNull
+ private static SLLE getSLLE(byte[] p, int start) {
+ SLLE s = new SLLE();
+ s.value = 0;
+ for (int i = 0; i < 4; i++) {
+ s.value += (p[start + i] & 0x7F) << (i * 7);
+ if ((p[start + i] & 0x80) == 0) { // If MSB == 0, it's the last byte
+ s.len = i + 1;
+ break;
+ }
+ }
+ return s;
+ }
+
+ /**
+ * Represents a decoded SLLE (Simple Length-Length Encoding, LEB128-like) entry.
+ * Contains both the decoded integer value and the number of bytes read.
+ */
+ private static class SLLE {
+ int value;
+ int len;
+ }
+
+ /**
+ * Stack that accumulates Dict entries and provides a method to pop all as an array.
+ */
+ private static class DictStack extends PopAllStack {
+ /**
+ * Pops and returns all elements in the stack as a {@link Dict} array.
+ * Clears the stack after use.
+ *
+ * @return a {@link Dict} array, or null if the stack is empty
+ */
+ public Dict[] popAllData() {
+ Object[] t = popAll();
+ if (t != null) {
+ Dict[] d = new Dict[t.length];
+ for (int i = 0; i < t.length; i++) {
+ d[i] = (Dict) t[i];
+ }
+ return d;
+ } else {
+ return null;
+ }
+ }
+ }
+
+ /**
+ * Extension of {@link Stack} that allows batch popping all elements at once.
+ *
+ * @param the element type
+ */
+ private static class PopAllStack extends Stack {
+ /**
+ * Pops all elements currently in the stack.
+ * Resets stack size to 0 afterward.
+ *
+ * @return an Object[] array of all items, or null if stack is empty
+ */
+ public Object[] popAll() {
+ if (size() > 0) {
+ Object[] t = new Object[size()];
+ System.arraycopy(elementData, 0, t, 0, size());
+ setSize(0);
+ return t;
+ } else {
+ return null;
+ }
+ }
+ }
+}
diff --git a/sdict/src/main/java/top/fumiama/sdict/protocol/Tea.java b/sdict/src/main/java/top/fumiama/sdict/protocol/Tea.java
new file mode 100644
index 0000000..f83c4fe
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/protocol/Tea.java
@@ -0,0 +1,158 @@
+/*
+ * Tea.java
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict.protocol;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Random;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Implementation of a modified Tiny Encryption Algorithm (TEA) with CBC-like chaining and custom padding.
+ * This variant uses 128-bit keys, little-endian encoding, and a hardcoded 16-round sum table.
+ *
+ * The encrypt/decrypt methods process data in 8-byte blocks using chained IVs and embed sequence numbers into key material.
+ */
+public class Tea {
+
+ /** 128-bit TEA key stored as four 32-bit integers (low endian order). */
+ private final int[] t = new int[4];
+
+ /** Random generator for padding purposes. */
+ private final Random r;
+
+ /**
+ * Constructs a TEA cipher with the given key.
+ * The key is normalized to 16 bytes (padded with 0), parsed in little-endian order.
+ * The last byte is masked to reserve 8 bits for the sequence number.
+ *
+ * @param tea raw key input (will be truncated or zero-padded to 16 bytes)
+ */
+ public Tea(@NotNull byte[] tea) {
+ byte[] tea16 = new byte[16];
+ System.arraycopy(tea, 0, tea16, 0, Math.min(tea.length, 15));
+ tea16[15] = 0;
+ ByteBuffer bf = ByteBuffer.wrap(tea16).order(ByteOrder.LITTLE_ENDIAN);
+ t[0] = bf.getInt(0);
+ t[1] = bf.getInt(4);
+ t[2] = bf.getInt(8);
+ t[3] = bf.getInt(12) & 0x00ffffff; // reserve highest 8 bits for sequence
+ r = new Random();
+ }
+
+ /**
+ * Encrypts data using TEA with CBC-like feedback and randomized padding.
+ *
+ * @param src the plaintext to encrypt
+ * @param seq a sequence number to embed into the key (8 bits added to t[3])
+ * @return the encrypted byte array, including padding
+ */
+ public @NotNull byte[] encryptLittleEndian(@NotNull byte[] src, byte seq) {
+ int lens = src.length;
+ int fill = 10 - (lens + 1) % 8; // pad to 8-byte alignment with room for 10 header bytes
+ int dstlen = fill + lens + 7;
+ byte[] dst = new byte[dstlen];
+
+ byte[] randFill = new byte[fill - 1];
+ t[3] = ((int) seq) << 24 | (t[3] & 0x00ffffff); // embed sequence ID into key
+
+ r.nextBytes(randFill);
+ dst[0] = (byte) ((fill - 3) | 0xF8); // encode pad length in top 3 bits
+ System.arraycopy(randFill, 0, dst, 1, fill - 1);
+ System.arraycopy(src, 0, dst, fill, lens);
+
+ long iv1 = 0, iv2 = 0, holder;
+ ByteBuffer bf = ByteBuffer.wrap(dst).order(ByteOrder.LITTLE_ENDIAN);
+ for (int i = 0; i < dstlen; i += 8) {
+ long block = bf.getLong(i);
+ holder = block ^ iv1;
+
+ int v0 = (int) (holder >> 32);
+ int v1 = (int) holder;
+ for (int j = 0; j < 0x10; j++) {
+ v0 += (v1 + sumtable[j]) ^ ((int) (((long) v1 << 4) & 0xfffffff0L) + t[0]) ^ ((v1 >>> 5 & 0x07ffffff) + t[1]);
+ v1 += (v0 + sumtable[j]) ^ ((int) (((long) v0 << 4) & 0xfffffff0L) + t[2]) ^ ((v0 >>> 5 & 0x07ffffff) + t[3]);
+ }
+
+ iv1 = ((long) v0 << 32) | (v1 & 0xffffffffL);
+ iv1 ^= iv2;
+ iv2 = holder;
+
+ bf.putLong(i, iv1);
+ }
+
+ return dst;
+ }
+
+ /**
+ * Decrypts a TEA-encrypted message encoded via {@link #encryptLittleEndian}.
+ * Returns null if input is malformed or padding is invalid.
+ *
+ * @param src the encrypted byte array
+ * @param seq the sequence number to embed in key (must match encryption)
+ * @return the decrypted plaintext, or null on failure
+ */
+ public byte[] decryptLittleEndian(@NotNull byte[] src, byte seq) {
+ if (src.length < 16 || (src.length % 8) != 0) {
+ return null;
+ }
+
+ byte[] dst = new byte[src.length];
+
+ long iv1, iv2 = 0, holder = 0;
+ t[3] = ((int) seq) << 24 | (t[3] & 0x00ffffff);
+
+ ByteBuffer sbf = ByteBuffer.wrap(src).order(ByteOrder.LITTLE_ENDIAN);
+ ByteBuffer dbf = ByteBuffer.wrap(dst).order(ByteOrder.LITTLE_ENDIAN);
+ for (int i = 0; i < src.length; i += 8) {
+ iv1 = sbf.getLong(i);
+ iv2 ^= iv1;
+
+ int v0 = (int) (iv2 >> 32);
+ int v1 = (int) iv2;
+ for (int j = 0x0f; j >= 0; j--) {
+ v1 -= (v0 + sumtable[j]) ^ ((int) (((long) v0 << 4) & 0xfffffff0L) + t[2]) ^ ((v0 >>> 5 & 0x07ffffff) + t[3]);
+ v0 -= (v1 + sumtable[j]) ^ ((int) (((long) v1 << 4) & 0xfffffff0L) + t[0]) ^ ((v1 >>> 5 & 0x07ffffff) + t[1]);
+ }
+
+ iv2 = ((long) v0 << 32) | (v1 & 0xffffffffL);
+ dbf.putLong(i, iv2 ^ holder);
+ holder = iv1;
+ }
+
+ int start = (dst[0] & 7) + 3;
+ int dataLen = src.length - 7 - start;
+ if (dataLen <= 0) return null;
+
+ byte[] dat = new byte[dataLen];
+ System.arraycopy(dst, start, dat, 0, dataLen);
+ return dat;
+ }
+
+ /**
+ * TEA 16-round precomputed delta sum table.
+ * Values: delta * (1 to 16), where delta = 0x9e3779b9 (golden ratio)
+ */
+ private static final int[] sumtable = {
+ 0x9e3579b9, 0x3c6ef172, 0xd2a66d2b, 0x78dd36e4,
+ 0x17e5609d, 0xb54fda56, 0x5384560f, 0xf1bb77c8,
+ 0x8ff24781, 0x2e4ac13a, 0xcc653af3, 0x6a9964ac,
+ 0x08d12965, 0xa708081e, 0x451221d7, 0xe37793d0,
+ };
+}
\ No newline at end of file
diff --git a/sdict/src/main/java/top/fumiama/sdict/utils/Utils.kt b/sdict/src/main/java/top/fumiama/sdict/utils/Utils.kt
new file mode 100644
index 0000000..13a91e9
--- /dev/null
+++ b/sdict/src/main/java/top/fumiama/sdict/utils/Utils.kt
@@ -0,0 +1,50 @@
+/*
+ * Utils.kt
+ *
+ * Copyright (C) 2025 Minamoto Fumiama
+ *
+ * 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, version 3.
+ *
+ * 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 https://www.gnu.org/licenses/.
+ */
+package top.fumiama.sdict.utils
+
+/**
+ * A utility object providing byte array formatting functions.
+ */
+object Utils {
+
+ /**
+ * Converts a [ByteArray] to its hexadecimal string representation.
+ *
+ * Each byte is represented by exactly two hexadecimal characters (e.g., `0F`, `A0`, `FF`).
+ * The resulting string contains no delimiters and is all lowercase (as produced by [Integer.toHexString]).
+ *
+ * @param byteArray the input array of bytes
+ * @return a hexadecimal string representing the byte contents
+ *
+ * Example:
+ * ```
+ * val input = byteArrayOf(0x0F, 0xA0.toByte())
+ * val hex = Utils.toHexStr(input) // "0fa0"
+ * ```
+ */
+ fun toHexStr(byteArray: ByteArray): String =
+ with(StringBuilder()) {
+ byteArray.forEach {
+ val hex = it.toInt() and 0xFF
+ val hexStr = Integer.toHexString(hex)
+ if (hexStr.length == 1) append("0").append(hexStr)
+ else append(hexStr)
+ }
+ toString()
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 2f4e072..1618e91 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,11 @@
+pluginManagement {
+ plugins {
+ id 'kotlin-android' version "$cm_kotlin_version"
+ id 'com.android.library' version '8.3.2'
+ id 'org.jetbrains.kotlin.android' version '1.7.10'
+ }
+}
+
include ':app'
-rootProject.name = "SimpleDict"
\ No newline at end of file
+rootProject.name = "SimpleDict"
+include ':sdict'