From 638add89f67d792e75192f38ac924d30e564a4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BA=90=E6=96=87=E9=9B=A8?= <41315874+fumiama@users.noreply.github.com> Date: Mon, 16 Jun 2025 00:27:28 +0900 Subject: [PATCH] =?UTF-8?q?v5.1.0=20=E4=BC=98=E5=8C=96=201.=20=E5=B0=86?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=BA=BF=E7=A8=8B=E6=94=B9=E4=B8=BA=E5=8D=8F?= =?UTF-8?q?=E7=A8=8B=202.=20=E6=A8=A1=E5=9D=97=E5=8C=96=20SimpleDict=20(v0?= =?UTF-8?q?.1.0)=20=E4=BF=AE=E5=A4=8D=201.=20jcenter=20=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish.yml | 27 ++ .idea/deploymentTargetSelector.xml | 10 + .idea/dictionaries/fumiama.xml | 7 + .idea/gradle.xml | 6 +- .idea/migrations.xml | 10 + app/build.gradle | 21 +- app/libs/com.lapism/search-2.4.1.aar | Bin 0 -> 71340 bytes .../simpledict/ExampleInstrumentedTest.kt | 24 -- .../top/fumiama/simpledict/ByteArrayQueue.kt | 27 -- .../java/top/fumiama/simpledict/Client.kt | 119 ------- .../top/fumiama/simpledict/CmdPacket.java | 56 --- .../top/fumiama/simpledict/MainActivity.kt | 178 +++++----- .../java/top/fumiama/simpledict/SimpleDict.kt | 170 ---------- .../fumiama/simpledict/SimpleProtobuf.java | 81 ----- .../main/java/top/fumiama/simpledict/Tea.java | 126 ------- .../main/java/top/fumiama/simpledict/Utils.kt | 14 - .../top/fumiama/simpledict/ExampleUnitTest.kt | 17 - build.gradle | 9 +- gradle.properties | 3 +- gradle/wrapper/gradle-wrapper.properties | 2 +- sdict/.gitignore | 1 + sdict/build.gradle.kts | 78 +++++ sdict/consumer-rules.pro | 0 sdict/proguard-rules.pro | 21 ++ sdict/src/main/AndroidManifest.xml | 4 + .../main/java/top/fumiama/sdict/SimpleDict.kt | 321 ++++++++++++++++++ .../top/fumiama/sdict/io/ByteArrayQueue.kt | 65 ++++ .../main/java/top/fumiama/sdict/io/Client.kt | 194 +++++++++++ .../top/fumiama/sdict/protocol/CmdPacket.java | 134 ++++++++ .../sdict/protocol/SimpleProtobuf.java | 166 +++++++++ .../java/top/fumiama/sdict/protocol/Tea.java | 158 +++++++++ .../java/top/fumiama/sdict/utils/Utils.kt | 50 +++ settings.gradle | 11 +- 33 files changed, 1377 insertions(+), 733 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/migrations.xml create mode 100644 app/libs/com.lapism/search-2.4.1.aar delete mode 100644 app/src/androidTest/java/top/fumiama/simpledict/ExampleInstrumentedTest.kt delete mode 100644 app/src/main/java/top/fumiama/simpledict/ByteArrayQueue.kt delete mode 100644 app/src/main/java/top/fumiama/simpledict/Client.kt delete mode 100644 app/src/main/java/top/fumiama/simpledict/CmdPacket.java delete mode 100644 app/src/main/java/top/fumiama/simpledict/SimpleDict.kt delete mode 100644 app/src/main/java/top/fumiama/simpledict/SimpleProtobuf.java delete mode 100644 app/src/main/java/top/fumiama/simpledict/Tea.java delete mode 100644 app/src/main/java/top/fumiama/simpledict/Utils.kt delete mode 100644 app/src/test/java/top/fumiama/simpledict/ExampleUnitTest.kt create mode 100644 sdict/.gitignore create mode 100644 sdict/build.gradle.kts create mode 100644 sdict/consumer-rules.pro create mode 100644 sdict/proguard-rules.pro create mode 100644 sdict/src/main/AndroidManifest.xml create mode 100644 sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt create mode 100644 sdict/src/main/java/top/fumiama/sdict/io/ByteArrayQueue.kt create mode 100644 sdict/src/main/java/top/fumiama/sdict/io/Client.kt create mode 100644 sdict/src/main/java/top/fumiama/sdict/protocol/CmdPacket.java create mode 100644 sdict/src/main/java/top/fumiama/sdict/protocol/SimpleProtobuf.java create mode 100644 sdict/src/main/java/top/fumiama/sdict/protocol/Tea.java create mode 100644 sdict/src/main/java/top/fumiama/sdict/utils/Utils.kt 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 + karakio nisi + 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 0000000000000000000000000000000000000000..158ddca350ec9f2b4792f01a123b05f2fd451cae GIT binary patch literal 71340 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

&#V3>&Ve6)Zw36KoPG%`%?u! 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 Vl#-Ha*N`3fu>yjjCh!OZ`Y+uk0CNBU literal 0 HcmV?d00001 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: + *


+     * [struct_len][type][key_len][key_bytes][type][data_len][data_bytes]
+     * 
+ * 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'