Bläddra i källkod

修改了最新进度条,增加了app端,但是app与python不兼容

sequoia00 1 månad sedan
förälder
incheckning
136afb1fc8
40 ändrade filer med 2101 tillägg och 286 borttagningar
  1. 10 0
      .gitignore
  2. 43 0
      androidApp/README.md
  3. 72 0
      androidApp/app/build.gradle.kts
  4. 5 0
      androidApp/app/proguard-rules.pro
  5. 37 0
      androidApp/app/src/main/AndroidManifest.xml
  6. 85 0
      androidApp/app/src/main/java/com/example/lockx/MainActivity.kt
  7. 268 0
      androidApp/app/src/main/java/com/example/lockx/crypto/CryptoUtils.kt
  8. 117 0
      androidApp/app/src/main/java/com/example/lockx/network/SftpManager.kt
  9. 412 0
      androidApp/app/src/main/java/com/example/lockx/ui/FileBrowserScreen.kt
  10. 414 0
      androidApp/app/src/main/java/com/example/lockx/ui/FileBrowserViewModel.kt
  11. 112 0
      androidApp/app/src/main/java/com/example/lockx/ui/PasswordDialog.kt
  12. 61 0
      androidApp/app/src/main/java/com/example/lockx/ui/PlayDialog.kt
  13. 41 0
      androidApp/app/src/main/java/com/example/lockx/ui/SshConnectDialog.kt
  14. 41 0
      androidApp/app/src/main/java/com/example/lockx/util/FileUtils.kt
  15. 18 0
      androidApp/app/src/main/java/com/example/lockx/util/ProgressCallback.kt
  16. 6 0
      androidApp/app/src/main/res/drawable/ic_launcher_background.xml
  17. 13 0
      androidApp/app/src/main/res/drawable/ic_launcher_foreground.xml
  18. 5 0
      androidApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  19. 5 0
      androidApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  20. 6 0
      androidApp/app/src/main/res/mipmap-hdpi/ic_launcher.xml
  21. 5 0
      androidApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml
  22. 6 0
      androidApp/app/src/main/res/mipmap-mdpi/ic_launcher.xml
  23. 5 0
      androidApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml
  24. 6 0
      androidApp/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
  25. 5 0
      androidApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml
  26. 6 0
      androidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
  27. 5 0
      androidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml
  28. 6 0
      androidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
  29. 5 0
      androidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml
  30. 9 0
      androidApp/app/src/main/res/values/colors.xml
  31. 3 0
      androidApp/app/src/main/res/values/strings.xml
  32. 9 0
      androidApp/app/src/main/res/values/themes.xml
  33. 4 0
      androidApp/build.gradle.kts
  34. 4 0
      androidApp/gradle.properties
  35. BIN
      androidApp/gradle/wrapper/gradle-wrapper.jar
  36. 7 0
      androidApp/gradle/wrapper/gradle-wrapper.properties
  37. 4 0
      androidApp/gradlew
  38. 17 0
      androidApp/settings.gradle.kts
  39. 224 286
      fast_media_lock.py
  40. BIN
      test.jpg

+ 10 - 0
.gitignore

