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 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: + *


+     * [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'