1
0
mirror of https://github.com/fumiama/simple-dict-android.git synced 2026-06-05 00:30:24 +08:00
优化
1. 将所有线程改为协程
2. 模块化 SimpleDict (v0.1.0)
修复
1. jcenter 失效
This commit is contained in:
源文雨
2025-06-16 00:27:28 +09:00
parent b1abd53f17
commit 638add89f6
33 changed files with 1377 additions and 733 deletions

27
.github/workflows/publish.yml vendored Normal file
View File

@@ -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 }}

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

View File

@@ -1,7 +1,14 @@
<component name="ProjectDictionaryState"> <component name="ProjectDictionaryState">
<dictionary name="fumiama"> <dictionary name="fumiama">
<words> <words>
<w>eujuno</w>
<w>karakio</w>
<w>nisi</w> <w>nisi</w>
<w>posena</w>
<w>rjimj</w>
<w>sdict</w>
<w>succ</w>
<w>zenbi</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>

6
.idea/gradle.xml generated
View File

@@ -4,16 +4,16 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/sdict" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@@ -9,9 +9,10 @@ android {
compileSdk 34 compileSdk 34
applicationId "top.fumiama.simpledict" applicationId "top.fumiama.simpledict"
minSdkVersion 26 minSdkVersion 26
//noinspection OldTargetApi
targetSdkVersion 34 targetSdkVersion 34
versionCode 22 versionCode 23
versionName '5.0.2' versionName '5.1.0'
resConfigs "zh", "zh-rCN" resConfigs "zh", "zh-rCN"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -44,14 +45,14 @@ android {
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.10.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.5' implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation project(':sdict')
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' implementation files('libs/com.lapism/search-2.4.1.aar') // https://stackoverflow.com/a/63029110/28801553
implementation 'com.lapism:search:2.4.1@aar'
} }

Binary file not shown.

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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;
}

View File