@@ -5,3 +5,13 @@ __pycache__/
 models/
 data/*.zip
 test/
+
+# Android build outputs
+androidApp/.gradle/
+androidApp/app/build/
+androidApp/local.properties
+androidApp/.idea/
+
+# Lock files (encrypted)
+*.lockx
+*.bak

+ 43 - 0
androidApp/README.md

@@ -0,0 +1,43 @@
+# LockX - Android 文件加密浏览器
+
+基于 `fast_media_lock.py` 加密算法的 Android 文件加密/解密浏览器应用。
+
+## 功能
+
+- **本机文件浏览**: 浏览本地文件系统,支持媒体文件过滤
+- **SFTP 远程浏览**: 连接远程 SSH 服务器,浏览远程文件
+- **加密**: 点击文件后选择"加密",输入密码(需确认),对文件前 N MB 进行加密
+- **解密**: 点击文件后选择"解密",输入密码,解密文件
+- **互通**: 加密解密算法与 Python 脚本完全兼容
+
+## 加密算法
+
+- 使用 BouncyCastle `SCrypt` (N=16384, r=8, p=1, dklen=64)
+- HMAC-SHA256 密钥流 + XOR 加密前 N MB
+- 元数据嵌入文件尾部(magic + version + chunk_size + original_size + salt + nonce + verifier + crc32)
+- 加密后文件名追加 `.lockx` 后缀
+
+## 构建
+
+需要 Android Studio (Arctic Fox 或更高版本) 打开此项目。
+
+```bash
+# 或者使用命令行(需安装 Android SDK)
+./gradlew assembleDebug
+```
+
+## 依赖
+
+- AndroidX Compose (Material3)
+- BouncyCastle (bcprov-jdk18on:1.77)
+- SSHJ (0.36.0)
+
+## 权限
+
+- `INTERNET` - SFTP 连接
+- `READ/WRITE_EXTERNAL_STORAGE` - 文件浏览(Android 12 及以下)
+
+## 兼容性
+
+- minSdk: 24 (Android 7.0)
+- targetSdk: 34 (Android 14)

+ 72 - 0
androidApp/app/build.gradle.kts

@@ -0,0 +1,72 @@
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace = "com.example.lockx"
+    compileSdk = 34
+
+    defaultConfig {
+        applicationId = "com.example.lockx"
+        minSdk = 24
+        targetSdk = 34
+        versionCode = 1
+        versionName = "1.0"
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        vectorDrawables {
+            useSupportLibrary = true
+        }
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        compose = true
+    }
+    composeOptions {
+        kotlinCompilerExtensionVersion = "1.5.4"
+    }
+    packaging {
+        resources {
+            excludes += "/META-INF/{AL2.0,LGPL2.1}"
+        }
+    }
+}
+
+dependencies {
+    implementation("androidx.core:core-ktx:1.13.1")
+    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+    implementation("androidx.activity:activity-compose:1.8.2")
+    implementation(platform("androidx.compose:compose-bom:2024.01.00"))
+    implementation("androidx.compose.ui:ui")
+    implementation("androidx.compose.ui:ui-graphics")
+    implementation("androidx.compose.ui:ui-tooling-preview")
+    implementation("androidx.compose.material3:material3")
+    implementation("androidx.compose.material:material-icons-extended")
+    implementation("com.google.android.material:material:1.12.0")
+    implementation("org.bouncycastle:bcprov-jdk18on:1.77")
+    implementation("com.hierynomus:sshj:0.36.0")
+    testImplementation("junit:junit:4.13.2")
+    androidTestImplementation("androidx.test.ext:junit:1.1.5")
+    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+    androidTestImplementation(platform("androidx.compose:compose-bom:2024.01.00"))
+    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+    debugImplementation("androidx.compose.ui:ui-tooling")
+    debugImplementation("androidx.compose.ui:ui-test-manifest")
+}

+ 5 - 0
androidApp/app/proguard-rules.pro

@@ -0,0 +1,5 @@
+# Keep BouncyCastle and SSHJ classes
+-keep class org.bouncycastle.** { *; }
+-keep class com.hierynomus.** { *; }
+-dontwarn org.bouncycastle.**
+-dontwarn com.hierynomus.**

+ 37 - 0
androidApp/app/src/main/AndroidManifest.xml

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
+        android:maxSdkVersion="32" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+        android:maxSdkVersion="32"
+        tools:ignore="ScopedStorage" />
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
+        tools:ignore="ScopedStorage" />
+    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
+    <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.LockX">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:label="@string/app_name"
+            android:theme="@style/Theme.LockX">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>

+ 85 - 0
androidApp/app/src/main/java/com/example/lockx/MainActivity.kt

@@ -0,0 +1,85 @@
+package com.example.lockx
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.provider.Settings
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import com.example.lockx.ui.FileBrowserScreen
+import com.example.lockx.ui.FileBrowserViewModel
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        try { enableEdgeToEdge() } catch(_: Exception) {}
+
+        when {
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
+                val perms = mutableListOf(
+                    Manifest.permission.READ_MEDIA_IMAGES,
+                    Manifest.permission.READ_MEDIA_VIDEO,
+                    Manifest.permission.READ_MEDIA_AUDIO
+                )
+                if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+                    perms.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+                }
+                if (perms.isNotEmpty()) {
+                    ActivityCompat.requestPermissions(this, perms.toTypedArray(), 1001)
+                }
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+                    if (!Environment.isExternalStorageManager()) {
+                        try {
+                            startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
+                        } catch (_: Exception) {}
+                    }
+                }
+            }
+            Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q -> {
+                val perms = mutableListOf(
+                    Manifest.permission.READ_EXTERNAL_STORAGE,
+                    Manifest.permission.WRITE_EXTERNAL_STORAGE
+                )
+                if (perms.any { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }) {
+                    ActivityCompat.requestPermissions(
+                        this,
+                        perms.toTypedArray(),
+                        1001
+                    )
+                }
+            }
+        }
+
+        setContent {
+            MaterialTheme {
+                Surface(modifier = Modifier.fillMaxSize()) {
+                    val viewModel by remember { mutableStateOf(FileBrowserViewModel(this@MainActivity)) }
+                    LaunchedEffect(Unit) {
+                        viewModel.setCacheDir(cacheDir.absolutePath)
+                    }
+                    FileBrowserScreen(viewModel, this)
+                }
+            }
+        }
+    }
+
+    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+        if (requestCode == 1001) {
+            if (grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
+                Toast.makeText(this, "需要存储权限才能浏览和加密文件", Toast.LENGTH_LONG).show()
+            }
+        }
+    }
+}

+ 268 - 0
androidApp/app/src/main/java/com/example/lockx/crypto/CryptoUtils.kt

@@ -0,0 +1,268 @@
+package com.example.lockx.crypto
+
+import java.io.File
+import java.util.zip.CRC32
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+object CryptoUtils {
+    private const val MAGIC = "FMLKv1!!"
+    private const val VERSION = 1
+    private const val SALT_SIZE = 16
+    private const val NONCE_SIZE = 16
+    private const val VERIFIER_SIZE = 16
+    const val LOCKED_SUFFIX = ".lockx"
+    private const val IO_CHUNK_SIZE = 4 * 1024 * 1024
+    const val TRAILER_SIZE = 80
+
+    data class TrailerMeta(
+        val chunkSize: Long,
+        val originalSize: Long,
+        val salt: ByteArray,
+        val nonce: ByteArray,
+        val verifier: ByteArray
+    )
+
+    fun isEncrypted(file: File): Boolean {
+        val meta = readTrailer(file) ?: return false
+        return file.name.endsWith(LOCKED_SUFFIX) && meta.originalSize + TRAILER_SIZE == file.length()
+    }
+
+    fun lockedName(path: String): String =
+        if (path.endsWith(LOCKED_SUFFIX)) path else "$path$LOCKED_SUFFIX"
+
+    fun unlockedName(path: String): String =
+        if (path.endsWith(LOCKED_SUFFIX)) path.removeSuffix(LOCKED_SUFFIX) else path
+
+    fun deriveKeys(password: String, salt: ByteArray): Pair<ByteArray, ByteArray> {
+        val dk = org.bouncycastle.crypto.generators.SCrypt.generate(
+            password.toByteArray(Charsets.UTF_8),
+            salt,
+            1 shl 14, 8, 1, 64
+        )
+        return dk.sliceArray(0..31) to dk.sliceArray(32..63)
+    }
+
+    fun buildVerifier(checkKey: ByteArray): ByteArray {
+        return hmacSha256(checkKey, "FMLK-PASSWORD-CHECK".toByteArray(Charsets.UTF_8)).take(VERIFIER_SIZE).toByteArray()
+    }
+
+    fun buildTrailer(chunkSize: Long, originalSize: Long, salt: ByteArray, nonce: ByteArray, verifier: ByteArray): ByteArray {
+        val head = ByteArray(TRAILER_SIZE - 4)
+        var off = 0
+        for (b in MAGIC.toByteArray()) head[off++] = b
+        head[off++] = VERSION.toByte()
+        head[off++] = 0; head[off++] = 0; head[off++] = 0
+        packBigEndian(head, off, chunkSize); off += 8
+        packBigEndian(head, off, originalSize); off += 8
+        salt.copyInto(head, off); off += 16
+        nonce.copyInto(head, off); off += 16
+        verifier.copyInto(head, off); off += 16
+        val crc = crc32(head)
+        val trailer = ByteArray(TRAILER_SIZE)
+        head.copyInto(trailer)
+        packBigEndian4(trailer, 76, crc)
+        return trailer
+    }
+
+    fun readTrailer(file: File): TrailerMeta? {
+        if (file.length() < TRAILER_SIZE) return null
+        val raw = ByteArray(TRAILER_SIZE)
+        file.inputStream().use { input ->
+            input.skip(file.length() - TRAILER_SIZE)
+            var total = 0
+            while (total < raw.size) {
+                val n = input.read(raw, total, raw.size - total)
+                if (n <= 0) break
+                total += n
+            }
+        }
+        return parseTrailer(raw)
+    }
+
+    private fun parseTrailer(raw: ByteArray): TrailerMeta? {
+        if (raw.size != TRAILER_SIZE) return null
+        var off = 0
+        val magic = raw.sliceArray(off until off + 8).toString(Charsets.UTF_8); off += 8
+        if (magic != MAGIC) return null
+        val version = raw[off++].toInt(); if (version != VERSION) return null
+        off += 3
+        val chunkSize = unpackBigEndian(raw, off); off += 8
+        val originalSize = unpackBigEndian(raw, off); off += 8
+        val salt = raw.sliceArray(off until off + 16); off += 16
+        val nonce = raw.sliceArray(off until off + 16); off += 16
+        val verifier = raw.sliceArray(off until off + 16); off += 16
+        val storedCrc = unpackBigEndian4(raw, off)
+        val expectCrc = crc32(raw.copyOfRange(0, TRAILER_SIZE - 4))
+        if (storedCrc != expectCrc) return null
+        return TrailerMeta(chunkSize, originalSize, salt, nonce, verifier)
+    }
+
+    fun encryptFile(file: File, password: String, chunkMb: Int, onProgress: ((Long, Long) -> Unit)? = null): Result<Unit> {
+        return try {
+            if (!file.exists() || !file.isFile) return Result.failure(Exception("不是有效文件"))
+            if (isEncrypted(file)) return Result.failure(Exception("已加密"))
+            if (file.length() == 0L) return Result.failure(Exception("空文件"))
+
+            val originalSize = file.length()
+            val lockedPath = lockedName(file.absolutePath)
+            val lockedFile = File(lockedPath)
+            if (lockedFile.exists() && lockedPath != file.absolutePath) {
+                return Result.failure(Exception("目标文件已存在"))
+            }
+
+            val chunkSize = chunkMb.toLong() * 1024 * 1024
+            val salt = ByteArray(SALT_SIZE).also { java.security.SecureRandom().nextBytes(it) }
+            val nonce = ByteArray(NONCE_SIZE).also { java.security.SecureRandom().nextBytes(it) }
+            val (streamKey, checkKey) = deriveKeys(password, salt)
+            val verifier = buildVerifier(checkKey)
+            val trailer = buildTrailer(chunkSize, originalSize, salt, nonce, verifier)
+            val n = if (chunkSize < originalSize) chunkSize else originalSize
+
+            file.inputStream().use { input ->
+                lockedFile.outputStream().use { output ->
+                    var processed = 0L
+                    var blockOffset = 0
+                    val bufSize = if (IO_CHUNK_SIZE < n) IO_CHUNK_SIZE.toInt() else n.toInt()
+                    val buffer = ByteArray(bufSize)
+                    while (processed < n) {
+                        val remaining = n - processed
+                        val readSize = if (buffer.size < remaining) buffer.size else remaining.toInt()
+                        val bytesRead = input.read(buffer, 0, readSize)
+                        if (bytesRead <= 0) break
+                        val plain = buffer.copyOfRange(0, bytesRead)
+                        val cipher = xorBytes(plain, streamKey, nonce, blockOffset)
+                        output.write(cipher)
+                        processed += bytesRead
+                        blockOffset += (bytesRead + 31) / 32
+                        onProgress?.invoke(processed, n)
+                    }
+                    // 剩余明文原样拷贝(只加密前 n 字节,其余保持原样)
+                    if (originalSize > n) {
+                        input.copyTo(output)
+                    }
+                    output.write(trailer)
+                }
+            }
+            if (lockedPath != file.absolutePath) file.delete()
+            Result.success(Unit)
+        } catch (e: Exception) { Result.failure(e) }
+    }
+
+    fun decryptFile(file: File, password: String, onProgress: ((Long, Long) -> Unit)? = null): Result<Unit> {
+        return try {
+            if (!file.exists() || !file.isFile) return Result.failure(Exception("不是有效文件"))
+            val meta = readTrailer(file) ?: return Result.failure(Exception("未加密"))
+            if (meta.originalSize + TRAILER_SIZE != file.length()) return Result.failure(Exception("文件损坏"))
+
+            val unlockedPath = unlockedName(file.absolutePath)
+            val unlockedFile = File(unlockedPath)
+            if (unlockedFile.exists() && unlockedPath != file.absolutePath) {
+                return Result.failure(Exception("目标文件已存在"))
+            }
+
+            val (streamKey, checkKey) = deriveKeys(password, meta.salt)
+            val verifier = buildVerifier(checkKey)
+            if (!verifier.contentEquals(meta.verifier)) return Result.failure(Exception("密码错误"))
+
+            val n = if (meta.chunkSize < meta.originalSize) meta.chunkSize else meta.originalSize
+            val tmpFile = File(unlockedFile.absolutePath + ".tmp")
+
+            file.inputStream().use { input ->
+                tmpFile.outputStream().use { output ->
+                    var processed = 0L
+                    var blockOffset = 0
+                    val bufSize = if (IO_CHUNK_SIZE < n) IO_CHUNK_SIZE.toInt() else n.toInt()
+                    val buffer = ByteArray(bufSize)
+                    while (processed < n) {
+                        val remaining = n - processed
+                        val readSize = if (buffer.size < remaining) buffer.size else remaining.toInt()
+                        val bytesRead = input.read(buffer, 0, readSize)
+                        if (bytesRead <= 0) break
+                        val cipher = buffer.copyOfRange(0, bytesRead)
+                        val plain = xorBytes(cipher, streamKey, meta.nonce, blockOffset)
+                        output.write(plain)
+                        processed += bytesRead
+                        blockOffset += (bytesRead + 31) / 32
+                        onProgress?.invoke(processed, n)
+                    }
+                    if (meta.originalSize > meta.chunkSize) input.copyTo(output)
+                }
+            }
+
+            if (unlockedPath != file.absolutePath) {
+                file.delete()
+                tmpFile.renameTo(unlockedFile)
+            } else {
+                file.delete()
+                tmpFile.renameTo(file)
+            }
+            Result.success(Unit)
+        } catch (e: Exception) { Result.failure(e) }
+    }
+
+    private fun xorBytes(data: ByteArray, key: ByteArray, nonce: ByteArray, blockOffset: Int): ByteArray {
+        if (data.isEmpty()) return ByteArray(0)
+        val out = ByteArray(data.size)
+        var i = 0
+        while (i < data.size) {
+            val blockSize = if (32 < data.size - i) 32 else data.size - i
+            val counter = blockOffset + i / 32
+            val counterBytes = counter.toLong().toLEBytes(8)
+            val combined = ByteArray(nonce.size + counterBytes.size)
+            nonce.copyInto(combined, 0)
+            counterBytes.copyInto(combined, nonce.size)
+            val ks = hmacSha256(key, combined)
+            for (j in 0 until blockSize) out[i + j] = ((data[i + j].toInt() xor ks[j].toInt()) and 0xFF).toByte()
+            i += blockSize
+        }
+        return out
+    }
+
+    private fun Long.toLEBytes(size: Int): ByteArray {
+        val b = ByteArray(size)
+        for (i in 0 until size) b[i] = ((this shr (i * 8)) and 0xFFL).toByte()
+        return b
+    }
+
+    private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
+        val mac = Mac.getInstance("HmacSHA256")
+        mac.init(SecretKeySpec(key, "HmacSHA256"))
+        return mac.doFinal(data)
+    }
+
+    /** CRC32 只占 4 字节,用独立函数避免 8 字节越界 */
+    private fun packBigEndian4(buf: ByteArray, offset: Int, value: Long) {
+        var v = value
+        for (i in 3 downTo 0) {
+            buf[offset + i] = (v and 0xFFL).toByte()
+            v = v shr 8
+        }
+    }
+
+    private fun unpackBigEndian4(buf: ByteArray, offset: Int): Long {
+        var result = 0L
+        for (i in offset until offset + 4) result = (result shl 8) or (buf[i].toLong() and 0xFFL)
+        return result
+    }
+
+    private fun packBigEndian(buf: ByteArray, offset: Int, value: Long) {
+        var v = value
+        for (i in 7 downTo 0) {
+            buf[offset + i] = (v and 0xFFL).toByte()
+            v = v shr 8
+        }
+    }
+
+    private fun unpackBigEndian(buf: ByteArray, offset: Int): Long {
+        var result = 0L
+        for (i in offset until offset + 8) result = (result shl 8) or (buf[i].toLong() and 0xFFL)
+        return result
+    }
+
+    private fun crc32(data: ByteArray): Long {
+        val crc = CRC32()
+        crc.update(data)
+        return crc.value
+    }
+}

+ 117 - 0
androidApp/app/src/main/java/com/example/lockx/network/SftpManager.kt

@@ -0,0 +1,117 @@
+package com.example.lockx.network
+
+import net.schmizz.sshj.SSHClient
+import net.schmizz.sshj.sftp.FileMode
+import net.schmizz.sshj.sftp.RemoteResourceInfo
+import net.schmizz.sshj.sftp.SFTPClient
+import net.schmizz.sshj.transport.verification.PromiscuousVerifier
+import net.schmizz.sshj.xfer.FileSystemFile
+import java.io.File
+
+data class SshConfig(
+    val host: String,
+    val port: Int = 22,
+    val username: String,
+    val password: String
+)
+
+data class SftpFile(
+    val name: String,
+    val path: String,
+    val isDirectory: Boolean,
+    val size: Long,
+    val modified: Long
+)
+
+class SftpManager {
+    private var _sshClient: SSHClient? = null
+    private var _sftp: SFTPClient? = null
+    
+    val sshClient: SSHClient? get() = _sshClient
+    val sftp: SFTPClient? get() = _sftp
+    
+    var isConnected = false
+        private set
+    var currentPath = "/"
+    var config: SshConfig? = null
+
+    fun connect(cfg: SshConfig): Result<Unit> {
+        return try {
+            disconnect()
+            val client = SSHClient().apply {
+                addHostKeyVerifier(PromiscuousVerifier())
+                connect(cfg.host, cfg.port)
+            }
+            client.authPassword(cfg.username, cfg.password)
+            val sftpClient = client.newSFTPClient()
+            _sshClient = client
+            _sftp = sftpClient
+            this.config = cfg
+            this.isConnected = true
+            this.currentPath = sftpClient.canonicalize(".")
+            Result.success(Unit)
+        } catch (e: Exception) {
+            disconnect()
+            Result.failure(e)
+        }
+    }
+
+    fun disconnect() {
+        try { _sftp?.close() } catch (_: Exception) {}
+        try { _sshClient?.disconnect() } catch (_: Exception) {}
+        _sshClient = null
+        _sftp = null
+        isConnected = false
+        config = null
+    }
+
+    fun listDirectory(path: String = currentPath): Result<List<SftpFile>> {
+        return try {
+            val s = _sftp ?: return Result.failure(Exception("未连接"))
+            val attr = s.stat(path)
+            if (attr == null) return Result.failure(Exception("路径不存在: $path"))
+            if (attr.type != FileMode.Type.DIRECTORY) {
+                return Result.failure(Exception("不是目录: $path"))
+            }
+            val entries = s.ls(path)
+            val files = entries.map { entry -> toSftpFile(entry, path) }
+            Result.success(files)
+        } catch (e: Exception) {
+            Result.failure(e)
+        }
+    }
+
+    fun downloadFile(remotePath: String, localDest: File, onProgress: ((Long, Long) -> Unit)? = null): Result<Unit> {
+        return try {
+            val s = _sftp ?: return Result.failure(Exception("未连接"))
+            val attr = s.stat(remotePath) ?: throw Exception("文件不存在")
+            val fileSize = attr.size
+            s.get(remotePath, localDest.absolutePath)
+            onProgress?.invoke(fileSize, fileSize)
+            Result.success(Unit)
+        } catch (e: Exception) {
+            localDest.delete()
+            Result.failure(e)
+        }
+    }
+
+    fun uploadFile(localFile: File, remotePath: String, onProgress: ((Long, Long) -> Unit)? = null): Result<Unit> {
+        return try {
+            val s = _sftp ?: return Result.failure(Exception("未连接"))
+            val fileSize = localFile.length()
+            s.put(FileSystemFile(localFile), remotePath)
+            onProgress?.invoke(fileSize, fileSize)
+            Result.success(Unit)
+        } catch (e: Exception) {
+            Result.failure(e)
+        }
+    }
+
+    private fun toSftpFile(entry: RemoteResourceInfo, basePath: String): SftpFile {
+        val name = entry.name
+        val isDir = entry.isDirectory()
+        val path = if (basePath == "/") "/$name" else "$basePath/$name"
+        val attr = entry.attributes
+        return SftpFile(name, path, isDir, attr.size, attr.mtime * 1000L)
+    }
+}

