From 29e46651fa60d1b8c82edee75a5001509d9ee1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BA=90=E6=96=87=E9=9B=A8?= <41315874+fumiama@users.noreply.github.com> Date: Sun, 22 Jun 2025 00:25:37 +0900 Subject: [PATCH] =?UTF-8?q?v5.1.1=20=E5=8D=87=E7=BA=A7=201.=20SimpleDict?= =?UTF-8?q?=20(v0.1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/dictionaries/fumiama.xml | 2 + sdict/build.gradle.kts | 2 +- .../main/java/top/fumiama/sdict/ApkUpdater.kt | 127 ++++++++++++++++ .../main/java/top/fumiama/sdict/SimpleDict.kt | 5 +- .../java/top/fumiama/sdict/SimpleKanban.kt | 137 ++++++++++++++++++ 5 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 sdict/src/main/java/top/fumiama/sdict/ApkUpdater.kt create mode 100644 sdict/src/main/java/top/fumiama/sdict/SimpleKanban.kt diff --git a/.idea/dictionaries/fumiama.xml b/.idea/dictionaries/fumiama.xml index 1efdb65..340f815 100644 --- a/.idea/dictionaries/fumiama.xml +++ b/.idea/dictionaries/fumiama.xml @@ -1,6 +1,7 @@ + catquit eujuno karakio nisi @@ -8,6 +9,7 @@ rjimj sdict succ + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx zenbi diff --git a/sdict/build.gradle.kts b/sdict/build.gradle.kts index b74477a..c4cfe8d 100644 --- a/sdict/build.gradle.kts +++ b/sdict/build.gradle.kts @@ -18,7 +18,7 @@ android { } group = "top.fumiama" - version = "0.1.0" + version = "0.1.1" mavenPublishing { publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) diff --git a/sdict/src/main/java/top/fumiama/sdict/ApkUpdater.kt b/sdict/src/main/java/top/fumiama/sdict/ApkUpdater.kt new file mode 100644 index 0000000..4c1ab5e --- /dev/null +++ b/sdict/src/main/java/top/fumiama/sdict/ApkUpdater.kt @@ -0,0 +1,127 @@ +package top.fumiama.sdict + +import top.fumiama.sdict.io.Client +import top.fumiama.sdict.utils.Utils.toHexStr +import java.security.MessageDigest + +/** + * A base class for checking and downloading APK updates from a remote [SimpleKanban] server. + * + * This class uses a [SimpleKanban] instance to communicate with the server, + * parse versioning messages, and fetch APK binaries. It provides overridable + * callbacks for handling update events with MD5 checksum verification. + * + * Subclasses can override the lifecycle methods to customize update behavior. + * + * @param serverIP The IP address of the update server. + * @param serverPort The port number of the update server. + * @param password The password used to authenticate with the server. + */ +open class ApkUpdater(serverIP: String, serverPort: Int, password: String) { + + /** Client used for network communication with the server. */ + private val client = Client(serverIP, serverPort) + + /** Wrapper around the client to handle kanban protocol operations. */ + private val kanban = SimpleKanban(client, password) + + /** + * Called when a newer app version or plain message is available from the server. + * + * The MD5 is only been provided when new APK is available. + * + * @param version The new version number. + * @param message A APK changelog or developer notice. + * @param md5 Optional MD5 checksum of the APK file, if provided. + */ + open suspend fun onCheckNewVersion(version: Int, message: String, md5: String? = null) {} + + /** + * Called when the current version is already the latest. + * + * @param version The current installed version. + */ + open suspend fun onCheckLatestVersion(version: Int) {} + + /** + * Called when APK downloading fails. + * + * @param cause The failure reason, either [UPDATE_FAIL_NETWORK] or [UPDATE_FAIL_FILE_CORRUPT]. + */ + open suspend fun onDownloadNewVersionFailed(cause: Int) {} + + /** + * Called when the APK file is successfully downloaded and verified. + * + * @param data The binary contents of the downloaded APK file. + */ + open suspend fun onDownloadNewVersionSuccess(data: ByteArray) {} + + /** + * Checks with the server if a new version is available. + * + * Parses the message format to determine: + * - If the current version is the latest (`"null"` response) + * - If a newer version exists with/without an MD5 checksum + * + * Example response format: + * ``` + * 5 + * Update message here + * md5:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + * ``` + * + * @param currentVersion The current installed version number. + */ + suspend fun check(currentVersion: Int) { + val msg = kanban[currentVersion] + if (msg == "null") { + onCheckLatestVersion(currentVersion) + return + } + val verNum = msg.substringBefore('\n').toIntOrNull() ?: return + if (!msg.contains("md5:")) { + onCheckNewVersion(verNum, msg.substringAfter('\n')) + return + } + onCheckNewVersion( + verNum, + msg.substringAfter('\n').substringBeforeLast('\n'), + msg.substringAfterLast("md5:") + ) + } + + /** + * Downloads the APK binary and validates its MD5 checksum. + * + * If the checksum is valid, the update is considered successful and + * [onDownloadNewVersionSuccess] is called. Otherwise, + * [onDownloadNewVersionFailed] is invoked with an appropriate error code. + * + * @param md5 The expected MD5 hash of the APK file. + * @param progressHandler Optional handler to track download progress. + */ + suspend fun download(md5: String, progressHandler: Client.Progress) { + client.progress = progressHandler + try { + kanban.fetch({ onDownloadNewVersionFailed(UPDATE_FAIL_NETWORK) }) { + if (md5 == toHexStr( + MessageDigest.getInstance("MD5").digest(it) + ) + ) onDownloadNewVersionSuccess(it) + else onDownloadNewVersionFailed(UPDATE_FAIL_FILE_CORRUPT) + } + } catch (e: Exception) { + e.printStackTrace() + client.progress = null + } + } + + companion object { + /** Error code indicating a network failure during the update process. */ + const val UPDATE_FAIL_NETWORK = 0 + + /** Error code indicating MD5 mismatch after download. */ + const val UPDATE_FAIL_FILE_CORRUPT = 1 + } +} \ No newline at end of file diff --git a/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt b/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt index d33a49d..761dafd 100644 --- a/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt +++ b/sdict/src/main/java/top/fumiama/sdict/SimpleDict.kt @@ -29,7 +29,8 @@ 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]. + * A high-level dictionary manager that communicates with a remote [Simple Dict Server](https://github.com/fumiama/simple-dict) + * 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. @@ -167,7 +168,7 @@ class SimpleDict( CmdPacket(CmdPacket.CMD_MD5, md5, teaPassword).encrypt(seq++) ) val cp = ack - Log.d("SimpleDict", "Check md5: $cp") + Log.d("SimpleDict", "check md5: $cp") closeDict() cp == "nequ" } else false diff --git a/sdict/src/main/java/top/fumiama/sdict/SimpleKanban.kt b/sdict/src/main/java/top/fumiama/sdict/SimpleKanban.kt new file mode 100644 index 0000000..9dbb5a6 --- /dev/null +++ b/sdict/src/main/java/top/fumiama/sdict/SimpleKanban.kt @@ -0,0 +1,137 @@ +/* + * SimpleKanban.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 android.util.Log +import top.fumiama.sdict.io.Client + +/** + * A client-side utility class for interacting with a + * [Simple Kanban Server](https://github.com/fumiama/simple-kanban). + * This class is designed to fetch raw kanban data or version-specific string responses + * from a server using a custom binary-text-mixed protocol. It must be executed in a separate thread + * or coroutine due to network I/O operations. + * + * @param client A pre-configured [Client] instance used to manage the socket connection. + * @param password A password string used for authentication when sending commands to the server. + * Please note that the password is sent in cleartext without any encryption. + */ +class SimpleKanban(private val client: Client, private val password: String) { + + /** + * Attempts to retrieve raw kanban data from the server. + * + * The client sends a specific command using the password and waits for a response, + * expecting a 4-byte LE header to determine the full message length, then reads the full payload. + * + * This process is retried up to 3 times if an exception occurs during reading. + * + * @return A [ByteArray] containing the raw data if successful, or `null` if all attempts fail. + */ + private val raw: ByteArray? + get() { + var times = 3 + var re: ByteArray + var firstReceived: ByteArray + do { + re = byteArrayOf() + if(client.initConnect()) { + client.sendMessage("${password}catquit") // Send command to request raw data. + client.receiveRawMessage(33) // Welcome to simple kanban server. + try { + firstReceived = client.receiveRawMessage(4) // Read header to get length. + val length = convert2Int(firstReceived) + Log.d("SimpleKanban", "raw length: $length") + // Handle any additional bytes beyond the header in the same buffer. + if(firstReceived.size > 4) + re += firstReceived.copyOfRange(4, firstReceived.size) + // Read remaining bytes based on calculated total length. + re += client.receiveRawMessage(length - re.size, setProgress = true) + break + } catch (e: Exception) { + e.printStackTrace() + } + client.closeConnect() + } + } while (times-- > 0) + return if(re.isEmpty()) null else re + } + + /** + * Converts a 4-byte little-endian byte array to an integer. + * + * The input is expected to be in little-endian format. + * + * @param buffer A 4-byte array containing the length in little-endian order. + * @return The converted integer value. + */ + private fun convert2Int(buffer: ByteArray) = + (buffer[3].toInt() and 0xff shl 24) or + (buffer[2].toInt() and 0xff shl 16) or + (buffer[1].toInt() and 0xff shl 8) or + (buffer[0].toInt() and 0xff) + + /** + * Asynchronously fetches the raw kanban data and handles success or failure. + * + * This method is suspendable and should be called within a coroutine context. + * + * @param doOnLoadFailure Called if the fetch operation fails or receives no data. + * @param doOnLoadSuccess Called with the received [ByteArray] if the fetch succeeds. + */ + suspend fun fetch( + doOnLoadFailure: suspend () -> Unit, + doOnLoadSuccess: suspend (data: ByteArray) -> Unit + ) { + raw?.let { doOnLoadSuccess(it) } ?: doOnLoadFailure() + } + + /** + * Requests a specific kanban version string from the server. + * + * The client sends a versioned request using the password and receives a response. + * The response may be a 4-byte "null" message or a length-prefixed string. + * In case of errors or connection failure, `"null"` is returned. + * + * @param version An integer identifying local kanban board version. + * @return The corresponding remote kanban data as a [String], or `"null"` if not found or failed. + */ + operator fun get(version: Int): String = + if(client.initConnect()) { + client.sendMessage("${password}get${version}quit") // Send version-specific request. + client.receiveRawMessage(36) // Welcome to simple kanban server. get + val r = try { + val firstReceive = client.receiveRawMessage(4) + if(firstReceive.decodeToString() == "null") "null" + else { + val length = convert2Int(firstReceive) + Log.d("SimpleKanban", "get length: $length") + var re = byteArrayOf() + if(firstReceive.size > 4) + re += firstReceive.copyOfRange(4, firstReceive.size) + re += client.receiveRawMessage(length - re.size) + if(re.isNotEmpty()) re.decodeToString() else "null" + } + } catch (e: Exception){ + e.printStackTrace() + "null" + } + client.closeConnect() + r + } else "null" +} \ No newline at end of file