mirror of
https://github.com/fumiama/simple-dict-android.git
synced 2026-06-05 00:30:24 +08:00
v5.1.0
优化 1. 将所有线程改为协程 2. 模块化 SimpleDict (v0.1.0) 修复 1. jcenter 失效
This commit is contained in:
27
.github/workflows/publish.yml
vendored
Normal file
27
.github/workflows/publish.yml
vendored
Normal 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
10
.idea/deploymentTargetSelector.xml
generated
Normal 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>
|
||||
7
.idea/dictionaries/fumiama.xml
generated
7
.idea/dictionaries/fumiama.xml
generated
@@ -1,7 +1,14 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="fumiama">
|
||||
<words>
|
||||
<w>eujuno</w>
|
||||
<w>karakio</w>
|
||||
<w>nisi</w>
|
||||
<w>posena</w>
|
||||
<w>rjimj</w>
|
||||
<w>sdict</w>
|
||||
<w>succ</w>
|
||||
<w>zenbi</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
6
.idea/gradle.xml
generated
6
.idea/gradle.xml
generated
@@ -4,16 +4,16 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/sdict" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
BIN
app/libs/com.lapism/search-2.4.1.aar
Normal file
BIN
app/libs/com.lapism/search-2.4.1.aar
Normal file
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
android.enableR8.fullMode=true
|
||||
cm_kotlin_version=1.7.10
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
||||
|
||||
1
sdict/.gitignore
vendored
Normal file
1
sdict/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
78
sdict/build.gradle.kts
Normal file
78
sdict/build.gradle.kts
Normal 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
0
sdict/consumer-rules.pro
Normal file
21
sdict/proguard-rules.pro
vendored
Normal file
21
sdict/proguard-rules.pro
vendored
Normal 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
|
||||
4
sdict/src/main/AndroidManifest.xml
Normal file
4
sdict/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
321
sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt
Normal file
321
sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt
Normal 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
|
||||
}
|
||||
}
|
||||
65
sdict/src/main/java/top/fumiama/sdict/io/ByteArrayQueue.kt
Normal file
65
sdict/src/main/java/top/fumiama/sdict/io/ByteArrayQueue.kt
Normal 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
|
||||
}
|
||||
}
|
||||
194
sdict/src/main/java/top/fumiama/sdict/io/Client.kt
Normal file
194
sdict/src/main/java/top/fumiama/sdict/io/Client.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
134
sdict/src/main/java/top/fumiama/sdict/protocol/CmdPacket.java
Normal file
134
sdict/src/main/java/top/fumiama/sdict/protocol/CmdPacket.java
Normal 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)
|
||||
* [2–17] MD5 hash of original data (16 bytes)
|
||||
* [18–N] 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
|
||||
}
|
||||
@@ -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 (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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
sdict/src/main/java/top/fumiama/sdict/protocol/Tea.java
Normal file
158
sdict/src/main/java/top/fumiama/sdict/protocol/Tea.java
Normal 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,
|
||||
};
|
||||
}
|
||||
50
sdict/src/main/java/top/fumiama/sdict/utils/Utils.kt
Normal file
50
sdict/src/main/java/top/fumiama/sdict/utils/Utils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
rootProject.name = "SimpleDict"
|
||||
include ':sdict'
|
||||
|
||||
Reference in New Issue
Block a user