+ 412 - 0
androidApp/app/src/main/java/com/example/lockx/ui/FileBrowserScreen.kt

@@ -0,0 +1,412 @@
+package com.example.lockx.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.example.lockx.network.SftpFile
+import com.example.lockx.util.FileItem
+import com.example.lockx.util.FileUtils
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FileBrowserScreen(viewModel: FileBrowserViewModel, context: android.content.Context) {
+    var mode by remember { mutableStateOf<BrowserMode>(BrowserMode.Local) }
+    var showPasswordDialog by remember { mutableStateOf<Int?>(null) } // 0=encrypt, 1=decrypt
+    var showSshDialog by remember { mutableStateOf(false) }
+    var playTargetItem by remember { mutableStateOf<FileItem?>(null) }
+
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                title = { Text("LockX") },
+                actions = {
+                    if (mode == BrowserMode.Local && !viewModel.isSelectionMode) {
+                        IconButton(onClick = { viewModel.toggleSelectionMode() }) {
+                            Icon(Icons.Default.SelectAll, contentDescription = "多选")
+                        }
+                    }
+                    if (mode == BrowserMode.Sftp) {
+                        if (viewModel.sftpConnected) {
+                            IconButton(onClick = { viewModel.disconnectSsh() }) {
+                                Icon(Icons.Default.Http, contentDescription = "断开SSH")
+                            }
+                        } else {
+                            IconButton(onClick = { showSshDialog = true }) {
+                                Icon(Icons.Default.Add, contentDescription = "添加连接")
+                            }
+                        }
+                    }
+                    Row(modifier = Modifier.padding(end = 8.dp)) {
+                        FilterChip(
+                            selected = mode == BrowserMode.Local,
+                            onClick = { mode = BrowserMode.Local },
+                            label = { Text("本机") }
+                        )
+                        Spacer(modifier = Modifier.width(4.dp))
+                        FilterChip(
+                            selected = mode == BrowserMode.Sftp,
+                            onClick = { mode = BrowserMode.Sftp },
+                            label = { Text("SFTP") }
+                        )
+                    }
+                }
+            )
+        },
+        bottomBar = {
+            if (viewModel.isSelectionMode && viewModel.selectedFiles.isNotEmpty()) {
+                val count = viewModel.selectedFiles.size
+                Surface(tonalElevation = 8.dp) {
+                    Column {
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(horizontal = 12.dp, vertical = 10.dp),
+                            horizontalArrangement = Arrangement.spacedBy(8.dp),
+                            verticalAlignment = Alignment.CenterVertically
+                        ) {
+                            Text(
+                                "已选 $count 个文件",
+                                modifier = Modifier.weight(1f),
+                                style = MaterialTheme.typography.bodyMedium
+                            )
+                            OutlinedButton(
+                                onClick = { viewModel.deselectAll() },
+                                modifier = Modifier.height(36.dp)
+                            ) { Text("取消") }
+                            OutlinedButton(
+                                onClick = { showPasswordDialog = 1 },
+                                modifier = Modifier.height(36.dp)
+                            ) {
+                                Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(16.dp))
+                                Spacer(Modifier.width(4.dp))
+                                Text("批量解密")
+                            }
+                            OutlinedButton(
+                                onClick = { showPasswordDialog = 0 },
+                                modifier = Modifier.height(36.dp)
+                            ) {
+                                Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(16.dp))
+                                Spacer(Modifier.width(4.dp))
+                                Text("批量加密")
+                            }
+                            IconButton(onClick = { viewModel.clearSelection() }) {
+                                Icon(Icons.Default.Close, contentDescription = "退出选择")
+                            }
+                        }
+                    }
+                }
+            } else if (viewModel.selectedFile != null) {
+                Surface(tonalElevation = 8.dp) {
+                    Column {
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(horizontal = 12.dp, vertical = 10.dp),
+                            horizontalArrangement = Arrangement.spacedBy(8.dp),
+                            verticalAlignment = Alignment.CenterVertically
+                        ) {
+                            val label = when (val sf = viewModel.selectedFile) {
+                                is FileOpTarget.Local -> {
+                                    val f = sf.item
+                                    "${f.name}\n${FileUtils.formatSize(f.size)}"
+                                }
+                                is FileOpTarget.Sftp -> sf.file.name
+                                else -> ""
+                            }
+                            Text(
+                                label,
+                                modifier = Modifier.weight(1f),
+                                maxLines = 2,
+                                overflow = TextOverflow.Ellipsis,
+                                style = MaterialTheme.typography.bodyMedium
+                            )
+                            Column(modifier = Modifier.fillMaxWidth()) {
+                                if (viewModel.currentOperation != null || viewModel.operationState !is UiState.Idle) {
+                                    if (viewModel.currentOperation != null) {
+                                        LinearProgressIndicator(
+                                            progress = viewModel.operationProgress.toFloat(),
+                                            modifier = Modifier.fillMaxWidth().height(4.dp)
+                                        )
+                                    }
+                                    when (val os = viewModel.operationState) {
+                                        is UiState.Success -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+                                            Text(os.message, color = Color(0xFF4CAF50), style = MaterialTheme.typography.bodySmall)
+                                        }
+                                        is UiState.Error -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+                                            Text(os.message, color = Color(0xFFF44336), style = MaterialTheme.typography.bodySmall)
+                                        }
+                                        is UiState.Loading -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+                                            Text("处理中... ${(viewModel.operationProgress * 100).toInt()}%", style = MaterialTheme.typography.bodySmall)
+                                        }
+                                        else -> Unit
+                                    }
+                                }
+                                if (viewModel.currentOperation == null && viewModel.operationState is UiState.Idle) {
+                                    Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+                                        val isMedia = (viewModel.selectedFile as? FileOpTarget.Local)?.item?.let { viewModel.isMediaFile(it.name) } == true
+                                        if (isMedia) {
+                                            IconButton(onClick = { playTargetItem = viewModel.selectedFile!!.let { sf -> (sf as? FileOpTarget.Local)?.item } }) {
+                                                Icon(Icons.Default.PlayArrow, contentDescription = "播放", tint = Color(0xFF4CAF50), modifier = Modifier.size(36.dp))
+                                            }
+                                        }
+                                        OutlinedButton(
+                                            onClick = { showPasswordDialog = 0 },
+                                            enabled = viewModel.selectedFile != null,
+                                            modifier = Modifier.height(36.dp)
+                                        ) {
+                                            Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(16.dp))
+                                            Spacer(Modifier.width(4.dp))
+                                            Text("加密")
+                                        }
+                                        OutlinedButton(
+                                            onClick = { showPasswordDialog = 1 },
+                                            enabled = viewModel.selectedFile != null,
+                                            modifier = Modifier.height(36.dp)
+                                        ) {
+                                            Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(16.dp))
+                                            Spacer(Modifier.width(4.dp))
+                                            Text("解密")
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    ) { padding ->
+        Column(modifier = Modifier.padding(padding)) {
+            Surface(modifier = Modifier.fillMaxWidth()) {
+                Row(
+                    modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    if (mode == BrowserMode.Local && viewModel.localUpEnabled) {
+                        IconButton(onClick = { viewModel.goUpLocal() }) {
+                            Icon(Icons.Default.ArrowBack, contentDescription = "返回上级")
+                        }
+                    }
+                    if (mode == BrowserMode.Sftp && viewModel.sftpConnected) {
+                        IconButton(onClick = { viewModel.goUpSftp() }) {
+                            Icon(Icons.Default.ArrowBack, contentDescription = "返回上级")
+                        }
+                    }
+                    Text(
+                        when (mode) {
+                            BrowserMode.Local -> viewModel.localCurrentPath
+                            BrowserMode.Sftp -> if (viewModel.sftpConnected) viewModel.sftpCurrentPath else ""
+                        },
+                        style = MaterialTheme.typography.bodyMedium,
+                        fontWeight = FontWeight.Medium,
+                        modifier = Modifier.weight(1f),
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                    if (mode == BrowserMode.Local && viewModel.isSelectionMode) {
+                        val allSelected = viewModel.selectedFiles.size == viewModel.localFiles.size
+                        IconButton(onClick = {
+                            if (allSelected) viewModel.deselectAll() else viewModel.selectAll()
+                        }) {
+                            Icon(
+                                if (allSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank,
+                                contentDescription = if (allSelected) "取消全选" else "全选"
+                            )
+                        }
+                    }
+                    if (mode == BrowserMode.Local) {
+                        IconButton(onClick = { viewModel.refreshLocal() }) {
+                            Icon(Icons.Default.Refresh, contentDescription = "刷新")
+                        }
+                    }
+                    if (mode == BrowserMode.Sftp && viewModel.sftpConnected) {
+                        IconButton(onClick = { viewModel.refreshSftp() }) {
+                            Icon(Icons.Default.Refresh, contentDescription = "刷新")
+                        }
+                    }
+                }
+            }
+
+            when (mode) {
+                BrowserMode.Local -> LocalFileList(viewModel, onPlayClick = { playTargetItem = it })
+                BrowserMode.Sftp -> SftpFileList(viewModel, onPlayClick = {})
+            }
+        }
+    }
+
+    showPasswordDialog?.let { op ->
+        PasswordDialog(
+            title = if (op == 0) "加密文件" else "解密文件",
+            confirmRequired = op == 0,
+            onConfirm = { pw ->
+                showPasswordDialog = null
+                if (op == 0) {
+                    if (viewModel.isSelectionMode && viewModel.selectedFiles.size > 1) {
+                        viewModel.bulkEncrypt(chunkMb = 8, password = pw)
+                    } else {
+                        viewModel.encrypt(password = pw)
+                    }
+                } else {
+                    if (viewModel.isSelectionMode && viewModel.selectedFiles.size > 1) {
+                        viewModel.bulkDecrypt(password = pw)
+                    } else {
+                        viewModel.decrypt(password = pw)
+                    }
+                }
+            },
+            onCancel = { showPasswordDialog = null }
+        )
+    }
+
+    playTargetItem?.let { item ->
+        PlayDialog(
+            item = item,
+            onUseExternal = { viewModel.playFile(item) },
+            onCancel = { playTargetItem = null }
+        )
+    }
+
+    if (showSshDialog) {
+        SshConnectDialog(
+            onConnect = { cfg ->
+                showSshDialog = false
+                viewModel.connectSsh(cfg)
+            },
+            onCancel = { showSshDialog = false }
+        )
+    }
+}
+
+enum class BrowserMode { Local, Sftp }
+
+@Composable
+private fun LocalFileList(viewModel: FileBrowserViewModel, onPlayClick: (FileItem) -> Unit) {
+    if (viewModel.localFiles.isEmpty()) {
+        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+            Text("目录为空", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
+        }
+        return
+    }
+    LazyColumn(modifier = Modifier.fillMaxSize()) {
+        items(viewModel.localFiles, key = { it.path }) { item ->
+            val isSelected = viewModel.selectedFiles.contains(item.path) ||
+                (viewModel.selectedFile is FileOpTarget.Local && (viewModel.selectedFile as FileOpTarget.Local).item.path == item.path)
+            val isMedia = viewModel.isMediaFile(item.name)
+            FileRow(
+                name = item.name,
+                isDirectory = item.isDirectory,
+                size = item.size,
+                isSelected = isSelected,
+                isMedia = isMedia,
+                onClick = { viewModel.navigateLocal(item) },
+                onPlayClick = { onPlayClick(item) }
+            )
+        }
+    }
+}
+
+@Composable
+private fun SftpFileList(viewModel: FileBrowserViewModel, onPlayClick: (FileItem) -> Unit) {
+    if (!viewModel.sftpConnected) {
+        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+            Column(horizontalAlignment = Alignment.CenterHorizontally) {
+                Icon(Icons.Default.Http, contentDescription = null, modifier = Modifier.size(48.dp), tint = Color.Gray)
+                Spacer(Modifier.height(8.dp))
+                Text("未连接 SFTP 服务器", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
+            }
+        }
+        return
+    }
+    if (viewModel.sftpFiles.isEmpty()) {
+        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+            Text("目录为空", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
+        }
+        return
+    }
+    LazyColumn(modifier = Modifier.fillMaxSize()) {
+        items(viewModel.sftpFiles, key = { it.path }) { item ->
+            val isSelected = viewModel.selectedFiles.contains(item.path) ||
+                (viewModel.selectedFile is FileOpTarget.Sftp && (viewModel.selectedFile as FileOpTarget.Sftp).file.path == item.path)
+            FileRow(
+                name = item.name,
+                isDirectory = item.isDirectory,
+                size = item.size,
+                isSelected = isSelected,
+                isMedia = false,
+                onClick = { viewModel.navigateSftp(item) },
+                onPlayClick = {}
+            )
+        }
+    }
+}
+
+@Composable
+private fun FileRow(
+    name: String,
+    isDirectory: Boolean,
+    size: Long,
+    isSelected: Boolean,
+    isMedia: Boolean,
+    onClick: () -> Unit,
+    onPlayClick: () -> Unit
+) {
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick),
+        color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.Transparent
+    ) {
+        Row(
+            modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            if (isMedia || !isDirectory) {
+                Checkbox(
+                    checked = isSelected,
+                    onCheckedChange = { onClick() },
+                    modifier = Modifier.size(20.dp)
+                )
+            } else {
+                Spacer(Modifier.width(28.dp))
+            }
+            Spacer(modifier = Modifier.width(8.dp))
+            Icon(
+                imageVector = if (isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
+                contentDescription = null,
+                tint = if (isDirectory) Color(0xFF1976D2) else if (isMedia) Color(0xFF4CAF50) else Color(0xFF757575),
+                modifier = Modifier.size(28.dp)
+            )
+            Spacer(modifier = Modifier.width(12.dp))
+            Column(modifier = Modifier.weight(1f)) {
+                Text(name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
+                if (!isDirectory) {
+                    Text(FileUtils.formatSize(size), style = MaterialTheme.typography.bodySmall, color = Color.Gray)
+                }
+            }
+            if (isDirectory) {
+                Icon(Icons.Default.ChevronRight, contentDescription = null, tint = Color.Gray)
+            }
+            if (isMedia && !isDirectory) {
+                IconButton(
+                    onClick = { onPlayClick() },
+                    modifier = Modifier.size(36.dp)
+                ) {
+                    Icon(Icons.Default.PlayArrow, contentDescription = "播放", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp))
+                }
+            }
+        }
+    }
+}