@@ -23,17 +23,31 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.children import androidx.core.view.children
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager
import com.lapism.search.internal.SearchLayout import com.lapism.search.internal.SearchLayout
import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.activity_main.cctrl
import kotlinx.android.synthetic.main.activity_main.view.* 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.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.fragment_main.fmvp
import kotlinx.android.synthetic.main.line_bottom.view.* import kotlinx.android.synthetic.main.line_bottom.view.lbtindex
import kotlinx.android.synthetic.main.line_word.view.* 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 import java.io.FileNotFoundException
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -42,7 +56,7 @@ class MainActivity : AppCompatActivity() {
private var port = 80 private var port = 80
private var pwd = "demo" private var pwd = "demo"
private var spwd: String? = null 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 cm: ClipboardManager? = null
private var noShowNisi = false private var noShowNisi = false
private var mViewPagerPosition = 0 private var mViewPagerPosition = 0
@@ -61,8 +75,7 @@ class MainActivity : AppCompatActivity() {
if(contains("spwd")) getString("spwd", spwd)?.apply { spwd = this } if(contains("spwd")) getString("spwd", spwd)?.apply { spwd = this }
if(contains("noNisi")) getBoolean("noNisi", noShowNisi).apply { noShowNisi = this } if(contains("noNisi")) getBoolean("noNisi", noShowNisi).apply { noShowNisi = this }
} }
Log.d("MyMain", "noNisi: $noShowNisi") Log.d("MyMain", "server: $host:$port, noNisi: $noShowNisi")
dict = SimpleDict(Client(host, port), pwd, externalCacheDir, spwd)
cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@@ -77,13 +90,17 @@ class MainActivity : AppCompatActivity() {
ffsw.apply { ffsw.apply {
setOnRefreshListener { setOnRefreshListener {
fetchThread { lifecycleScope.launch {
updateSize() fetch {
updateSize()
}
} }
} }
isRefreshing = true isRefreshing = true
fetchThread { lifecycleScope.launch {
updateSize() fetch {
updateSize()
}
} }
} }
@@ -98,8 +115,10 @@ class MainActivity : AppCompatActivity() {
val a = lm.findFirstVisibleItemPosition() val a = lm.findFirstVisibleItemPosition()
val b = lm.findLastVisibleItemPosition() val b = lm.findLastVisibleItemPosition()
val total = lm.itemCount val total = lm.itemCount
if(a <= 0) adapter.scrollUp(1) lifecycleScope.launch {
else if(b >= total-1) adapter.scrollDown(1) if(a <= 0) adapter.scrollUp(1)
else if(b >= total-1) adapter.scrollDown(1)
}
} }
}) })
setAdapter(adapter) setAdapter(adapter)
@@ -130,8 +149,7 @@ class MainActivity : AppCompatActivity() {
override fun onQueryTextSubmit(query: CharSequence): Boolean { override fun onQueryTextSubmit(query: CharSequence): Boolean {
if(query.isNotEmpty()) { if(query.isNotEmpty()) {
val key = query.toString() val key = query.toString()
val data = dict?.get(key) showDictAlert(key, dict[key], recyclerView.children.toList().let { children ->
showDictAlert(key, data, recyclerView.children.toList().let { children ->
val i = children.map { it.ta.text }.indexOf(key) val i = children.map { it.ta.text }.indexOf(key)
if(i >= 0) children[i] else null if(i >= 0) children[i] else null
}) })
@@ -207,7 +225,7 @@ class MainActivity : AppCompatActivity() {
if(isSeeking) { if(isSeeking) {
val bar = mControlBarStates[mViewPagerPosition] val bar = mControlBarStates[mViewPagerPosition]
bar.index = bar.getPosition(p) bar.index = bar.getPosition(p)
updateSize(false) lifecycleScope.launch { updateSize(false) }
} }
} }
@@ -221,7 +239,7 @@ class MainActivity : AppCompatActivity() {
Log.d("MyMain", "onStopTrackingTouch") Log.d("MyMain", "onStopTrackingTouch")
s?.progress?.let { s?.progress?.let {
val ad = mVPAdapter.views[mViewPagerPosition]?.recyclerView?.adapter as? ListViewHolder.RecyclerViewAdapter ?: return 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") Log.d("MyMain", "update size, updateSeekbar: $updateSeekbar")
val bar = mControlBarStates[mViewPagerPosition] val bar = mControlBarStates[mViewPagerPosition]
cctrl?.lbtindex?.text = bar.formatRange(getString(R.string.info_index_meter)) 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() if (updateSeekbar) cctrl?.sb?.progress = bar.getPercentage()
} }
private fun fetchThread(doWhenFinish: (()->Unit)? = null) { private suspend fun fetch(doWhenFinish: (suspend ()->Unit)? = null) {
Thread{ withContext(Dispatchers.IO) {
dict?.fetchDict({ dict.fetch({
runOnUiThread { withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, R.string.toast_refresh_failed, Toast.LENGTH_SHORT).show() 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() Toast.makeText(this@MainActivity, R.string.toast_refresh_succeeded, Toast.LENGTH_SHORT).show()
} }
}) { }) {
runOnUiThread { withContext(Dispatchers.Main) {
ffsw.isRefreshing = false ffsw.isRefreshing = false
(mVPAdapter.views[mViewPagerPosition]?.recyclerView?.adapter as? ListViewHolder.RecyclerViewAdapter)?.refresh() (mVPAdapter.views[mViewPagerPosition]?.recyclerView?.adapter as? ListViewHolder.RecyclerViewAdapter)?.refresh()
updateSize() updateSize()
doWhenFinish?.apply { this() } doWhenFinish?.invoke()
} }
} }
}.start() }
} }
private fun showDictAlert(key: String, data: String?, line: View?) { private fun showDictAlert(key: String, data: String?, line: View?) {
@@ -296,34 +314,40 @@ class MainActivity : AppCompatActivity() {
.setView(t) .setView(t)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
val newText = t.diet.text.toString().trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() } val newText = t.diet.text.toString().trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() }
if (t.diet.text.isNotEmpty() && newText != data) Thread { if (t.diet.text.isNotEmpty() && newText != data) lifecycleScope.launch {
val k = key.trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() } withContext(Dispatchers.IO) {
if(dict?.set(k, newText) == true) { val k = key.trim().replace(Regex("[\\uFF00-\\uFF5E]")) { (it.value[0] - 0xFEE0).toString() }
line?.tb?.text = newText if(dict.set(k, newText)) withContext(Dispatchers.Main) {
} else runOnUiThread { line?.tb?.text = newText
Toast.makeText(this, R.string.toast_failed, Toast.LENGTH_SHORT).show() } 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() else Toast.makeText(this, R.string.toast_unchanged, Toast.LENGTH_SHORT).show()
} }
.setNegativeButton(android.R.string.cancel) { _, _ -> } .setNegativeButton(android.R.string.cancel) { _, _ -> }
.show() .show()
} }
.setNeutralButton(R.string.alert_word_button_delete) { _, _ -> .setNeutralButton(R.string.alert_word_button_delete) { _, _ ->
Thread{ lifecycleScope.launch{
if(dict?.del(key) == true) line?.apply { withContext(Dispatchers.IO) {
val delKey = SpannableString(key) if(dict.del(key)) line?.apply {
val delData = SpannableString(data) val delKey = SpannableString(key)
delKey.setSpan(StrikethroughSpan(), 0, key.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) val delData = SpannableString(data)
delData.setSpan(StrikethroughSpan(), 0, (data?.length?:0), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) delKey.setSpan(StrikethroughSpan(), 0, key.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
ta.text = delKey delData.setSpan(StrikethroughSpan(), 0, (data?.length?:0), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tn.text = delKey withContext(Dispatchers.Main) {
tb.text = delData 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) { _, _ -> } .setNegativeButton(android.R.string.cancel) { _, _ -> }
.show() .show()
@@ -354,21 +378,21 @@ class MainActivity : AppCompatActivity() {
inner class SearchViewHolder(itemView: View) : ListViewHolder(itemView) { inner class SearchViewHolder(itemView: View) : ListViewHolder(itemView) {
inner class RecyclerViewAdapter : ListViewHolder.RecyclerViewAdapter(visibleThreshold) { inner class RecyclerViewAdapter : ListViewHolder.RecyclerViewAdapter(visibleThreshold) {
override fun getKeys(filterText: CharSequence?) = filterText?.let { filter(it) } 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<String> { private fun filter(text: CharSequence): List<String> {
return dict?.keys?.filter { return dict.keys.filter {
it.contains(text, true) it.contains(text, true)
}?.toSet()?.plus( }.toSet().plus(
dict?.filterValues { dict.filterValues {
it?.contains(text, true) ?: false it?.contains(text, true) ?: false
}.let { }.let {
val newSet = mutableSetOf<String>() val newSet = mutableSetOf<String>()
it?.keys?.forEach { k -> it.keys.forEach { k ->
newSet += k newSet += k
} }
newSet newSet
} }
)?.toList()?: emptyList() ).toList()
} }
} }
} }
@@ -383,7 +407,7 @@ class MainActivity : AppCompatActivity() {
bar.sort(keys.toList()) bar.sort(keys.toList())
} }
} }
else dict?.latestKeys?.let { keys -> else dict.latestKeys.let { keys ->
Log.d("MyMain", "LikeViewHolder getKeys all, set size: ${keys.size}") Log.d("MyMain", "LikeViewHolder getKeys all, set size: ${keys.size}")
mControlBarStates[0].let { bar -> mControlBarStates[0].let { bar ->
bar.total = keys.size bar.total = keys.size
@@ -391,7 +415,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
)?: emptyList() )?: 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) { override fun onBindViewHolder(holder: ListViewHolder, p: Int) {
val position = p + index val position = p + index
Log.d("MyMain", "Bind open at $p($position)") Log.d("MyMain", "Bind open at $p($position)")
Thread{ lifecycleScope.launch { withContext(Dispatchers.IO) {
listKeys?.apply { listKeys?.apply {
if (position >= size) return@Thread if (position >= size) return@withContext
val key = get(position) val key = get(position)
val data = getValue(key) val data = getValue(key)
val like = dictPreferences?.contains(key) == true val like = dictPreferences?.contains(key) == true
//Log.d("MyMain", "Like status of $key is $like") //Log.d("MyMain", "Like status of $key is $like")
holder.itemView.apply { holder.itemView.apply line@ {
runOnUiThread { withContext(Dispatchers.Main) {
if (!noShowNisi) { if (!noShowNisi) {
tn.visibility = View.VISIBLE tn.visibility = View.VISIBLE
tn.text = key tn.text = key
@@ -435,13 +459,11 @@ class MainActivity : AppCompatActivity() {
vl.setBackgroundResource(if(like) R.drawable.ic_like_filled else R.drawable.ic_like) vl.setBackgroundResource(if(like) R.drawable.ic_like_filled else R.drawable.ic_like)
//Log.d("MyMain", "Set like of $key: $like") //Log.d("MyMain", "Set like of $key: $like")
setOnClickListener { setOnClickListener {
showDictAlert(key, data, this) showDictAlert(key, data, this@line)
} }
setOnLongClickListener { setOnLongClickListener {
cm?.setPrimaryClip(ClipData.newPlainText("SimpleDict", "$key\n$data")) 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 true
} }
vl.setOnClickListener { vl.setOnClickListener {
@@ -464,7 +486,7 @@ class MainActivity : AppCompatActivity() {
if(p >= itemCount-1) scrollDown(if(p < renderLinesCount) 4 else 1) if(p >= itemCount-1) scrollDown(if(p < renderLinesCount) 4 else 1)
else if(p <= 1) scrollUp(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 } override fun getItemCount() = (listKeys?.size?:0).let { if(it > renderLinesCount) renderLinesCount else it }
@@ -478,7 +500,7 @@ class MainActivity : AppCompatActivity() {
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun scrollDown(n: Int) { suspend fun scrollDown(n: Int) {
if((listKeys?.size ?: 0) <= renderLinesCount) return if((listKeys?.size ?: 0) <= renderLinesCount) return
val oldIndex = index val oldIndex = index
val nextIndex = if(oldIndex + n + renderLinesCount > (listKeys?.size ?: 0)) (listKeys?.size ?: 0) - renderLinesCount else oldIndex + n 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 if(nextIndex < 0) return
index = nextIndex index = nextIndex
if(n >= renderLinesCount) { if(n >= renderLinesCount) {
runOnUiThread { notifyDataSetChanged() } withContext(Dispatchers.Main) { notifyDataSetChanged() }
return return
} }
// index next index // index next index
@@ -495,25 +517,25 @@ class MainActivity : AppCompatActivity() {
// ---remain--- ↑ // ---remain--- ↑
// ----delete---- → → → → → ↗ // ----delete---- → → → → → ↗
val insert = nextIndex - oldIndex val insert = nextIndex - oldIndex
runOnUiThread { withContext(Dispatchers.Main) {
notifyItemRangeInserted(renderLinesCount, insert) notifyItemRangeInserted(renderLinesCount, insert)
notifyItemRangeRemoved(0, insert) notifyItemRangeRemoved(0, insert)
} }
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun scrollUp(n: Int) { suspend fun scrollUp(n: Int) {
if((listKeys?.size ?: 0) <= renderLinesCount) return if((listKeys?.size ?: 0) <= renderLinesCount) return
val oldIndex = index val oldIndex = index
val nextIndex = if(oldIndex-n >= 0) oldIndex-n else 0 val nextIndex = if(oldIndex-n >= 0) oldIndex-n else 0
if(oldIndex == nextIndex) return if(oldIndex == nextIndex) return
index = nextIndex index = nextIndex
if(n >= renderLinesCount) { if(n >= renderLinesCount) {
runOnUiThread { notifyDataSetChanged() } withContext(Dispatchers.Main) { notifyDataSetChanged() }
return return
} }
val insert = oldIndex - nextIndex val insert = oldIndex - nextIndex
runOnUiThread { withContext(Dispatchers.Main) {
notifyItemRangeInserted(0, insert) notifyItemRangeInserted(0, insert)
notifyItemRangeRemoved(renderLinesCount, insert) notifyItemRangeRemoved(renderLinesCount, insert)
} }
@@ -522,7 +544,7 @@ class MainActivity : AppCompatActivity() {
fun getPosition() = index fun getPosition() = index
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun setProgress(p: Int) { suspend fun setProgress(p: Int) {
if(p > 100 || p < 0) return if(p > 100 || p < 0) return
var newIndex = p * (listKeys?.size?:0) / 100 var newIndex = p * (listKeys?.size?:0) / 100
if(newIndex + renderLinesCount > (listKeys?.size?:0)) { if(newIndex + renderLinesCount > (listKeys?.size?:0)) {
@@ -534,7 +556,7 @@ class MainActivity : AppCompatActivity() {
val n = newIndex - oldIndex val n = newIndex - oldIndex
if(n >= renderLinesCount || n <= -renderLinesCount) { if(n >= renderLinesCount || n <= -renderLinesCount) {
index = newIndex index = newIndex
runOnUiThread { notifyDataSetChanged() } withContext(Dispatchers.Main) { notifyDataSetChanged() }
return return
} }
if(n > 0) scrollDown(n) if(n > 0) scrollDown(n)
@@ -550,7 +572,7 @@ class MainActivity : AppCompatActivity() {
if(ad?.hasRefreshed == false) { if(ad?.hasRefreshed == false) {
ad.refresh() ad.refresh()
} }
updateSize() lifecycleScope.launch { updateSize() }
} }
override fun onPageScrollStateChanged(state: Int) { override fun onPageScrollStateChanged(state: Int) {
@@ -583,7 +605,7 @@ class MainActivity : AppCompatActivity() {
Log.d("MyMain", "new start: $newStart, index: ${bar.index}") Log.d("MyMain", "new start: $newStart, index: ${bar.index}")
if (newStart != bar.index) { if (newStart != bar.index) {
bar.index = newStart bar.index = newStart
updateSize() lifecycleScope.launch { updateSize() }
} }
} }
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { 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") Log.d("MyMain", "new scroll state: $newState, a: $a, b: $b")
this@MainActivity.ffsw.isEnabled = newState == 0 && a == 0 this@MainActivity.ffsw.isEnabled = newState == 0 && a == 0
val total = lm.itemCount val total = lm.itemCount
if(a <= 0) ad.scrollUp(1) lifecycleScope.launch {
else if(b >= total-1) ad.scrollDown(1) if(a <= 0) ad.scrollUp(1)
else if(b >= total-1) ad.scrollDown(1)
}
} }
}) })
} }

View File

@@ -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<String, String?>()
val size get() = dict.size
val keys get() = dict.keys
var latestKeys = arrayOf<String>()
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
}
}

View File

@@ -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<Dict> {
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<T> extends Stack<T> {
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;
}
}
}

View File

@@ -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,
};
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -1,16 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.7.10' ext.kotlin_version = "$cm_kotlin_version"
repositories { repositories {
google() google()
jcenter()
mavenCentral()
mavenCentral() mavenCentral()
maven { url 'https://maven.google.com' } maven { url 'https://maven.google.com' }
maven { url "https://jitpack.io" } maven { url "https://jitpack.io" }
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
@@ -21,12 +19,11 @@ buildscript {
allprojects { allprojects {
repositories { repositories {
google() google()
jcenter()
mavenCentral() mavenCentral()
maven { url "https://jitpack.io" } maven { url "https://jitpack.io" }
} }
} }
task clean(type: Delete) { tasks.register('clean', Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@@ -19,4 +19,5 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
android.enableR8.fullMode=true android.enableR8.fullMode=true
cm_kotlin_version=1.7.10

View File

@@ -3,5 +3,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

1
sdict/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

78
sdict/build.gradle.kts Normal file
View File

@@ -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"))
}

0
sdict/consumer-rules.pro Normal file
View File

21
sdict/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

View File

@@ -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<String, String?>()
/** 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<String>()
/** 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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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:
* <ul>
* <li>a command byte</li>
* <li>raw data</li>
* <li>an MD5 checksum of the data</li>
* </ul>
* It supports encryption and decryption using a TEA cipher with an embedded sequence number.
*
* <p>
* Packet layout when encrypted:
* <pre><code>
* [0] cmd (1 byte)
* [1] encrypted data length (1 byte)
* [217] MD5 hash of original data (16 bytes)
* [18N] encrypted data payload
* </code></pre>
* </p>
*/
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
}

View File

@@ -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.
* <p>
* 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:
* <pre><code>
* [struct_len][type][key_len][key_bytes][type][data_len][data_bytes]
* </code></pre>
* Lengths are SLLE-encoded (14 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<Dict> {
/**
* 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 <T> the element type
*/
private static class PopAllStack<T> extends Stack<T> {
/**
* 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;
}
}
}
}

View File

@@ -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.
* <p>
* 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,
};
}

View File

@@ -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()
}
}

View File

@@ -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' include ':app'
rootProject.name = "SimpleDict" rootProject.name = "SimpleDict"
include ':sdict'