1
0
mirror of https://github.com/fumiama/simple-dict-android.git synced 2026-06-12 22:40:27 +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

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