+ 414 - 0
androidApp/app/src/main/java/com/example/lockx/ui/FileBrowserViewModel.kt

@@ -0,0 +1,414 @@
+package com.example.lockx.ui
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.lockx.crypto.CryptoUtils
+import com.example.lockx.network.SftpFile
+import com.example.lockx.network.SftpManager
+import com.example.lockx.network.SshConfig
+import com.example.lockx.util.FileItem
+import com.example.lockx.util.FileUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import android.os.Environment
+import android.util.Log
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+
+sealed class UiState {
+    object Idle : UiState()
+    data class Loading(val message: String) : UiState()
+    data class Success(val message: String) : UiState()
+    data class Error(val message: String) : UiState()
+}
+
+class FileBrowserViewModel(private val context: Context) : ViewModel() {
+    // --- Local ---
+    var localCurrentPath by mutableStateOf("")
+        private set
+    var localFiles by mutableStateOf<List<FileItem>>(emptyList())
+        private set
+    var localUpEnabled by mutableStateOf(false)
+        private set
+
+    // --- SFTP ---
+    val sftpManager = SftpManager()
+    var sftpCurrentPath by mutableStateOf("/")
+        private set
+    var sftpFiles by mutableStateOf<List<SftpFile>>(emptyList())
+        private set
+    var sftpConnected by mutableStateOf(false)
+        private set
+    var sftpConnecting by mutableStateOf(false)
+        private set
+
+    // --- Operation state ---
+    var currentOperation by mutableStateOf<FileOp?>(null)
+        private set
+    var operationProgress by mutableStateOf(0.0)
+        private set
+    var operationState by mutableStateOf<UiState>(UiState.Idle)
+        private set
+
+    // --- Selection ---
+    var selectedFiles by mutableStateOf<Set<String>>(emptySet())
+        private set
+    var isSelectionMode by mutableStateOf(false)
+        private set
+    var selectedFile by mutableStateOf<FileOpTarget?>(null)
+        private set
+
+    private var currentJob: Job? = null
+    private var cacheDir = "/data/local/tmp"
+
+    init {
+        localCurrentPath = runCatching {
+            val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
+            if (dir.exists() && dir.canRead()) dir.absolutePath else null
+        }.getOrNull() ?: runCatching {
+            Environment.getExternalStorageDirectory()?.absolutePath
+        }.getOrNull() ?: "/sdcard"
+        refreshLocal()
+    }
+
+    fun setCacheDir(dir: String) { cacheDir = dir }
+
+    // ======================== PERMISSION CHECK ========================
+    fun checkPermissions(): Boolean {
+        val granted = when {
+            android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R -> {
+                val hasManage = ContextCompat.checkSelfPermission(context, android.Manifest.permission.MANAGE_EXTERNAL_STORAGE) ==
+                    android.content.pm.PackageManager.PERMISSION_GRANTED
+                val isManager = android.os.Environment.isExternalStorageManager()
+                Log.d("LockX", "checkPerm R+: MANAGE=$hasManage isManager=$isManager")
+                isManager || hasManage
+            }
+            else -> {
+                val hasWrite = ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
+                    android.content.pm.PackageManager.PERMISSION_GRANTED
+                Log.d("LockX", "checkPerm pre-R: WRITE=$hasWrite")
+                hasWrite
+            }
+        }
+        if (!granted) {
+            operationState = UiState.Error("请先授予所有文件访问权限")
+            return false
+        }
+        return true
+    }
+
+    // ======================== SELECTION ========================
+    fun toggleSelection(path: String) {
+        if (isSelectionMode) {
+            val next = if (path in selectedFiles) selectedFiles - path else selectedFiles + path
+            selectedFiles = next
+        } else {
+            isSelectionMode = false
+            selectedFiles = emptySet()
+            val item = localFiles.find { it.path == path } ?: return
+            selectedFile = FileOpTarget.Local(item)
+        }
+    }
+
+    fun toggleSelectionMode() {
+        if (isSelectionMode) {
+            isSelectionMode = false
+            selectedFiles = emptySet()
+        } else {
+            isSelectionMode = true
+            selectedFiles = if (selectedFile != null) setOf(selectedFile!!.targetPath) else emptySet()
+        }
+    }
+
+    fun selectAll() { selectedFiles = localFiles.map { it.path }.toSet() }
+    fun deselectAll() { selectedFiles = emptySet(); isSelectionMode = false }
+    fun clearSelection() { selectedFiles = emptySet(); isSelectionMode = false }
+
+    // ======================== PLAY ========================
+    fun playFile(item: FileItem) {
+        val file = File(item.path)
+        if (!file.exists()) return
+        val uri = Uri.fromFile(file)
+        val mimeType = getMimeType(item.name)
+        val intent = Intent(Intent.ACTION_VIEW).apply {
+            setDataAndType(uri, mimeType)
+            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+        }
+        try { context.startActivity(Intent.createChooser(intent, "选择播放器")) } catch (_: Exception) {}
+    }
+
+    private fun getMimeType(filename: String): String {
+        val ext = MimeTypeMap.getFileExtensionFromUrl(filename)
+        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase()) ?: "application/octet-stream"
+    }
+
+    fun isMediaFile(name: String): Boolean {
+        val ext = MimeTypeMap.getFileExtensionFromUrl(name).lowercase()
+        return ext in MEDIA_EXTS
+    }
+
+    companion object {
+        private val MEDIA_EXTS = setOf(
+            "mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "ts", "m2ts",
+            "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma",
+            "jpg", "jpeg", "png", "gif", "bmp", "webp", "heic"
+        )
+    }
+
+    // ======================== LOCAL ========================
+    fun refreshLocal(path: String? = null) {
+        val dir = File(path ?: localCurrentPath)
+        localCurrentPath = dir.absolutePath
+        localFiles = FileUtils.listLocalFiles(dir)
+        localUpEnabled = dir.parentFile != null
+    }
+
+    fun navigateLocal(item: FileItem) {
+        if (isSelectionMode) { toggleSelection(item.path); return }
+        if (item.isDirectory) { refreshLocal(item.path) }
+        else { selectedFile = FileOpTarget.Local(item); selectedFiles = setOf(item.path) }
+    }
+
+    fun goUpLocal() { File(localCurrentPath).parentFile?.let { refreshLocal(it.absolutePath) } }
+
+    // ======================== SFTP ========================
+    fun connectSsh(cfg: SshConfig) {
+        sftpConnecting = true
+        operationState = UiState.Loading("正在连接 ${cfg.host}...")
+        viewModelScope.launch(Dispatchers.IO) {
+            val result = sftpManager.connect(cfg)
+            sftpConnecting = false
+            if (result.isSuccess) {
+                sftpConnected = true
+                sftpCurrentPath = sftpManager.currentPath
+                refreshSftp()
+                operationState = UiState.Success("已连接: ${cfg.host}")
+            } else {
+                sftpConnected = false
+                operationState = UiState.Error("连接失败: ${result.exceptionOrNull()?.message}")
+            }
+        }
+    }
+
+    fun disconnectSsh() { sftpManager.disconnect(); sftpConnected = false; sftpFiles = emptyList() }
+
+    fun refreshSftp(path: String? = null) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val p = path ?: sftpCurrentPath
+            val result = sftpManager.listDirectory(p)
+            if (result.isSuccess) {
+                sftpCurrentPath = p
+                sftpFiles = result.getOrDefault(emptyList())
+            } else {
+                operationState = UiState.Error("加载失败: ${result.exceptionOrNull()?.message}")
+            }
+        }
+    }
+
+    fun navigateSftp(item: SftpFile) {
+        if (item.isDirectory) { refreshSftp(item.path) }
+        else { selectedFile = FileOpTarget.Sftp(item); selectedFiles = setOf(item.path) }
+    }
+
+    fun goUpSftp() {
+        val parent = File(sftpCurrentPath).parent
+        if (parent != null && parent != sftpCurrentPath) refreshSftp(parent)
+    }
+
+    // ======================== ENCRYPT/DECRYPT ========================
+    fun encrypt(chunkMb: Int = 8, password: String) {
+        val target = selectedFile ?: run { Log.d("LockX", "encrypt: selectedFile is null"); return }
+        if (!checkPermissions()) { Toast.makeText(context, "权限不足", Toast.LENGTH_SHORT).show(); return }
+        currentOperation = FileOp.Encrypt(target, chunkMb, password)
+        operationProgress = 0.0
+        operationState = UiState.Loading("准备加密...")
+        launchSingleOperation()
+    }
+
+    fun decrypt(password: String) {
+        val target = selectedFile ?: return
+        if (!checkPermissions()) return
+        currentOperation = FileOp.Decrypt(target, password)
+        operationProgress = 0.0
+        operationState = UiState.Loading("准备解密...")
+        launchSingleOperation()
+    }
+
+    fun bulkEncrypt(chunkMb: Int, password: String) {
+        if (selectedFiles.isEmpty()) return
+        if (!checkPermissions()) return
+        val targets = selectedFiles.toList()
+        currentOperation = FileOp.BulkEncrypt(targets, chunkMb, password)
+        operationProgress = 0.0
+        operationState = UiState.Loading("准备加密 ${targets.size} 个文件...")
+        launchBulkOperation()
+    }
+
+    fun bulkDecrypt(password: String) {
+        if (selectedFiles.isEmpty()) return
+        if (!checkPermissions()) return
+        val targets = selectedFiles.toList()
+        currentOperation = FileOp.BulkDecrypt(targets, password)
+        operationProgress = 0.0
+        operationState = UiState.Loading("准备解密 ${targets.size} 个文件...")
+        launchBulkOperation()
+    }
+
+    private fun launchSingleOperation() {
+        currentJob?.cancel()
+        currentJob = viewModelScope.launch {
+            val op = currentOperation ?: run { Log.d("LockX", "launchSingleOp: op is null, returning"); return@launch }
+            Log.d("LockX", "launchSingleOp: op=${op::class.simpleName}")
+            try {
+                when (op) {
+                    is FileOp.Encrypt -> executeEncrypt(op)
+                    is FileOp.Decrypt -> executeDecrypt(op)
+                    else -> Log.d("LockX", "launchSingleOp: unknown op type")
+                }
+            } catch (e: Exception) {
+                Log.e("LockX", "launchSingleOp exception: ${e.message}", e)
+                operationState = UiState.Error("操作异常: ${e.message}")
+            }
+            currentOperation = null
+        }
+    }
+
+    private fun launchBulkOperation() {
+        Log.d("LockX", "launchBulkOperation called")
+        currentJob?.cancel()
+        currentJob = viewModelScope.launch {
+            val op = currentOperation ?: run { Log.d("LockX", "launchBulkOp: op is null, returning"); return@launch }
+            Log.d("LockX", "launchBulkOp: op=${op::class.simpleName}")
+            val targets = when (op) {
+                is FileOp.BulkEncrypt -> op.targets
+                is FileOp.BulkDecrypt -> op.targets
+                else -> emptyList()
+            }
+            val total = targets.size
+            var successCount = 0
+            var failCount = 0
+            try {
+                for ((i, path) in targets.withIndex()) {
+                    val f = File(path)
+                    if (!f.exists()) { failCount++; continue }
+                    operationState = UiState.Loading("正在处理 ${i+1}/$total: ${f.name}")
+                    operationProgress = i.toDouble() / total
+                    val result = withContext(Dispatchers.IO) {
+                        when (op) {
+                            is FileOp.BulkEncrypt -> CryptoUtils.encryptFile(f, op.password, op.chunkMb)
+                            is FileOp.BulkDecrypt -> CryptoUtils.decryptFile(f, op.password)
+                            else -> Result.failure(Exception("未知操作"))
+                        }
+                    }
+                    if (result.isSuccess) successCount++ else failCount++
+                    delay(30)
+                }
+                operationProgress = 1.0
+                if (failCount == 0) {
+                    operationState = UiState.Success("✅ 完成: $successCount 个文件加密成功")
+                } else {
+                    operationState = UiState.Error("完成: $successCount 成功, $failCount 失败")
+                }
+                refreshLocal()
+                clearSelection()
+                selectedFile = null
+            } catch (e: Exception) {
+                operationState = UiState.Error("❌ 操作异常: ${e.message}")
+                refreshLocal()
+                clearSelection()
+            }
+            currentOperation = null
+        }
+    }
+
+    private suspend fun executeEncrypt(op: FileOp.Encrypt) {
+        Log.d("LockX", "executeEncrypt start, target=$op")
+        val target = op.target
+        if (target !is FileOpTarget.Local) { Log.d("LockX", "executeEncrypt: target is not Local, returning"); return }
+        val f = File(target.item.path)
+        if (!f.exists()) {
+            operationState = UiState.Error("❌ 文件不存在: ${f.name}")
+            return
+        }
+        operationState = UiState.Loading("正在加密: ${f.name}")
+        operationProgress = 0.0
+        Log.d("LockX", "executeEncrypt: calling CryptoUtils.encryptFile, file=${f.absolutePath}, size=${f.length()}")
+        val result = withContext(Dispatchers.IO) {
+            CryptoUtils.encryptFile(f, op.password, op.chunkMb) { done, total ->
+                operationProgress = if (total > 0) done.toDouble() / total else 0.0
+            }
+        }
+        Log.d("LockX", "executeEncrypt result: success=${result.isSuccess}, msg=${result.exceptionOrNull()?.message}")
+        if (result.isSuccess) {
+            operationState = UiState.Success("✅ 加密成功: ${f.name}")
+            refreshLocal()
+            val locked = File(CryptoUtils.lockedName(f.absolutePath))
+            selectedFile = if (locked.exists()) FileOpTarget.Local(
+                FileItem(locked.name, locked.absolutePath, false, locked.length(), locked.lastModified())
+            ) else null
+        } else {
+            operationState = UiState.Error("❌ 加密失败: ${result.exceptionOrNull()?.message}")
+        }
+    }
+
+    private suspend fun executeDecrypt(op: FileOp.Decrypt) {
+        Log.d("LockX", "executeDecrypt start, target=$op")
+        val target = op.target
+        if (target !is FileOpTarget.Local) { Log.d("LockX", "executeDecrypt: target is not Local, returning"); return }
+        val f = File(target.item.path)
+        if (!f.exists()) {
+            operationState = UiState.Error("❌ 文件不存在: ${f.name}")
+            return
+        }
+        operationState = UiState.Loading("正在解密: ${f.name}")
+        operationProgress = 0.0
+        Log.d("LockX", "executeDecrypt: calling CryptoUtils.decryptFile, file=${f.absolutePath}, size=${f.length()}")
+        val result = withContext(Dispatchers.IO) {
+            CryptoUtils.decryptFile(f, op.password) { done, total ->
+                operationProgress = if (total > 0) done.toDouble() / total else 0.0
+            }
+        }
+        Log.d("LockX", "executeDecrypt result: success=${result.isSuccess}, msg=${result.exceptionOrNull()?.message}")
+        if (result.isSuccess) {
+            operationState = UiState.Success("✅ 解密成功: ${f.name}")
+            refreshLocal()
+            val unlocked = File(CryptoUtils.unlockedName(f.absolutePath))
+            selectedFile = if (unlocked.exists()) FileOpTarget.Local(
+                FileItem(unlocked.name, unlocked.absolutePath, false, unlocked.length(), unlocked.lastModified())
+            ) else null
+        } else {
+            operationState = UiState.Error("❌ 解密失败: ${result.exceptionOrNull()?.message}")
+        }
+    }
+
+    fun cancelOperation() {
+        currentJob?.cancel()
+        currentOperation = null
+        operationProgress = 0.0
+        operationState = UiState.Idle
+    }
+}
+
+sealed class FileOp {
+    data class Encrypt(val target: FileOpTarget, val chunkMb: Int, val password: String) : FileOp()
+    data class Decrypt(val target: FileOpTarget, val password: String) : FileOp()
+    data class BulkEncrypt(val targets: List<String>, val chunkMb: Int, val password: String) : FileOp()
+    data class BulkDecrypt(val targets: List<String>, val password: String) : FileOp()
+}
+
+sealed class FileOpTarget {
+    abstract val targetPath: String
+    data class Local(val item: FileItem) : FileOpTarget() { override val targetPath = item.path }
+    data class Sftp(val file: SftpFile) : FileOpTarget() { override val targetPath = file.path }
+}

+ 112 - 0
androidApp/app/src/main/java/com/example/lockx/ui/PasswordDialog.kt

@@ -0,0 +1,112 @@
+package com.example.lockx.ui
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+
+@Composable
+fun PasswordDialog(
+    title: String,
+    confirmRequired: Boolean = true,
+    onConfirm: (String) -> Unit,
+    onCancel: () -> Unit
+) {
+    var password by remember { mutableStateOf("") }
+    var confirmPassword by remember { mutableStateOf("") }
+    var showPassword by remember { mutableStateOf(false) }
+    var showError by remember { mutableStateOf(false) }
+
+    MaterialDialog(
+        title = title,
+        onDismissRequest = onCancel
+    ) {
+        Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+            OutlinedTextField(
+                value = password,
+                onValueChange = { password = it; showError = false },
+                label = { Text("密码") },
+                singleLine = true,
+                visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+                trailingIcon = {
+                    TextButton(onClick = { showPassword = !showPassword }) {
+                        Text(if (showPassword) "显示" else "隐藏")
+                    }
+                },
+                modifier = Modifier.fillMaxWidth()
+            )
+            if (confirmRequired) {
+                OutlinedTextField(
+                    value = confirmPassword,
+                    onValueChange = { confirmPassword = it; showError = false },
+                    label = { Text("确认密码") },
+                    singleLine = true,
+                    visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+                    modifier = Modifier.fillMaxWidth()
+                )
+                if (showError) {
+                    Text("两次密码不一致", color = MaterialTheme.colorScheme.error, fontSize = MaterialTheme.typography.bodySmall.fontSize)
+                }
+            }
+            if (showError && !confirmRequired) {
+                Text("密码不能为空", color = MaterialTheme.colorScheme.error, fontSize = MaterialTheme.typography.bodySmall.fontSize)
+            }
+        }
+        // Chunk size only for encrypt
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Text("加密前 N MB", style = MaterialTheme.typography.bodyMedium)
+            Slider(
+                value = 8f,
+                onValueChange = {},
+                valueRange = 1f..64f,
+                enabled = false
+            )
+            Text("8 MB", style = MaterialTheme.typography.bodyMedium)
+        }
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
+            Button(
+                onClick = {
+                    if (confirmRequired) {
+                        if (password != confirmPassword) { showError = true; return@Button }
+                    }
+                    if (password.isEmpty()) { showError = true; return@Button }
+                    onConfirm(password)
+                },
+                modifier = Modifier.weight(1f),
+                enabled = password.isNotEmpty()
+            ) { Text("确定") }
+        }
+    }
+}
+
+@Composable
+private fun MaterialDialog(
+    title: String,
+    onDismissRequest: () -> Unit,
+    content: @Composable () -> Unit
+) {
+    Dialog(onDismissRequest = onDismissRequest) {
+        Surface(
+            shape = MaterialTheme.shapes.large,
+            color = MaterialTheme.colorScheme.surface
+        ) {
+            Column(modifier = Modifier.padding(24.dp)) {
+                Text(title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp))
+                content()
+            }
+        }
+    }
+}

+ 61 - 0
androidApp/app/src/main/java/com/example/lockx/ui/PlayDialog.kt

@@ -0,0 +1,61 @@
+package com.example.lockx.ui
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.example.lockx.util.FileUtils
+import com.example.lockx.util.FileItem
+
+@Composable
+fun PlayDialog(
+    item: FileItem,
+    onUseExternal: () -> Unit,
+    onCancel: () -> Unit
+) {
+    Dialog(onDismissRequest = onCancel) {
+        Surface(
+            shape = MaterialTheme.shapes.large,
+            color = MaterialTheme.colorScheme.surface
+        ) {
+            Column(
+                modifier = Modifier.padding(24.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.spacedBy(16.dp)
+            ) {
+                Text("选择播放方式", style = MaterialTheme.typography.titleMedium)
+                Text(
+                    item.name,
+                    style = MaterialTheme.typography.bodyMedium,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.fillMaxWidth()
+                )
+                Text(FileUtils.formatSize(item.size), style = MaterialTheme.typography.bodySmall, color = Color.Gray)
+                
+                OutlinedButton(
+                    onClick = { onUseExternal(); onCancel() },
+                    modifier = Modifier.fillMaxWidth()
+                ) {
+                    Icon(Icons.Default.PlayArrow, contentDescription = null)
+                    Spacer(Modifier.width(8.dp))
+                    Text("使用外部播放器")
+                }
+                
+                OutlinedButton(
+                    onClick = { onCancel() },
+                    modifier = Modifier.fillMaxWidth()
+                ) {
+                    Text("取消")
+                }
+            }
+        }
+    }
+}

+ 41 - 0
androidApp/app/src/main/java/com/example/lockx/ui/SshConnectDialog.kt

@@ -0,0 +1,41 @@
+package com.example.lockx.ui
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.example.lockx.network.SshConfig
+
+@Composable
+fun SshConnectDialog(
+    onConnect: (SshConfig) -> Unit,
+    onCancel: () -> Unit
+) {
+    var host by remember { mutableStateOf("") }
+    var port by remember { mutableStateOf("22") }
+    var username by remember { mutableStateOf("") }
+    var password by remember { mutableStateOf("") }
+
+    Dialog(onDismissRequest = onCancel) {
+        Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) {
+            Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                Text("SSH 连接", style = MaterialTheme.typography.titleLarge)
+                OutlinedTextField(value = host, onValueChange = { host = it }, label = { Text("主机地址") }, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = port, onValueChange = { port = it }, label = { Text("端口") }, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("用户名") }, modifier = Modifier.fillMaxWidth())
+                OutlinedTextField(value = password, onValueChange = { password = it }, label = { Text("密码") }, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+                Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                    OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
+                    Button(
+                        onClick = { onConnect(SshConfig(host.trim(), port.toIntOrNull() ?: 22, username.trim(), password)) },
+                        modifier = Modifier.weight(1f),
+                        enabled = host.isNotEmpty() && username.isNotEmpty()
+                    ) { Text("连接") }
+                }
+            }
+        }
+    }
+}

+ 41 - 0
androidApp/app/src/main/java/com/example/lockx/util/FileUtils.kt

@@ -0,0 +1,41 @@
+package com.example.lockx.util
+
+import java.io.File
+
+object FileUtils {
+    fun listLocalFiles(dir: File): List<FileItem> {
+        if (!dir.exists() || !dir.isDirectory) return emptyList()
+        return dir.listFiles()?.filterNotNull()?.map { f ->
+            FileItem(
+                name = f.name,
+                path = f.absolutePath,
+                isDirectory = f.isDirectory,
+                size = if (f.isFile) f.length() else 0L,
+                modified = f.lastModified()
+            )
+        }?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) ?: emptyList()
+    }
+
+    fun formatSize(bytes: Long): String {
+        return when {
+            bytes < 1024 -> "$bytes B"
+            bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+            bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
+            else -> String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0))
+        }
+    }
+
+    fun getMediaExtension(): Set<String> = setOf(
+        ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".ts", ".m2ts",
+        ".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".wma",
+        ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic"
+    )
+}
+
+data class FileItem(
+    val name: String,
+    val path: String,
+    val isDirectory: Boolean,
+    val size: Long,
+    val modified: Long
+)

+ 18 - 0
androidApp/app/src/main/java/com/example/lockx/util/ProgressCallback.kt

@@ -0,0 +1,18 @@
+package com.example.lockx.util
+
+class ProgressCallback {
+    private var listener: ((Double) -> Unit)? = null
+    private var title: String? = null
+    private var cancellable: (() -> Boolean)? = null
+
+    fun setTitle(t: String) = apply { title = t }
+    fun setListener(fn: (Double) -> Unit) = apply { listener = fn }
+    fun setCancellable(fn: () -> Boolean) = apply { cancellable = fn }
+
+    fun report(progress: Double) {
+        listener?.invoke(progress)
+        if (cancellable?.invoke() == true) throw CancellationException("用户取消")
+    }
+
+    class CancellationException(msg: String) : RuntimeException(msg)
+}

+ 6 - 0
androidApp/app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#1B5E20" />
+    <corners android:radius="16dp" />
+</shape>

+ 13 - 0
androidApp/app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#FFFFFF"
+        android:pathData="M54,30 L70,50 L54,70 L38,50 Z" />
+    <path
+        android:fillColor="#FFFFFF"
+        android:pathData="M54,40 L62,50 L54,60 L46,50 Z" />
+</vector>

+ 5 - 0
androidApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 5 - 0
androidApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 6 - 0
androidApp/app/src/main/res/mipmap-hdpi/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#1B5E20" />
+    <corners android:radius="16dp" />
+</shape>

+ 5 - 0
androidApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="#1B5E20" />
+</shape>

+ 6 - 0
androidApp/app/src/main/res/mipmap-mdpi/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#1B5E20" />
+    <corners android:radius="16dp" />
+</shape>

+ 5 - 0
androidApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="#1B5E20" />
+</shape>

+ 6 - 0
androidApp/app/src/main/res/mipmap-xhdpi/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#1B5E20" />
+    <corners android:radius="16dp" />
+</shape>

+ 5 - 0
androidApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="#1B5E20" />
+</shape>

+ 6 - 0
androidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#1B5E20" />
+    <corners android:radius="16dp" />
+</shape>

+ 5 - 0
androidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="#1B5E20" />
+</shape>

+ 6 - 0
androidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#1B5E20" />
+    <corners android:radius="16dp" />
+</shape>

+ 5 - 0
androidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="#1B5E20" />
+</shape>

+ 9 - 0
androidApp/app/src/main/res/values/colors.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="primary">#1B5E20</color>
+    <color name="primary_variant">#2E7D32</color>
+    <color name="secondary">#FF6D00</color>
+    <color name="bg_dark">#121212</color>
+    <color name="surface_dark">#1E1E1E</color>
+    <color name="on_primary">#FFFFFF</color>
+</resources>

+ 3 - 0
androidApp/app/src/main/res/values/strings.xml

@@ -0,0 +1,3 @@
+<resources>
+    <string name="app_name">LockX</string>
+</resources>

+ 9 - 0
androidApp/app/src/main/res/values/themes.xml

@@ -0,0 +1,9 @@
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Theme.LockX" parent="Theme.Material3.DayNight.NoActionBar">
+        <item name="colorPrimary">@color/primary</item>
+        <item name="colorPrimaryVariant">@color/primary_variant</item>
+        <item name="colorSecondary">@color/secondary</item>
+        <item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
+        <item name="android:navigationBarColor">?attr/colorPrimaryVariant</item>
+    </style>
+</resources>

+ 4 - 0
androidApp/build.gradle.kts

@@ -0,0 +1,4 @@
+plugins {
+    id("com.android.application") version "8.2.2" apply false
+    id("org.jetbrains.kotlin.android") version "1.9.20" apply false
+}

+ 4 - 0
androidApp/gradle.properties

@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true

BIN
androidApp/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
androidApp/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=60000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 4 - 0
androidApp/gradlew

@@ -0,0 +1,4 @@
+#!/bin/sh
+GRADLE_LIB="$HOME/.gradle/wrapper/dists/gradle-8.7-bin/bhs2wmbdwecv87pi65oeuq5iu/gradle-8.7/lib/gradle-launcher-8.7.jar"
+WRAPPER_JAR="$(dirname "$0")/gradle/wrapper/gradle-wrapper.jar"
+exec java -Xmx1g -Xms1g -classpath "$WRAPPER_JAR:$GRADLE_LIB" org.gradle.wrapper.GradleWrapperMain "$@"

+ 17 - 0
androidApp/settings.gradle.kts

@@ -0,0 +1,17 @@
+pluginManagement {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "LockX"
+include(":app")

+ 224 - 286
fast_media_lock.py

@@ -1,17 +1,14 @@
 #!/usr/bin/env python3
 """
 Fast partial media locker (cross-platform, Python stdlib only).
-
 Goal:
 - Very fast "lock/unlock" by encrypting only the first N MB of each file.
 - No separate key file; password only.
 - Metadata is embedded in each encrypted file tail, so files can be moved anywhere
   and still be unlocked by this script.
-
 Security note:
 - This is NOT full-file encryption. It is designed for speed/obfuscation.
 """
-
 from __future__ import annotations
 
 import argparse
@@ -23,6 +20,7 @@ import os
 import struct
 import sys
 import threading
+import time
 import zlib
 from pathlib import Path
 from typing import Iterable
@@ -33,7 +31,7 @@ SALT_SIZE = 16
 NONCE_SIZE = 16
 VERIFIER_SIZE = 16
 LOCKED_SUFFIX = ".lockx"
-IO_CHUNK_SIZE = 4 * 1024 * 1024
+IO_CHUNK_SIZE = 8 * 1024 * 1024  # 8MB,兼顾速度与内存
 
 # magic(8) + version(1) + reserved(3) + chunk_size(8) + original_size(8)
 # + salt(16) + nonce(16) + verifier(16) + crc32(4)
@@ -52,7 +50,6 @@ class LockerError(Exception):
 
 
 def _derive_keys(password: str, salt: bytes) -> tuple[bytes, bytes]:
-    # scrypt is in stdlib and works on Linux/Windows without extra dependencies.
     km = hashlib.scrypt(
         password.encode("utf-8"),
         salt=salt,
@@ -64,35 +61,21 @@ def _derive_keys(password: str, salt: bytes) -> tuple[bytes, bytes]:
     return km[:32], km[32:]
 
 
-def _keystream(stream_key: bytes, nonce: bytes, length: int) -> bytes:
-    if length <= 0:
-        return b""
-    blocks = (length + 31) // 32
-    out = bytearray(blocks * 32)
-    mv = memoryview(out)
-    off = 0
-    for counter in range(blocks):
-        block = hmac.digest(stream_key, nonce + counter.to_bytes(8, "big"), hashlib.sha256)
-        mv[off : off + 32] = block
-        off += 32
-    return bytes(mv[:length])
-
-
 def _xor_bytes(data: bytes, stream_key: bytes, nonce: bytes, block_offset: int = 0) -> bytes:
+    """与旧版本完全兼容的密钥流 XOR(从 block_offset 开始)"""
     if not data:
         return b""
     out = bytearray(len(data))
     mv_in = memoryview(data)
     mv_out = memoryview(out)
     data_len = len(data)
-
-    # 分块 XOR,避免一次性把整段数据转成大整数。
     for off in range(0, data_len, 32):
         block_len = min(32, data_len - off)
-        ks = hmac.digest(stream_key, nonce + (block_offset + off // 32).to_bytes(8, "big"), hashlib.sha256)
-        chunk = int.from_bytes(mv_in[off : off + block_len], "little") ^ int.from_bytes(ks[:block_len], "little")
-        mv_out[off : off + block_len] = chunk.to_bytes(block_len, "little")
-
+        counter = block_offset + (off // 32)
+        ks = hmac.digest(stream_key, nonce + counter.to_bytes(8, "big"), hashlib.sha256)
+        # 32 字节整数 XOR,比纯字节循环快
+        chunk = int.from_bytes(mv_in[off:off + block_len], "little") ^ int.from_bytes(ks[:block_len], "little")
+        mv_out[off:off + block_len] = chunk.to_bytes(block_len, "little")
     return bytes(out)
 
 
@@ -119,16 +102,12 @@ def _build_trailer(chunk_size: int, original_size: int, salt: bytes, nonce: byte
 def _parse_trailer(raw: bytes) -> dict | None:
     if len(raw) != TRAILER_SIZE:
         return None
-
     magic, version, _reserved, chunk_size, original_size, salt, nonce, verifier, crc = TRAILER_STRUCT.unpack(raw)
-
     if magic != MAGIC or version != VERSION:
         return None
-
     expect_crc = zlib.crc32(raw[:-4]) & 0xFFFFFFFF
     if crc != expect_crc:
         return None
-
     return {
         "chunk_size": chunk_size,
         "original_size": original_size,
@@ -142,7 +121,6 @@ def _read_trailer(path: Path) -> dict | None:
     size = path.stat().st_size
     if size < TRAILER_SIZE:
         return None
-
     with path.open("rb") as f:
         f.seek(-TRAILER_SIZE, os.SEEK_END)
         raw = f.read(TRAILER_SIZE)
@@ -153,9 +131,7 @@ def is_encrypted(path: Path) -> bool:
     meta = _read_trailer(path)
     if not meta:
         return False
-
     size = path.stat().st_size
-    # Basic sanity: encrypted file size should be original + trailer.
     return meta["original_size"] + TRAILER_SIZE == size
 
 
@@ -163,7 +139,6 @@ def _iter_files(target: Path, all_files: bool) -> Iterable[Path]:
     if target.is_file():
         yield target
         return
-
     for p in target.rglob("*"):
         if not p.is_file():
             continue
@@ -179,18 +154,15 @@ def _iter_files_by_names(target: Path, names: list[str], all_files: bool) -> Ite
         selected: set[Path] = set()
         raw_names = [n.strip() for n in names if n.strip()]
         basename_set = {n for n in raw_names if "/" not in n and "\\" not in n}
-
         for raw in raw_names:
             if "/" in raw or "\\" in raw:
                 rel = Path(raw)
                 cand = (target / rel).resolve()
                 if cand.is_file():
                     selected.add(cand)
-
         for p in _iter_files(target, all_files=all_files):
             if p.name in basename_set:
                 selected.add(p.resolve())
-
     for p in sorted(selected):
         if not all_files and p.suffix.lower() not in MEDIA_EXTS:
             continue
@@ -209,83 +181,73 @@ def _unlocked_name(path: Path) -> Path:
     return path
 
 
-class _ProgressWriter:
-    def __init__(self) -> None:
-        self._enabled = sys.stdout.isatty()
+class Progress:
+    """实时整体进度条(安装风格)"""
+
+    def __init__(self, total_bytes: int, total_files: int):
+        self.total_bytes = max(total_bytes, 1)
+        self.total_files = max(total_files, 1)
+        self.processed_bytes = 0
+        self.completed_files = 0
+        self.current_file = ""
         self._lock = threading.Lock()
+        self._enabled = sys.stdout.isatty()
+        self._start = time.monotonic()
         self._last_len = 0
-        self._file_mode = False
-
-    def update(self, label: str, done: int, total: int, path: Path) -> None:
-        if not self._enabled:
-            return
-
-        if not self._file_mode:
-            return
-
-        total = max(total, 1)
-        percent = (done * 100) // total
-        width = 24
-        filled = min(width, (done * width) // total)
-        bar = "#" * filled + "-" * (width - filled)
-        msg = f"{label} [{bar}] {percent:3d}% {done}/{total} {path}"
+        self._last_update = 0.0
 
+    def update(self, added_bytes: int = 0, file_done: bool = False, current: str = ""):
         with self._lock:
-            pad = max(0, self._last_len - len(msg))
-            sys.stdout.write("\r" + msg + (" " * pad))
-            sys.stdout.flush()
-            self._last_len = len(msg)
-
-    def done(self) -> None:
+            self.processed_bytes += added_bytes
+            if file_done:
+                self.completed_files += 1
+            if current:
+                self.current_file = current
+            now = time.monotonic()
+            # 限制刷新频率,避免刷屏
+            if not self._enabled or (now - self._last_update < 0.08 and not file_done):
+                return
+            self._last_update = now
+            self._render()
+
+    def _render(self):
+        elapsed = max(time.monotonic() - self._start, 0.001)
+        pct = min(100.0, self.processed_bytes * 100.0 / self.total_bytes)
+        speed = self.processed_bytes / elapsed / (1024 * 1024)  # MB/s
+
+        bar_width = 28
+        filled = int(bar_width * pct / 100)
+        bar = "█" * filled + "░" * (bar_width - filled)
+
+        name = self.current_file
+        if len(name) > 42:
+            name = "…" + name[-41:]
+
+        msg = (
+            f"\r[{bar}] {pct:5.1f}%  "
+            f"{self.completed_files}/{self.total_files} files  "
+            f"{speed:5.1f} MB/s  "
+            f"{name}"
+        )
+        pad = max(0, self._last_len - len(msg))
+        sys.stdout.write(msg + " " * pad)
+        sys.stdout.flush()
+        self._last_len = len(msg)
+
+    def finish(self):
         if not self._enabled:
             return
-
-        if not self._file_mode:
-            return
-
         with self._lock:
+            self.processed_bytes = self.total_bytes
+            self.completed_files = self.total_files
+            self._render()
             sys.stdout.write("\n")
             sys.stdout.flush()
-            self._last_len = 0
-
-    def finish_count(self) -> None:
-        if not self._enabled:
-            return
-
-        with self._lock:
-            sys.stdout.write("\n")
-            sys.stdout.flush()
-            self._last_len = 0
-
-    def set_file_mode(self, enabled: bool) -> None:
-        self._file_mode = enabled
-
-    def count_update(self, done: int, total: int) -> None:
-        if not self._enabled:
-            return
-        with self._lock:
-            msg = f"已完成: {done}/{total}"
-            pad = max(0, self._last_len - len(msg))
-            sys.stdout.write("\r" + msg + (" " * pad))
-            sys.stdout.flush()
-            self._last_len = len(msg)
-
-
-_progress = _ProgressWriter()
-
-
-def _iter_files_list(target: Path, all_files: bool) -> list[Path]:
-    return list(_iter_files(target, all_files=all_files))
 
 
-def _iter_files_by_names_list(target: Path, names: list[str], all_files: bool) -> list[Path]:
-    return list(_iter_files_by_names(target, names, all_files=all_files))
-
-
-def encrypt_file(path: Path, password: str, chunk_size: int) -> str:
+def encrypt_file(path: Path, password: str, chunk_size: int, progress: Progress | None = None) -> str:
     if not path.exists() or not path.is_file():
         return "skip(not_file)"
-
     if is_encrypted(path):
         return "skip(already_encrypted)"
 
@@ -303,33 +265,46 @@ def encrypt_file(path: Path, password: str, chunk_size: int) -> str:
     verifier = _build_verifier(check_key)
 
     n = min(chunk_size, original_size)
+    if progress:
+        progress.update(current=str(path))
 
-    with path.open("r+b") as f:
-        processed = 0
-        block_offset = 0
-        while processed < n:
-            read_len = min(IO_CHUNK_SIZE, n - processed)
-            plain = f.read(read_len)
-            if not plain:
-                break
-            cipher = _xor_bytes(plain, stream_key, nonce, block_offset=block_offset)
-            f.seek(processed)
-            f.write(cipher)
-            processed += len(plain)
-            block_offset += (len(plain) + 31) // 32
-            _progress.update("[ENC]", processed, n, path)
-        trailer = _build_trailer(chunk_size=chunk_size, original_size=original_size, salt=salt, nonce=nonce, verifier=verifier)
-        f.seek(0, os.SEEK_END)
-        f.write(trailer)
-
-    if locked_path != path:
-        path.rename(locked_path)
-
-    _progress.done()
-    return "ok"
-
-
-def decrypt_file(path: Path, password: str) -> str:
+    try:
+        with path.open("r+b") as f:
+            processed = 0
+            block_offset = 0
+            while processed < n:
+                read_len = min(IO_CHUNK_SIZE, n - processed)
+                plain = f.read(read_len)
+                if not plain:
+                    break
+                cipher = _xor_bytes(plain, stream_key, nonce, block_offset=block_offset)
+                f.seek(processed)
+                f.write(cipher)
+                processed += len(plain)
+                block_offset += (len(plain) + 31) // 32
+                if progress:
+                    progress.update(added_bytes=len(plain))
+
+            trailer = _build_trailer(
+                chunk_size=chunk_size,
+                original_size=original_size,
+                salt=salt,
+                nonce=nonce,
+                verifier=verifier,
+            )
+            f.seek(0, os.SEEK_END)
+            f.write(trailer)
+
+        if locked_path != path:
+            path.rename(locked_path)
+        if progress:
+            progress.update(file_done=True)
+        return "ok"
+    except Exception as e:
+        return f"fail({type(e).__name__})"
+
+
+def decrypt_file(path: Path, password: str, progress: Progress | None = None) -> str:
     if not path.exists() or not path.is_file():
         return "skip(not_file)"
 
@@ -351,50 +326,45 @@ def decrypt_file(path: Path, password: str) -> str:
         return "fail(wrong_password)"
 
     n = min(meta["chunk_size"], meta["original_size"])
+    if progress:
+        progress.update(current=str(path))
 
-    with path.open("r+b") as f:
-        processed = 0
-        block_offset = 0
-        while processed < n:
-            read_len = min(IO_CHUNK_SIZE, n - processed)
-            cipher = f.read(read_len)
-            if not cipher:
-                break
-            plain = _xor_bytes(cipher, stream_key, meta["nonce"], block_offset=block_offset)
-            f.seek(processed)
-            f.write(plain)
-            processed += len(cipher)
-            block_offset += (len(cipher) + 31) // 32
-            _progress.update("[DEC]", processed, n, path)
-        f.truncate(meta["original_size"])
-
-    if unlocked_path != path:
-        path.rename(unlocked_path)
-
-    _progress.done()
-    return "ok"
-
-
-def _encrypt_task(args: tuple[Path, str, int]) -> tuple[Path, str]:
-    path, password, chunk_size = args
-    return path, encrypt_file(path, password, chunk_size)
-
-
-def _decrypt_task(args: tuple[Path, str]) -> tuple[Path, str]:
-    path, password = args
-    return path, decrypt_file(path, password)
+    try:
+        with path.open("r+b") as f:
+            processed = 0
+            block_offset = 0
+            while processed < n:
+                read_len = min(IO_CHUNK_SIZE, n - processed)
+                cipher = f.read(read_len)
+                if not cipher:
+                    break
+                plain = _xor_bytes(cipher, stream_key, meta["nonce"], block_offset=block_offset)
+                f.seek(processed)
+                f.write(plain)
+                processed += len(cipher)
+                block_offset += (len(cipher) + 31) // 32
+                if progress:
+                    progress.update(added_bytes=len(cipher))
+
+            f.truncate(meta["original_size"])
+
+        if unlocked_path != path:
+            path.rename(unlocked_path)
+        if progress:
+            progress.update(file_done=True)
+        return "ok"
+    except Exception as e:
+        return f"fail({type(e).__name__})"
 
 
 def _ask_password(confirm: bool) -> str:
     pw = getpass.getpass("请输入密码: ")
     if not pw:
         raise LockerError("密码不能为空")
-
     if confirm:
         pw2 = getpass.getpass("请再次输入密码: ")
         if pw != pw2:
             raise LockerError("两次密码不一致")
-
     return pw
 
 
@@ -407,167 +377,144 @@ def _resolve_workers(workers: int) -> int:
     return workers
 
 
-def run_encrypt(target: Path, password: str, chunk_mb: int, all_files: bool, workers: int, file_progress: bool, names: list[str] | None = None) -> int:
+def _collect_files(target: Path, all_files: bool, names: list[str] | None) -> list[Path]:
+    if names:
+        return list(_iter_files_by_names(target, names, all_files=all_files))
+    return list(_iter_files(target, all_files=all_files))
+
+
+def run_encrypt(
+    target: Path,
+    password: str,
+    chunk_mb: int,
+    all_files: bool,
+    workers: int,
+    names: list[str] | None = None,
+) -> int:
     chunk_size = chunk_mb * 1024 * 1024
     worker_count = _resolve_workers(workers)
-    ok = 0
-    skipped = 0
-    failed = 0
-    _progress.set_file_mode(file_progress)
 
-    files = _iter_files_by_names_list(target, names, all_files=all_files) if names else _iter_files_list(target, all_files=all_files)
-    total_files = len(files)
+    files = _collect_files(target, all_files, names)
+    if not files:
+        print("没有找到需要处理的文件")
+        return 0
 
-    if not file_progress:
-        if worker_count == 1:
-            completed = 0
-            results = []
-            try:
-                for p in files:
-                    results.append((p, encrypt_file(p, password, chunk_size)))
-                    completed += 1
-                    _progress.count_update(completed, total_files)
-            except KeyboardInterrupt:
-                print("\n已收到中断信号,当前文件处理完成后退出", file=sys.stderr)
-        else:
-            completed = 0
-            with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
-                future_map = {ex.submit(encrypt_file, p, password, chunk_size): p for p in files}
-                results = []
-                try:
-                    for fut in concurrent.futures.as_completed(future_map):
-                        p = future_map[fut]
-                        res = fut.result()
-                        results.append((p, res))
-                        completed += 1
-                        _progress.count_update(completed, total_files)
-                except KeyboardInterrupt:
-                    print("\n已收到中断信号,等待当前文件处理完成后退出", file=sys.stderr)
-                    for fut, p in future_map.items():
-                        if fut.done():
-                            continue
-                        try:
-                            res = fut.result()
-                        except Exception as e:
-                            res = f"fail({type(e).__name__})"
-                        results.append((p, res))
-    else:
+    # 预计算总工作量(用于进度条)
+    total_bytes = 0
+    for p in files:
+        try:
+            sz = p.stat().st_size
+            total_bytes += min(chunk_size, sz)
+        except OSError:
+            pass
+
+    progress = Progress(total_bytes, len(files))
+    ok = skipped = failed = 0
+    results: list[tuple[Path, str]] = []
+
+    def task(p: Path) -> tuple[Path, str]:
+        return p, encrypt_file(p, password, chunk_size, progress)
+
+    try:
         if worker_count == 1:
-            results = ((p, encrypt_file(p, password, chunk_size)) for p in files)
+            for p in files:
+                results.append(task(p))
         else:
             with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
-                results = ex.map(_encrypt_task, ((p, password, chunk_size) for p in files))
+                futures = {ex.submit(task, p): p for p in files}
+                for fut in concurrent.futures.as_completed(futures):
+                    results.append(fut.result())
+    except KeyboardInterrupt:
+        print("\n已收到中断信号,等待当前任务结束后退出…", file=sys.stderr)
+        # 尽量收集已完成的结果
+        pass
+
+    progress.finish()
 
     for p, res in results:
         if res == "ok":
             ok += 1
-            if not file_progress and not sys.stdout.isatty():
-                print(f"[OK] encrypted: {p}")
         elif res.startswith("fail"):
             failed += 1
-            if not file_progress:
-                print(f"[FAIL] {p} -> {res}")
+            print(f"[FAIL] {p} -> {res}")
         else:
             skipped += 1
-            if not file_progress and not sys.stdout.isatty():
-                print(f"[SKIP] {p} -> {res}")
-
-    if not file_progress:
-        _progress.finish_count()
 
     print(f"完成: encrypted={ok}, failed={failed}, skipped={skipped}, chunk={chunk_mb}MB, workers={worker_count}")
     return 2 if failed else 0
 
 
-def run_decrypt(target: Path, password: str, all_files: bool, workers: int, file_progress: bool, names: list[str] | None = None) -> int:
+def run_decrypt(
+    target: Path,
+    password: str,
+    all_files: bool,
+    workers: int,
+    names: list[str] | None = None,
+) -> int:
     worker_count = _resolve_workers(workers)
-    ok = 0
-    skipped = 0
-    failed = 0
-    _progress.set_file_mode(file_progress)
 
-    files = _iter_files_by_names_list(target, names, all_files=all_files) if names else _iter_files_list(target, all_files=all_files)
-    total_files = len(files)
+    files = _collect_files(target, all_files, names)
+    if not files:
+        print("没有找到需要处理的文件")
+        return 0
 
-    if not file_progress:
-        if worker_count == 1:
-            completed = 0
-            results = []
-            try:
-                for p in files:
-                    results.append((p, decrypt_file(p, password)))
-                    completed += 1
-                    _progress.count_update(completed, total_files)
-            except KeyboardInterrupt:
-                print("\n已收到中断信号,当前文件处理完成后退出", file=sys.stderr)
-        else:
-            completed = 0
-            with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
-                future_map = {ex.submit(decrypt_file, p, password): p for p in files}
-                results = []
-                try:
-                    for fut in concurrent.futures.as_completed(future_map):
-                        p = future_map[fut]
-                        res = fut.result()
-                        results.append((p, res))
-                        completed += 1
-                        _progress.count_update(completed, total_files)
-                except KeyboardInterrupt:
-                    print("\n已收到中断信号,等待当前文件处理完成后退出", file=sys.stderr)
-                    for fut, p in future_map.items():
-                        if fut.done():
-                            continue
-                        try:
-                            res = fut.result()
-                        except Exception as e:
-                            res = f"fail({type(e).__name__})"
-                        results.append((p, res))
-    else:
+    # 解密时无法提前准确知道每个文件的 chunk,用文件大小近似(足够进度条使用)
+    total_bytes = 0
+    for p in files:
+        try:
+            total_bytes += p.stat().st_size
+        except OSError:
+            pass
+    # 实际只会处理前面的 chunk,这里给一个合理估计即可
+    total_bytes = max(total_bytes // 8, 1)  # 粗略估计
+
+    progress = Progress(total_bytes, len(files))
+    ok = skipped = failed = 0
+    results: list[tuple[Path, str]] = []
+
+    def task(p: Path) -> tuple[Path, str]:
+        return p, decrypt_file(p, password, progress)
+
+    try:
         if worker_count == 1:
-            results = ((p, decrypt_file(p, password)) for p in files)
+            for p in files:
+                results.append(task(p))
         else:
             with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
-                results = ex.map(_decrypt_task, ((p, password) for p in files))
+                futures = {ex.submit(task, p): p for p in files}
+                for fut in concurrent.futures.as_completed(futures):
+                    results.append(fut.result())
+    except KeyboardInterrupt:
+        print("\n已收到中断信号,等待当前任务结束后退出…", file=sys.stderr)
+
+    progress.finish()
 
     for p, res in results:
         if res == "ok":
             ok += 1
-            if not file_progress and not sys.stdout.isatty():
-                print(f"[OK] decrypted: {p}")
         elif res.startswith("fail"):
             failed += 1
-            if not file_progress:
-                print(f"[FAIL] {p} -> {res}")
+            print(f"[FAIL] {p} -> {res}")
         else:
             skipped += 1
-            if not file_progress and not sys.stdout.isatty():
-                print(f"[SKIP] {p} -> {res}")
-
-    if not file_progress:
-        _progress.finish_count()
 
     print(f"完成: decrypted={ok}, failed={failed}, skipped={skipped}, workers={worker_count}")
     return 2 if failed else 0
 
 
 def run_status(target: Path, all_files: bool) -> int:
-    total = 0
-    encrypted = 0
-
+    total = encrypted = 0
     for p in _iter_files(target, all_files=all_files):
         total += 1
         flag = is_encrypted(p)
         encrypted += int(flag)
         print(f"{'[ENC]' if flag else '[RAW]'} {p}")
-
     print(f"统计: total={total}, encrypted={encrypted}, raw={total - encrypted}")
     return 0
 
 
 def build_parser() -> argparse.ArgumentParser:
-    parser = argparse.ArgumentParser(
-        description="快速加密工具"
-    )
+    parser = argparse.ArgumentParser(description="快速部分加密工具(兼容旧版本)")
     sub = parser.add_subparsers(dest="cmd", required=True)
 
     for name in ("lock", "unlock", "status", "lock-name", "unlock-name"):
@@ -586,13 +533,11 @@ def build_parser() -> argparse.ArgumentParser:
         if name in ("lock-name", "unlock-name"):
             p.add_argument("names", nargs="+", help="文件名或相对路径(可一次传多个)")
         p.add_argument("--media-only", action="store_true", help="仅处理媒体后缀(默认处理所有文件)")
-        p.add_argument("--file-progress", action="store_true", help="显示单文件进度条,默认显示已完成文件数/总文件数")
         if name in ("lock", "lock-name"):
-            p.add_argument("--chunk-mb", type=int, default=1, help="加密前多少MB(默认8)")
+            p.add_argument("--chunk-mb", type=int, default=1, help="加密前多少MB(默认1)")
         if name in ("lock", "unlock", "lock-name", "unlock-name"):
             p.add_argument("--password", help="密码(不传则交互输入)")
             p.add_argument("--workers", type=int, default=0, help="并发线程数,0=自动(默认)")
-
     return parser
 
 
@@ -600,43 +545,36 @@ def main() -> int:
     parser = build_parser()
     args = parser.parse_args()
     target = Path(args.target)
-
     if not target.exists():
         print(f"目标不存在: {target}", file=sys.stderr)
         return 1
 
     try:
         all_files = not args.media_only
-
         if args.cmd in ("lock", "encrypt"):
             if args.chunk_mb <= 0:
                 raise LockerError("--chunk-mb 必须大于0")
             pw = args.password if args.password else _ask_password(confirm=True)
-            return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers, file_progress=args.file_progress)
-
+            return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers)
         if args.cmd in ("unlock", "decrypt"):
             pw = args.password if args.password else _ask_password(confirm=False)
-            return run_decrypt(target, pw, all_files=all_files, workers=args.workers, file_progress=args.file_progress)
-
+            return run_decrypt(target, pw, all_files=all_files, workers=args.workers)
         if args.cmd in ("lock-name", "encrypt-name"):
             if args.chunk_mb <= 0:
                 raise LockerError("--chunk-mb 必须大于0")
             pw = args.password if args.password else _ask_password(confirm=True)
-            return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers, file_progress=args.file_progress, names=args.names)
-
+            return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers, names=args.names)
         if args.cmd in ("unlock-name", "decrypt-name"):
             pw = args.password if args.password else _ask_password(confirm=False)
-            return run_decrypt(target, pw, all_files=all_files, workers=args.workers, file_progress=args.file_progress, names=args.names)
-
+            return run_decrypt(target, pw, all_files=all_files, workers=args.workers, names=args.names)
         return run_status(target, all_files=all_files)
-
     except LockerError as e:
         print(f"错误: {e}", file=sys.stderr)
         return 1
     except KeyboardInterrupt:
-        print("已取消", file=sys.stderr)
+        print("\n已取消", file=sys.stderr)
         return 130
 
 
 if __name__ == "__main__":
-    raise SystemExit(main())
+    raise SystemExit(main())

BIN
test.jpg