From 8c1383fe4fb41f6b986e2f3235e237daa505a3ef Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Sun, 11 Oct 2026 20:59:05 +0800 Subject: [PATCH] =?UTF-8?q?SmbFast=20v20=20(1.18.0)=EF=BC=9ASMB=20?= =?UTF-8?q?=E9=AB=98=E9=80=9F=E4=BC=A0=E8=BE=93=E5=AE=89=E5=8D=93=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SMB 3.1.1 多流条带传输(下载/上传),轮转交错写入、48MB 内存护栏 - 前台服务保活 + WiFi/CPU 锁,后台传输不断连 - HyperOS 焦点通知/超级岛适配(miui.focus.param 双轨协议) - 全屏连接编辑器:共享多选、批量建连;错误中文提示 + TCP 预探测 - 凭据 AndroidKeyStore 加密存储;失败自动/手动重试;空间预检 - 全局单层极光背景(修复切页闪烁)+ 单元测试 --- .gitignore | 17 + README.md | 55 +++ app/build.gradle.kts | 85 ++++ app/proguard-rules.pro | 21 + app/src/main/AndroidManifest.xml | 47 ++ app/src/main/java/com/smbfast/app/App.kt | 38 ++ .../main/java/com/smbfast/app/MainActivity.kt | 45 ++ .../com/smbfast/app/data/ConnectionProfile.kt | 21 + .../main/java/com/smbfast/app/data/Prefs.kt | 99 ++++ .../java/com/smbfast/app/smb/FastTransfer.kt | 280 +++++++++++ .../com/smbfast/app/smb/ShareEnumerator.kt | 223 +++++++++ .../java/com/smbfast/app/smb/SmbClientPool.kt | 110 +++++ .../java/com/smbfast/app/smb/SmbErrors.kt | 46 ++ .../main/java/com/smbfast/app/smb/SmbRepo.kt | 147 ++++++ .../smbfast/app/transfer/TransferManager.kt | 290 ++++++++++++ .../smbfast/app/transfer/TransferService.kt | 283 +++++++++++ .../main/java/com/smbfast/app/ui/AppRoot.kt | 44 ++ .../java/com/smbfast/app/ui/AppViewModel.kt | 428 +++++++++++++++++ .../com/smbfast/app/ui/components/Form.kt | 154 ++++++ .../com/smbfast/app/ui/components/Glass.kt | 336 +++++++++++++ .../smbfast/app/ui/screens/BrowserScreen.kt | 440 ++++++++++++++++++ .../smbfast/app/ui/screens/ConfirmDialog.kt | 32 ++ .../app/ui/screens/ConnectionEditScreen.kt | 392 ++++++++++++++++ .../app/ui/screens/ConnectionsScreen.kt | 249 ++++++++++ .../smbfast/app/ui/screens/SettingsScreen.kt | 260 +++++++++++ .../smbfast/app/ui/screens/TransfersScreen.kt | 286 ++++++++++++ .../java/com/smbfast/app/ui/theme/Theme.kt | 62 +++ .../java/com/smbfast/app/util/CrashLog.kt | 50 ++ .../main/java/com/smbfast/app/util/Files.kt | 97 ++++ .../res/drawable/ic_launcher_foreground.xml | 15 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 7 + app/src/main/res/xml/file_paths.xml | 6 + .../test/java/com/smbfast/app/PathsTest.kt | 31 ++ .../java/com/smbfast/app/ShareParseTest.kt | 29 ++ app/src/test/resources/netshareenum_resp.bin | Bin 0 -> 884 bytes build.gradle.kts | 5 + gradle.properties | 6 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 44309 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++ gradlew.bat | 94 ++++ settings.gradle.kts | 29 ++ smbfast.keystore | Bin 0 -> 2666 bytes 46 files changed, 5130 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/smbfast/app/App.kt create mode 100644 app/src/main/java/com/smbfast/app/MainActivity.kt create mode 100644 app/src/main/java/com/smbfast/app/data/ConnectionProfile.kt create mode 100644 app/src/main/java/com/smbfast/app/data/Prefs.kt create mode 100644 app/src/main/java/com/smbfast/app/smb/FastTransfer.kt create mode 100644 app/src/main/java/com/smbfast/app/smb/ShareEnumerator.kt create mode 100644 app/src/main/java/com/smbfast/app/smb/SmbClientPool.kt create mode 100644 app/src/main/java/com/smbfast/app/smb/SmbErrors.kt create mode 100644 app/src/main/java/com/smbfast/app/smb/SmbRepo.kt create mode 100644 app/src/main/java/com/smbfast/app/transfer/TransferManager.kt create mode 100644 app/src/main/java/com/smbfast/app/transfer/TransferService.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/AppRoot.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/AppViewModel.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/components/Form.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/components/Glass.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/screens/BrowserScreen.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/screens/ConfirmDialog.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/screens/ConnectionEditScreen.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/screens/ConnectionsScreen.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/screens/TransfersScreen.kt create mode 100644 app/src/main/java/com/smbfast/app/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/smbfast/app/util/CrashLog.kt create mode 100644 app/src/main/java/com/smbfast/app/util/Files.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/com/smbfast/app/PathsTest.kt create mode 100644 app/src/test/java/com/smbfast/app/ShareParseTest.kt create mode 100644 app/src/test/resources/netshareenum_resp.bin create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 smbfast.keystore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..395c834 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Gradle +.gradle/ +build/ + +# IDE +.idea/ +*.iml +.vscode/ +captures/ + +# Android +local.properties + +# 构建产物(APK 由分发目录/发版流程管理,不进 git) +dist/ +*.apk +*.aab diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf3bcda --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# SMB 极速 (SmbFast) + +一个以**传输速度为第一目标**的安卓 SMB 客户端,基于 SMB2/SMB3 协议(SMBJ 库)+ Jetpack Compose。 + +## 功能 + +- **连接管理**:添加/编辑/删除 NAS、Windows 共享连接,支持域账号、匿名登录,连接前可测试 +- **文件浏览**:共享目录浏览(面包屑导航)、新建文件夹、重命名、删除、按类型显示图标 +- **高速下载**:多流分块并行下载(默认 4 流、1MB 分块,可调至 16 流 / 8MB) +- **上传**:系统文件选择器上传(顺序流);本地文件多流并行上传 +- **传输管理**:实时进度、实时速度(EMA 平滑)、取消、完成后直接打开 +- **设置**:并行流数、分块大小、下载目录 + +## 为什么快 + +| 手段 | 说明 | +| --- | --- | +| SMB 3.1.1 协商 | 优先协商 SMB3(大 MTU),单请求 MaxRead/MaxWrite 可达 8MB(SMB2 仅 1MB) | +| 多流并行 | 大文件切成条带,每条带独立打开 SMB 句柄并行读取,靠 credits 在单连接上流水线并发 | +| 大分块 | 减少 RTT 往返次数,请求粒度默认 1MB、可调 8MB(自动按服务器协商值截断) | +| 连接复用 | Connection/Session/Share 全程池化,浏览无重复握手开销 | +| 关闭签名/加密 | 默认不做 SMB 签名与加密以换取吞吐(服务器强制时自动回退协商) | +| 本地零拷贝拼装 | FileChannel 定位写 + 预分配,各流直接写入自己的偏移 | + +> 实测建议:手机连 5GHz Wi-Fi,NAS 开 SMB3(Windows 8+/Server 2012+ 默认开启)。千兆内网理论 110MB/s 左右,多流可基本跑满;百兆/2.5G 环境收益更明显(单流顺序读通常只能到 30~60MB/s)。 + +## 构建 + +要求:JDK 17+,Android SDK(platform 35、build-tools 34+)。 + +```bash +./gradlew :app:assembleDebug # 产物: app/build/outputs/apk/debug/app-debug.apk +./gradlew :app:assembleRelease # 需要签名配置 +``` + +`local.properties` 中配置 `sdk.dir`。Gradle 仓库默认走阿里云镜像(`settings.gradle.kts`),国内环境无需代理。 + +## 使用 + +1. 首页点 **+** 添加连接:填服务器 IP(如 `192.168.1.10`)、共享名称(如 `share`)、用户名密码(或勾选匿名) +2. 点「测试」确认可用,然后「连接」 +3. 浏览目录,点文件右侧菜单选择 **下载** / **下载并打开** +4. 「传输」页查看实时速度;设置页可调并行流数与分块大小 + +下载保存位置:优先 `Download/SMBFast/`,受限存储设备回退到应用私有目录(设置页可见实际路径)。 + +## 已知限制 + +- SMBJ 不支持 SMB3 Multichannel(多网卡聚合),多流并行在同一 TCP 连接上实现 +- 密码明文保存在应用私有 SharedPreferences(未加密) +- 暂不支持 SMB over QUIC / 直连 TLS,未做流媒体边下边播 + +## 技术栈 + +Kotlin · Jetpack Compose (Material 3) · SMBJ 0.15.0 (SMB2/SMB3) · BouncyCastle · Gson · Coroutines diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..db166d3 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,85 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.smbfast.app" + compileSdk = 35 + buildToolsVersion = "34.0.0" + + defaultConfig { + applicationId = "com.smbfast.app" + minSdk = 26 + targetSdk = 35 + versionCode = 20 + versionName = "1.18.0" + } + + signingConfigs { + create("release") { + storeFile = rootProject.file("smbfast.keystore") + storePassword = "smbfast2026" + keyAlias = "smbfast" + keyPassword = "smbfast2026" + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + packaging { + resources.excludes += setOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE*", + "META-INF/NOTICE*", + "META-INF/AL2.0", + "META-INF/LGPL2.1", + ) + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.3") + implementation(platform("androidx.compose:compose-bom:2024.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + // SMB2/SMB3 客户端库 + implementation("com.hierynomus:smbj:0.15.0") + implementation("org.bouncycastle:bcprov-jdk18on:1.85.2") + implementation("org.slf4j:slf4j-simple:2.0.18") + + implementation("com.google.code.gson:gson:2.11.0") + + testImplementation("junit:junit:4.13.2") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..56777ad --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# ---- SMBJ / BouncyCastle / MBassador ---- +-dontwarn org.slf4j.** +-dontwarn java.beans.** +-dontwarn javax.naming.** +-dontwarn sun.security.** +-dontwarn org.bouncycastle.** +-dontwarn net.engio.mbassy.** +# Java 平台 GSS-API(SPNEGO/Kerberos 认证用),安卓无此 API,NTLM 认证不受影响 +-dontwarn org.ietf.jgss.** + +-keep class org.bouncycastle.** { *; } +-keep class com.hierynomus.** { *; } +-keep class net.engio.mbassy.** { *; } + +-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*, MethodParameters + +# ---- Gson ---- +-keep class com.google.gson.** { *; } +-keep class com.smbfast.app.data.** { ; } +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken { ; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c3af654 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/smbfast/app/App.kt b/app/src/main/java/com/smbfast/app/App.kt new file mode 100644 index 0000000..7440529 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/App.kt @@ -0,0 +1,38 @@ +package com.smbfast.app + +import android.app.Application +import com.smbfast.app.smb.SmbClientPool +import com.smbfast.app.transfer.TransferManager +import com.smbfast.app.util.CrashLog +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + +class App : Application() { + + val transferManager by lazy { TransferManager(this) } + + override fun onCreate() { + super.onCreate() + instance = this + CrashLog.init(this) + // SMBJ 依赖 BouncyCastle 提供 AES-CCM/GCM 与 CMAC + runCatching { + if (Security.getProvider("BC") == null) { + Security.addProvider(BouncyCastleProvider()) + } + } + } + + override fun onTerminate() { + SmbClientPool.shutdownAll() + super.onTerminate() + } + + companion object { + lateinit var instance: App + private set + + /** 进程级传输管理器:Activity/Service 共享同一份任务列表 */ + val transferManager: TransferManager get() = instance.transferManager + } +} diff --git a/app/src/main/java/com/smbfast/app/MainActivity.kt b/app/src/main/java/com/smbfast/app/MainActivity.kt new file mode 100644 index 0000000..54171e5 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/MainActivity.kt @@ -0,0 +1,45 @@ +package com.smbfast.app + +import android.Manifest +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.smbfast.app.ui.AppRoot +import com.smbfast.app.ui.AppViewModel +import com.smbfast.app.ui.theme.SmbFastTheme + +class MainActivity : ComponentActivity() { + + private val requestNotifPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // 后台传输需要常驻通知(Android 13+ 运行时权限,拒绝也不影响传输) + if (Build.VERSION.SDK_INT >= 33) { + requestNotifPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + // 全程暗色 UI:系统栏透明且使用浅色图标 + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + ) + setContent { + SmbFastTheme { + val vm: AppViewModel = viewModel(factory = AppViewModel.factory(applicationContext)) + Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.ui.graphics.Color.Transparent) { + AppRoot(vm) + } + } + } + } +} diff --git a/app/src/main/java/com/smbfast/app/data/ConnectionProfile.kt b/app/src/main/java/com/smbfast/app/data/ConnectionProfile.kt new file mode 100644 index 0000000..3dd8e1b --- /dev/null +++ b/app/src/main/java/com/smbfast/app/data/ConnectionProfile.kt @@ -0,0 +1,21 @@ +package com.smbfast.app.data + +import com.hierynomus.smbj.auth.AuthenticationContext + +data class ConnectionProfile( + var id: String = "", + var name: String = "", + var host: String = "", + var port: Int = 445, + var domain: String = "", + var username: String = "", + var password: String = "", + var anonymous: Boolean = false, + var share: String = "", +) { + fun authContext(): AuthenticationContext = + if (anonymous) AuthenticationContext.anonymous() + else AuthenticationContext(username, password.toCharArray(), domain.ifBlank { null }) + + fun displayHost(): String = if (port == 445) host else "$host:$port" +} diff --git a/app/src/main/java/com/smbfast/app/data/Prefs.kt b/app/src/main/java/com/smbfast/app/data/Prefs.kt new file mode 100644 index 0000000..da00d87 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/data/Prefs.kt @@ -0,0 +1,99 @@ +package com.smbfast.app.data + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.security.KeyStore +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** 传输速度相关设置 */ +data class SpeedSettings( + var streams: Int = 4, // 并行流数(每个流独立 SMB 句柄,分块并行下载) + var chunkKB: Int = 1024, // 每个请求的分块大小 KB +) + +class Prefs(context: Context) { + private val sp = context.getSharedPreferences("smbfast", Context.MODE_PRIVATE) + private val gson = Gson() + + fun loadConnections(): MutableList { + val raw = loadJson("connections") ?: return mutableListOf() + val type = object : TypeToken>() {}.type + val list = runCatching { gson.fromJson>(raw, type) } + .getOrNull() ?: mutableListOf() + // Gson 绕过构造函数可能注入 null,统一归一化 + list.forEach { c -> + c.id = c.id ?: UUID.randomUUID().toString() + c.name = c.name ?: "" + c.host = c.host ?: "" + c.domain = c.domain ?: "" + c.username = c.username ?: "" + c.password = c.password ?: "" + c.share = c.share ?: "" + if (c.port <= 0) c.port = 445 + } + return list + } + + fun saveConnections(list: List) { + sp.edit().putString("connections", Crypto.encrypt(gson.toJson(list))).apply() + } + + fun loadSettings(): SpeedSettings { + val raw = loadJson("settings") ?: return SpeedSettings() + return runCatching { gson.fromJson(raw, SpeedSettings::class.java) } + .getOrNull() ?: SpeedSettings() + } + + fun saveSettings(s: SpeedSettings) { + sp.edit().putString("settings", Crypto.encrypt(gson.toJson(s))).apply() + } + + /** 读取并解密;旧版本的明文数据原样返回,下次保存时自动加密迁移 */ + private fun loadJson(key: String): String? { + val raw = sp.getString(key, null) ?: return null + return Crypto.decrypt(raw) ?: raw + } + + /** 凭据加密:AndroidKeyStore AES/GCM,密文为 Base64(iv + cipher) */ + private object Crypto { + private const val ALIAS = "smbfast_store_key" + + private fun key(): SecretKey { + val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + gen.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + return gen.generateKey() + } + + fun encrypt(plain: String): String? = runCatching { + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, key()) + Base64.encodeToString(c.iv + c.doFinal(plain.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + }.getOrNull() + + fun decrypt(enc: String): String? = runCatching { + val raw = Base64.decode(enc, Base64.NO_WRAP) + if (raw.size <= 12) return@runCatching null + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, raw, 0, 12)) + String(c.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8) + }.getOrNull() + } +} diff --git a/app/src/main/java/com/smbfast/app/smb/FastTransfer.kt b/app/src/main/java/com/smbfast/app/smb/FastTransfer.kt new file mode 100644 index 0000000..ab0bdb0 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/smb/FastTransfer.kt @@ -0,0 +1,280 @@ +package com.smbfast.app.smb + +import com.hierynomus.msfscc.fileinformation.FileStandardInformation +import com.smbfast.app.data.ConnectionProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.StandardOpenOption +import java.util.concurrent.atomic.AtomicLong + +/** + * 高速传输引擎。 + * + * 提速手段: + * 1. SMB 3.1.1 协商(大 MTU → 更大 MaxRead/MaxWrite 单请求上限) + * 2. 每个流独立打开 SMB 句柄,把大文件切成多个"条带"并行读取, + * 依靠 SMB2 credits 机制在单条 TCP 连接上流水线并发多个请求 + * 3. 大分块(默认 1MB,可调至 8MB),减少请求次数与 RTT 影响 + * 4. 本地文件预分配 + FileChannel 定位写,避免顺序拼接 + */ +class FastTransfer { + + /** 传输进度载体,由 TransferManager 轮询展示 */ + class Progress { + val done = AtomicLong(0) + @Volatile var total: Long = 0 + } + + /** 多流分块并行下载 */ + suspend fun download( + profile: ConnectionProfile, + shareName: String, + remotePath: String, + dest: File, + streams: Int, + chunkKB: Int, + progress: Progress, + ) = withContext(Dispatchers.IO) { + val share = SmbClientPool.diskShare(profile, shareName) + val serverMaxRead = SmbClientPool.maxReadSize(profile).toLong() + val chunk = (chunkKB.coerceAtLeast(64)).toLong() * 1024L + // 单请求大小不超过服务器协商上限 + val reqSize = minOf(chunk, serverMaxRead).toInt().coerceAtLeast(64 * 1024) + + val h0 = SmbRepo.openRead(share, remotePath) + val size = h0.getFileInformation(FileStandardInformation::class.java).endOfFile + progress.total = size + + dest.parentFile?.mkdirs() + val channel = FileChannel.open( + dest.toPath(), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, + ) + try { + // 小文件没必要多流;大文件按条带并行。 + // 条带数量即并发上限:每个 async 一个条带,天然限制并行度 + val stripeCount = + if (size >= MULTISTREAM_THRESHOLD) streams.coerceIn(1, MAX_STREAMS) else 1 + val stripe = (size + stripeCount - 1L) / stripeCount + + coroutineScope { + (0 until stripeCount).map { i -> + async(Dispatchers.IO) { + val h = if (i == 0) h0 else SmbRepo.openRead(share, remotePath) + try { + var pos = i * stripe + val end = minOf(size, pos + stripe) + val buf = ByteArray(capReqSize(reqSize, stripeCount)) + while (pos < end) { + coroutineContext.ensureActive() + val want = minOf(end - pos, buf.size.toLong()).toInt() + val got = readFully(h, pos, buf, want) + if (got <= 0) throw IOException("服务器提前结束读取 (offset=$pos)") + channel.write(ByteBuffer.wrap(buf, 0, got), pos) + pos += got + progress.done.addAndGet(got.toLong()) + } + } finally { + if (i != 0) runCatching { h.close() } + } + } + }.awaitAll() + } + } finally { + runCatching { channel.force(true) } + runCatching { channel.close() } + runCatching { h0.close() } + } + } + + /** + * 上传输入流。若流底层是可定位读取的本地文件(SAF/MediaStore 选本地文件时几乎都是 + * FileInputStream),按条带多流并行写;否则退回单流顺序写。 + */ + suspend fun uploadStream( + profile: ConnectionProfile, + shareName: String, + remotePath: String, + input: InputStream, + declaredSize: Long, + streams: Int, + chunkKB: Int, + progress: Progress, + ) = withContext(Dispatchers.IO) { + val share = SmbClientPool.diskShare(profile, shareName) + val serverMaxWrite = SmbClientPool.maxWriteSize(profile).toLong() + val chunk = (chunkKB.coerceAtLeast(64)).toLong() * 1024L + val reqSize = minOf(chunk, serverMaxWrite).toInt().coerceAtLeast(64 * 1024) + + val channel = (input as? java.io.FileInputStream)?.channel + val seekable = channel != null && runCatching { channel.size() >= 0L }.getOrDefault(false) + + input.use { + if (channel == null || !seekable) { + sequentialUpload(share, remotePath, it, declaredSize, reqSize, progress) + return@withContext + } + var size = runCatching { channel.size() }.getOrDefault(0L) + if (size <= 0L) size = declaredSize + if (size <= 0L) { + sequentialUpload(share, remotePath, it, declaredSize, reqSize, progress) + return@withContext + } + progress.total = size + stripeUpload(share, remotePath, channel, size, streams, reqSize, progress) + } + } + + /** + * 多流条带上传:各流按"轮转交错"推进——任意时刻所有流的写入区间聚在 + * stripeCount*reqSize 的连续窗口内整体前移,机械盘 NAS 不会在相距甚远的 + * 磁区间来回寻道,避免速度大幅波动。首句柄负责截断旧文件,条带句柄不截断。 + */ + private suspend fun stripeUpload( + share: com.hierynomus.smbj.share.DiskShare, + remotePath: String, + channel: FileChannel, + size: Long, + streams: Int, + reqSize: Int, + progress: Progress, + ) = withContext(Dispatchers.IO) { + val h0 = SmbRepo.openWrite(share, remotePath) + try { + val stripeCount = + if (size >= MULTISTREAM_THRESHOLD) streams.coerceIn(1, MAX_STREAMS) else 1 + + coroutineScope { + (0 until stripeCount).map { i -> + async(Dispatchers.IO) { + val h = if (i == 0) h0 else SmbRepo.openWriteNoTrunc(share, remotePath) + try { + val buf = ByteArray(capReqSize(reqSize, stripeCount)) + var round = 0L + while (true) { + coroutineContext.ensureActive() + // 本流第 round 轮的全局块号 = round*stripeCount + i + val pos = (round * stripeCount + i) * reqSize.toLong() + if (pos >= size) break + val want = minOf(size - pos, buf.size.toLong()).toInt() + var got = 0 + while (got < want) { + // positioned read(pread):多协程并发安全 + val n = channel.read(ByteBuffer.wrap(buf, got, want - got), pos + got) + if (n < 0) throw IOException("本地读取提前结束 (offset=${pos + got})") + got += n + } + var off = 0 + while (off < want) { + val w = h.write(buf, pos + off, off, want - off) + if (w <= 0) throw IOException("远端写入失败 (offset=${pos + off})") + off += w.toInt() + } + progress.done.addAndGet(want.toLong()) + round++ + } + } finally { + if (i != 0) runCatching { h.close() } + } + } + }.awaitAll() + } + } finally { + runCatching { h0.close() } + } + } + + /** 不可定位流(管道/网络流)的顺序上传,单句柄逐块写 */ + private suspend fun sequentialUpload( + share: com.hierynomus.smbj.share.DiskShare, + remotePath: String, + src: InputStream, + declaredSize: Long, + reqSize: Int, + progress: Progress, + ) = withContext(Dispatchers.IO) { + val h = SmbRepo.openWrite(share, remotePath) + try { + progress.total = declaredSize + val buf = ByteArray(reqSize) + var pos = 0L + while (true) { + coroutineContext.ensureActive() + var r = 0 + while (r < buf.size) { + val n = src.read(buf, r, buf.size - r) + if (n < 0) break + r += n + } + if (r == 0) break + var off = 0 + while (off < r) { + val w = h.write(buf, pos, off, r - off) + if (w <= 0) throw IOException("远端写入失败 (offset=$pos)") + pos += w + off += w.toInt() + } + progress.done.addAndGet(r.toLong()) + } + } finally { + runCatching { h.close() } + } + } + + /** 本地文件多流并行上传(复用轮转交错条带) */ + suspend fun uploadFile( + profile: ConnectionProfile, + shareName: String, + remotePath: String, + local: File, + streams: Int, + chunkKB: Int, + progress: Progress, + ) = withContext(Dispatchers.IO) { + val share = SmbClientPool.diskShare(profile, shareName) + val serverMaxWrite = SmbClientPool.maxWriteSize(profile).toLong() + val chunk = (chunkKB.coerceAtLeast(64)).toLong() * 1024L + val reqSize = minOf(chunk, serverMaxWrite).toInt().coerceAtLeast(64 * 1024) + + val size = local.length() + progress.total = size + + val src = FileChannel.open(local.toPath(), StandardOpenOption.READ) + try { + stripeUpload(share, remotePath, src, size, streams, reqSize, progress) + } finally { + runCatching { src.close() } + } + } + + private fun readFully(h: com.hierynomus.smbj.share.File, pos: Long, buf: ByteArray, len: Int): Int { + var off = 0 + while (off < len) { + val n = h.read(buf, pos + off, off, len - off) + if (n <= 0) break + off += n + } + return off + } + + /** 内存护栏:所有条带缓冲合计不超过 48MB,防止"流数×分块"拉满时堆溢出(闪退) */ + private fun capReqSize(reqSize: Int, streams: Int): Int { + val budget = BUFFER_BUDGET_BYTES / streams.coerceIn(1, MAX_STREAMS) + return minOf(reqSize.toLong(), budget).toInt().coerceAtLeast(256 * 1024) + } + + companion object { + private const val MULTISTREAM_THRESHOLD: Long = 8L * 1024 * 1024 // 8MB 以下单流 + private const val BUFFER_BUDGET_BYTES = 48L * 1024 * 1024 + const val MAX_STREAMS = 16 + } +} diff --git a/app/src/main/java/com/smbfast/app/smb/ShareEnumerator.kt b/app/src/main/java/com/smbfast/app/smb/ShareEnumerator.kt new file mode 100644 index 0000000..77e1455 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/smb/ShareEnumerator.kt @@ -0,0 +1,223 @@ +package com.smbfast.app.smb + +import com.hierynomus.msdtyp.AccessMask +import com.hierynomus.mssmb2.SMB2CreateDisposition +import com.hierynomus.mssmb2.SMB2ImpersonationLevel +import com.hierynomus.mssmb2.SMB2ShareAccess +import com.hierynomus.smbj.share.NamedPipe +import com.hierynomus.smbj.share.PipeShare +import com.smbfast.app.data.ConnectionProfile +import com.smbfast.app.util.CrashLog +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.EnumSet + +/** + * 通过 srvsvc 管道 (DCERPC NetShareEnumAll, opnum 15) 枚举服务器共享。 + * + * 字节布局已对照 MS-SRVS/pidl 规范并在真实 fnOS(Samba) 服务器上验证: + * - Bind PDU 上下文项必须含 2 字节 p_cont_id + 1 字节 reserved + * - 请求桩:ServerName 传 NULL(部分服务器不解析字符串)、 + * InfoStruct 为 [ref] 指针不传 referent、ctr 在请求侧无 BUFFERS count + * - 响应桩为两阶段布局:先全部元素标量,再全部字符串缓冲区 + * - 部分服务器(如 fnOS)省略尾部 return 码,需容错 + */ +object ShareEnumerator { + + private val SRVSVC_UUID = byteArrayOf( + 0xC8.toByte(), 0x4F.toByte(), 0x32.toByte(), 0x4B.toByte(), + 0x70, 0x16, 0xD3.toByte(), 0x01, + 0x12, 0x78, 0x5A, 0x47, 0xBF.toByte(), 0x6E, 0xE1.toByte(), 0x88.toByte(), + ) + private val NDR_UUID = byteArrayOf( + 0x04, 0x5D, 0x88.toByte(), 0x8A.toByte(), + 0xEB.toByte(), 0x1C, 0xC9.toByte(), 0x11, + 0x9F.toByte(), 0xE8.toByte(), 0x08, 0x00, 0x2B, 0x10, 0x48, 0x60, + ) + private const val ERROR_MORE_DATA = 234 + + fun listShares(p: ConnectionProfile): List { + var lastErr: Exception? = null + repeat(2) { attempt -> + try { + val shares = rpcEnumShares(p) + if (shares.isNotEmpty()) { + CrashLog.note( + "ShareEnum(${p.host}, user=${p.username})", + "第${attempt + 1}次尝试成功: $shares", + ) + return shares + } + CrashLog.note("ShareEnum(${p.host})", "第${attempt + 1}次返回空") + } catch (e: Exception) { + lastErr = e + CrashLog.append("ShareEnum(${p.host}) 第${attempt + 1}次尝试", e) + } + if (attempt == 0) runCatching { Thread.sleep(500) } + } + val probed = probeCommonShares(p) + CrashLog.note("ShareEnum(${p.host})", "RPC 失败(${lastErr?.message}),探测降级命中: $probed") + return probed + } + + private fun rpcEnumShares(p: ConnectionProfile): List { + val session = SmbClientPool.session(p) + val pipeShare = session.connectShare("IPC$") as PipeShare + val pipe = openPipe(pipeShare) + try { + pipe.transact(buildBind()) + var resume = 0 + val names = ArrayList() + var rounds = 0 + do { + val resp = pipe.transact(buildRequest(resume)) + if ((resp[2].toInt() and 0xFF) == 3) { + throw java.io.IOException("服务器返回 RPC fault") + } + val page = parse(resp) + names.addAll(page.names) + resume = page.resume + rounds++ + } while (page.status == ERROR_MORE_DATA && page.count > 0 && rounds < 8) + return names.filter { it.isNotBlank() && !it.endsWith("$") }.distinct() + } finally { + runCatching { pipe.close() } + runCatching { pipeShare.close() } + } + } + + internal class Page(val names: List, val count: Int, val resume: Int, val status: Int) + + private fun openPipe(pipeShare: PipeShare): NamedPipe = + pipeShare.open( + "srvsvc", + SMB2ImpersonationLevel.Impersonation, + EnumSet.of(AccessMask.GENERIC_READ, AccessMask.GENERIC_WRITE), + null, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OPEN, + null, + ) + + private fun le32(v: Int): ByteArray = + ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array() + + private fun header(ptype: Int, fragLen: Int, callId: Int): ByteArray { + val b = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) + b.put(5); b.put(0); b.put(ptype.toByte()); b.put(0x03) + b.putInt(0x00000010) + b.putShort(fragLen.toShort()); b.putShort(0); b.putInt(callId) + return b.array() + } + + private fun buildBind(): ByteArray { + val body = ByteArrayOutputStream() + body.writeBytes(byteArrayOf(0xB8.toByte(), 0x0B)) // max_xmit_frag + body.writeBytes(byteArrayOf(0xB8.toByte(), 0x0B)) // max_recv_frag + body.writeBytes(ByteArray(4)) // assoc_group + body.write(1); body.writeBytes(ByteArray(3)) // 1 context + pad + body.writeBytes(byteArrayOf(0x00, 0x00)) // p_cont_id = 0 + body.write(1); body.write(0) // n_transfer_syn + reserved + body.writeBytes(SRVSVC_UUID); body.writeBytes(le32(3)) + body.writeBytes(NDR_UUID); body.writeBytes(le32(2)) + return header(11, 16 + body.size(), 2) + body.toByteArray() + } + + private fun buildRequest(resume: Int): ByteArray { + val s = ByteArrayOutputStream() + s.writeBytes(le32(0)) // ServerName = NULL + s.writeBytes(le32(1)) // Level = 1 + s.writeBytes(le32(1)) // union switch + s.writeBytes(le32(0x00020000)) // ctr referent + s.writeBytes(le32(0)) // EntriesRead = 0 + s.writeBytes(le32(0)) // Buffer = NULL + s.writeBytes(le32(1048576)) // PreferedMaximumLength + s.writeBytes(le32(0x00020000)) // resume referent + s.writeBytes(le32(resume)) + val pre = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) + pre.putInt(s.size()); pre.putShort(0); pre.putShort(15) // opnum 15 = NetShareEnumAll + return header(0, 16 + 8 + s.size(), 3) + pre.array() + s.toByteArray() + } + + private class Trio(val max: Int, val actual: Int) + + private fun readTrio(bb: ByteBuffer): Trio { + val max = bb.int + bb.int // offset + val actual = bb.int + return Trio(max, actual) + } + + private fun readStr(bb: ByteBuffer, chars: Int): String { + val len = chars * 2 + if (chars < 0 || len < 0 || bb.remaining() < len) { + throw java.io.IOException("NDR 字符串越界 chars=$chars len=$len pos=${bb.position()} rem=${bb.remaining()}") + } + val arr = ByteArray(len) + bb.get(arr) + val pad = (4 - (len % 4)) % 4 + if (bb.remaining() >= pad) bb.position(bb.position() + pad) + return String(arr, Charsets.UTF_16LE).trimEnd('\u0000') + } + + internal fun parse(pdu: ByteArray): Page { + val bb = ByteBuffer.wrap(pdu).order(ByteOrder.LITTLE_ENDIAN) + // 16 字节 RPC 头 + alloc_hint(4) + p_cont_id(2) + cancel_count(2) = 24,桩从 24 开始 + bb.position(24) + val level = bb.int + bb.int // union switch + bb.int // ctr referent + val count = bb.int // EntriesRead + bb.int // array referent + val count2 = bb.int // BUFFERS count + if (count <= 0 || count > 65536 || count2 !in 0..count) { + throw java.io.IOException("响应条目异常 level=$level count=$count count2=$count2") + } + val n = minOf(count, count2) + for (i in 0 until n) { // SCALARS: name_ref, type, comment_ref + bb.int + bb.int + bb.int + } + val names = ArrayList(n) + for (i in 0 until n) { // BUFFERS: 每个元素 name trio+data、comment trio+data 交错 + val nameTrio = readTrio(bb) + names.add(readStr(bb, nameTrio.actual)) + val cmtTrio = readTrio(bb) + if (cmtTrio.actual > 0 && bb.remaining() >= cmtTrio.actual * 2) readStr(bb, cmtTrio.actual) + } + var resume = 0 + var status = 0 + if (bb.remaining() >= 16) { + bb.int // totalentries referent + bb.int // totalentries + bb.int // resume referent + resume = bb.int + } + if (bb.remaining() >= 4) status = bb.int + return Page(names, n, resume, status) + } + + // ---------------- 常见共享名探测(降级方案) ---------------- + + private val COMMON = listOf( + "share", "public", "media", "video", "videos", "movie", "movies", + "photo", "photos", "music", "download", "downloads", "homes", "home", + "usb", "sda1", "sdb1", "nas", "data", "files", "backup", + "C\$", "D\$", "E\$", "Users", + ) + + private fun probeCommonShares(p: ConnectionProfile): List { + val found = ArrayList() + val session = SmbClientPool.session(p) + for (name in COMMON) { + runCatching { + val sh = session.connectShare(name) + found.add(name) + runCatching { sh.close() } + } + } + return found + } +} diff --git a/app/src/main/java/com/smbfast/app/smb/SmbClientPool.kt b/app/src/main/java/com/smbfast/app/smb/SmbClientPool.kt new file mode 100644 index 0000000..8d19bc4 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/smb/SmbClientPool.kt @@ -0,0 +1,110 @@ +package com.smbfast.app.smb + +import com.hierynomus.mssmb2.SMB2Dialect +import com.hierynomus.smbj.SMBClient +import com.hierynomus.smbj.SmbConfig +import com.hierynomus.smbj.connection.Connection +import com.hierynomus.smbj.session.Session +import com.hierynomus.smbj.share.DiskShare +import com.smbfast.app.data.ConnectionProfile +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * SMB 连接池:复用 SMBClient / Connection / Session / DiskShare, + * 避免每次操作重复握手(对高吞吐至关重要)。 + */ +object SmbClientPool { + + // 独立客户端实例;连接在首次访问时创建 + private val client: SMBClient by lazy { + val cfg = SmbConfig.builder() + // 优先协商 SMB 3.1.1(大 MTU、更大 MaxRead/MaxWrite、多协议协商) + .withDialects( + SMB2Dialect.SMB_3_1_1, + SMB2Dialect.SMB_3_0_2, + SMB2Dialect.SMB_3_0, + SMB2Dialect.SMB_2_1, + SMB2Dialect.SMB_2_0_2, + ) + // 单个 READ/WRITE 请求的最大载荷(实际仍受服务器协商的 MaxRead/MaxWrite 限制) + .withBufferSize(8 * 1024 * 1024) + .withTimeout(120, TimeUnit.SECONDS) // 整体请求超时 + .withSoTimeout(90, TimeUnit.SECONDS) // socket 读超时 + // 为最大化吞吐默认关闭签名与加密(可在服务端强制开启时自动回退) + .withSigningRequired(false) + .withEncryptData(false) + // 直连 IP 场景无需 DFS;多协议协商会多一个往返且是已知问题点 + .withDfsEnabled(false) + .withMultiProtocolNegotiate(false) + .withWorkStationName("android-smbfast") + .build() + SMBClient(cfg) + } + + private val sessions = ConcurrentHashMap() + private val shares = ConcurrentHashMap() + + private class ShareEntry(val session: Session, val share: DiskShare) + + private fun sessionKey(p: ConnectionProfile) = + "${p.host}:${p.port}|${p.username}|${p.domain}|${p.anonymous}" + + @Synchronized + fun session(p: ConnectionProfile): Session { + val key = sessionKey(p) + sessions[key]?.let { s -> + if (s.connection.isConnected) return s + runCatching { s.connection.close() } + sessions.remove(key) + } + val conn: Connection = client.connect(p.host, p.port) + return try { + val s = conn.authenticate(p.authContext()) + sessions[key] = s + s + } catch (e: Exception) { + runCatching { conn.close() } + throw e + } + } + + @Synchronized + fun diskShare(p: ConnectionProfile, shareName: String): DiskShare { + val key = "${sessionKey(p)}|${shareName.uppercase()}" + shares[key]?.let { e -> + if (e.session.connection.isConnected) return e.share + runCatching { e.share.close() } + shares.remove(key) + } + val s = session(p) + val disk = s.connectShare(shareName) as DiskShare + shares[key] = ShareEntry(s, disk) + return disk + } + + /** 服务器协商的最大单次读取字节数 */ + fun maxReadSize(p: ConnectionProfile): Int = + runCatching { session(p).connection.negotiatedProtocol.maxReadSize } + .getOrDefault(1024 * 1024) + + /** 服务器协商的最大单次写入字节数 */ + fun maxWriteSize(p: ConnectionProfile): Int = + runCatching { session(p).connection.negotiatedProtocol.maxWriteSize } + .getOrDefault(1024 * 1024) + + fun invalidate(p: ConnectionProfile) { + val prefix = sessionKey(p) + shares.keys.filter { it.startsWith(prefix) }.forEach { + runCatching { shares.remove(it)?.share?.close() } + } + sessions.remove(prefix)?.let { s -> runCatching { s.connection.close() } } + } + + fun shutdownAll() { + shares.keys.toList().forEach { runCatching { shares.remove(it)?.share?.close() } } + sessions.keys.toList().forEach { + runCatching { sessions.remove(it)?.connection?.close() } + } + } +} diff --git a/app/src/main/java/com/smbfast/app/smb/SmbErrors.kt b/app/src/main/java/com/smbfast/app/smb/SmbErrors.kt new file mode 100644 index 0000000..815f3ce --- /dev/null +++ b/app/src/main/java/com/smbfast/app/smb/SmbErrors.kt @@ -0,0 +1,46 @@ +package com.smbfast.app.smb + +import com.hierynomus.mserref.NtStatus +import com.hierynomus.mssmb2.SMBApiException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** 将底层异常翻译为可读的中文提示(自动穿透 SMBJ 的多层包装找根因) */ +fun friendlySmbError(e: Throwable): String { + // SMBApiException 携带 NTSTATUS,最能说明问题,优先在整条异常链上找 + var cur: Throwable? = e + while (cur != null) { + if (cur is SMBApiException) return smbStatusMessage(cur) + cur = cur.cause + } + // 再扫描异常链找网络层根因(SocketTimeout 等通常被包在两三层里) + cur = e + while (cur != null) { + when (cur) { + is UnknownHostException -> + return "无法解析主机名:请检查服务器地址是否拼写正确" + is SocketTimeoutException -> + return "连接超时:主机没有响应,请确认 IP 填写正确、手机与 NAS 在同一网络" + is ConnectException -> + return "连接被拒绝:地址可达但端口未开放,请检查 NAS 的 SMB 服务和端口" + is NoRouteToHostException -> + return "无法连接:网络不可达,请检查 IP 是否正确、是否同一网段" + else -> {} + } + cur = cur.cause + } + return e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName +} + +private fun smbStatusMessage(e: SMBApiException): String = when (e.status) { + NtStatus.STATUS_LOGON_FAILURE -> "登录失败:用户名或密码错误" + NtStatus.STATUS_LOGON_TYPE_NOT_GRANTED -> "登录被拒绝:该账号不允许此登录类型" + NtStatus.STATUS_ACCESS_DENIED -> "访问被拒绝:账号无权限" + NtStatus.STATUS_BAD_NETWORK_NAME -> "共享不存在:请检查共享名称" + NtStatus.STATUS_OBJECT_NAME_NOT_FOUND -> "文件或目录不存在" + NtStatus.STATUS_OBJECT_PATH_NOT_FOUND -> "路径不存在" + NtStatus.STATUS_SHARING_VIOLATION -> "文件被其他程序占用(共享冲突)" + else -> "SMB 错误 ${e.statusCode}: ${e.message}" +} diff --git a/app/src/main/java/com/smbfast/app/smb/SmbRepo.kt b/app/src/main/java/com/smbfast/app/smb/SmbRepo.kt new file mode 100644 index 0000000..155316a --- /dev/null +++ b/app/src/main/java/com/smbfast/app/smb/SmbRepo.kt @@ -0,0 +1,147 @@ +package com.smbfast.app.smb + +import com.hierynomus.msfscc.FileAttributes +import com.hierynomus.msfscc.fileinformation.FileIdBothDirectoryInformation +import com.hierynomus.msfscc.fileinformation.FileStandardInformation +import com.hierynomus.smbj.share.DiskShare +import com.hierynomus.smbj.share.File +import com.smbfast.app.data.ConnectionProfile + +data class SmbEntry( + val name: String, + val isDir: Boolean, + val size: Long, + val lastModified: Long, +) + +/** 浏览/管理操作封装(同步函数,调用方负责切到 IO 线程) */ +object SmbRepo { + + fun joinPath(parent: String, name: String): String = + if (parent.isBlank()) name else "$parent\\$name" + + fun parentPath(path: String): String = + path.substringBeforeLast('\\', "") + + fun displayNameOf(path: String): String = path.substringAfterLast('\\').ifBlank { path } + + fun list(p: ConnectionProfile, share: String, path: String): List { + val sh: DiskShare = SmbClientPool.diskShare(p, share) + val raw: List = sh.list(path) + return raw + .filter { !it.fileName.isNullOrBlank() && it.fileName != "." && it.fileName != ".." } + .map { + val isDir = (it.fileAttributes and FileAttributes.FILE_ATTRIBUTE_DIRECTORY.value) != 0L + SmbEntry( + name = it.fileName ?: "", + isDir = isDir, + size = if (isDir) 0L else it.endOfFile, + lastModified = runCatching { it.lastWriteTime.toEpochMillis() }.getOrDefault(0L), + ) + } + .sortedWith(compareByDescending { it.isDir }.thenBy { it.name.lowercase() }) + } + + fun fileSize(p: ConnectionProfile, share: String, path: String): Long { + val sh = SmbClientPool.diskShare(p, share) + return sh.getFileInformation(path, FileStandardInformation::class.java).endOfFile + } + + fun mkdir(p: ConnectionProfile, share: String, path: String) { + SmbClientPool.diskShare(p, share).mkdir(path) + } + + fun delete(p: ConnectionProfile, share: String, path: String, isDir: Boolean) { + val sh = SmbClientPool.diskShare(p, share) + if (isDir) sh.rmdir(path, true) else sh.rm(path) + } + + fun rename(p: ConnectionProfile, share: String, path: String, newName: String) { + val sh = SmbClientPool.diskShare(p, share) + val target = joinPath(parentPath(path), newName) + val entry = sh.open( + path, + java.util.EnumSet.of(com.hierynomus.msdtyp.AccessMask.DELETE), + null, + com.hierynomus.mssmb2.SMB2ShareAccess.ALL, + com.hierynomus.mssmb2.SMB2CreateDisposition.FILE_OPEN, + null, + ) + try { + entry.rename(target, true) + } finally { + runCatching { entry.close() } + } + } + + /** 连接 + 认证 + 打开共享并尝试列出根目录;成功返回 null,失败返回错误信息 */ + fun testConnect(p: ConnectionProfile, share: String): String? { + // 先用 5 秒短超时探测 TCP 端口:填错 IP/端口时立刻给出明确错误, + // 而不是等 SMBJ 的长超时(最长两分钟)让界面一直卡在"连接中" + runCatching { probeTcp(p.host, p.port) }.onFailure { return friendlySmbError(it) } + return try { + val sh = SmbClientPool.diskShare(p, share) + sh.list("") + SmbClientPool.invalidate(p) // 测试连接不复用,正式进入时重新建立 + null + } catch (e: Exception) { + SmbClientPool.invalidate(p) + com.smbfast.app.util.CrashLog.append("testConnect(host=${p.host}, share=$share, user=${p.username})", e) + friendlySmbError(e) + } + } + + private fun probeTcp(host: String, port: Int) { + if (host.isBlank()) throw IllegalArgumentException("请先填写服务器地址") + java.net.Socket().use { s -> + s.connect(java.net.InetSocketAddress(host, port), 5000) + } + } + + /** 远端已存在同名文件时生成不重名路径:name (1).ext,避免静默覆盖远端文件 */ + internal fun uniqueRemotePath(share: DiskShare, path: String): String { + val parent = parentPath(path) + val name = displayNameOf(path) + val dot = name.lastIndexOf('.') + val stem = if (dot > 0) name.substring(0, dot) else name + val ext = if (dot > 0) name.substring(dot) else "" + var candidate = path + var i = 1 + while (runCatching { share.fileExists(candidate) }.getOrDefault(false) && i < 100) { + candidate = if (parent.isBlank()) "$stem ($i)$ext" else "$parent\\$stem ($i)$ext" + i++ + } + return candidate + } + + internal fun openRead(share: DiskShare, path: String): File = + share.openFile( + path, + java.util.EnumSet.of(com.hierynomus.msdtyp.AccessMask.GENERIC_READ), + null, + com.hierynomus.mssmb2.SMB2ShareAccess.ALL, + com.hierynomus.mssmb2.SMB2CreateDisposition.FILE_OPEN, + null, + ) + + internal fun openWrite(share: DiskShare, path: String): File = + share.openFile( + path, + java.util.EnumSet.of(com.hierynomus.msdtyp.AccessMask.GENERIC_WRITE), + null, + com.hierynomus.mssmb2.SMB2ShareAccess.ALL, + com.hierynomus.mssmb2.SMB2CreateDisposition.FILE_OVERWRITE_IF, + null, + ) + + /** 并行条带的后续句柄:打开已存在文件续写、不截断(截断只允许发生在首个句柄) */ + internal fun openWriteNoTrunc(share: DiskShare, path: String): File = + share.openFile( + path, + java.util.EnumSet.of(com.hierynomus.msdtyp.AccessMask.GENERIC_WRITE), + null, + com.hierynomus.mssmb2.SMB2ShareAccess.ALL, + com.hierynomus.mssmb2.SMB2CreateDisposition.FILE_OPEN_IF, + null, + ) +} diff --git a/app/src/main/java/com/smbfast/app/transfer/TransferManager.kt b/app/src/main/java/com/smbfast/app/transfer/TransferManager.kt new file mode 100644 index 0000000..2e9f82e --- /dev/null +++ b/app/src/main/java/com/smbfast/app/transfer/TransferManager.kt @@ -0,0 +1,290 @@ +package com.smbfast.app.transfer + +import android.content.Context +import com.smbfast.app.data.ConnectionProfile +import com.smbfast.app.smb.FastTransfer +import com.smbfast.app.smb.friendlySmbError +import com.smbfast.app.util.Files +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import java.io.InputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.coroutineContext + +enum class Direction { DOWNLOAD, UPLOAD } + +enum class Status { RUNNING, DONE, FAILED, CANCELLED } + +data class TransferItem( + val id: String, + val name: String, + val direction: Direction, + val status: Status, + val total: Long = 0, + val done: Long = 0, + val speedBps: Long = 0, + val error: String? = null, + val localPath: String? = null, + val openWhenDone: Boolean = false, + // 重试所需源信息(仅内存,不持久化);上传流任务没有 localPath,不可重试 + val profile: ConnectionProfile? = null, + val shareName: String? = null, + val remotePath: String? = null, + val streams: Int = 4, + val chunkKB: Int = 1024, +) + +class TransferManager(private val appContext: Context) { + + private val eh = kotlinx.coroutines.CoroutineExceptionHandler { _, e -> + com.smbfast.app.util.CrashLog.append("transfer-scope", e) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + eh) + private val engine = FastTransfer() + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val jobs = ConcurrentHashMap() + private val progresses = ConcurrentHashMap() + + val downloadDir: File by lazy { Files.resolveDownloadDir(appContext) } + + fun enqueueDownload( + profile: ConnectionProfile, + shareName: String, + remotePath: String, + sizeHint: Long, + streams: Int, + chunkKB: Int, + openWhenDone: Boolean = false, + ) { + val name = com.smbfast.app.smb.SmbRepo.displayNameOf(remotePath) + val temp = File(downloadDir, "$name.smbpart") + // 磁盘空间预检:不够就立刻失败并说明原因,别传到 90% 才爆 + val free = downloadDir.usableSpace.takeIf { it > 0 } + if (sizeHint > 0 && free != null && sizeHint > free) { + upsert( + TransferItem( + id = newId(), name = name, direction = Direction.DOWNLOAD, status = Status.FAILED, + total = sizeHint, + error = "本地存储空间不足(需 ${Files.humanSize(sizeHint)},可用 ${Files.humanSize(free)})", + profile = profile, shareName = shareName, remotePath = remotePath, + streams = streams, chunkKB = chunkKB, + ) + ) + return + } + val id = newId() + progresses[id] = FastTransfer.Progress().also { it.total = sizeHint } + upsert( + TransferItem( + id = id, name = name, direction = Direction.DOWNLOAD, status = Status.RUNNING, + total = sizeHint, localPath = temp.absolutePath, openWhenDone = openWhenDone, + profile = profile, shareName = shareName, remotePath = remotePath, + streams = streams, chunkKB = chunkKB, + ) + ) + jobs[id] = scope.launch { + val progress = progresses[id]!! + val ticker = launch { + runCatching { tickerLoop(id, progress) } + .onFailure { com.smbfast.app.util.CrashLog.append("ticker", it) } + } + try { + try { + engine.download(profile, shareName, remotePath, temp, streams, chunkKB, progress) + } catch (ce: CancellationException) { + throw ce + } catch (first: Exception) { + // 网络闪断自动重试一次(从头下载;临时文件每次都被截断,安全) + com.smbfast.app.util.CrashLog.append("download-retry($remotePath)", first) + progress.done.set(0) + patch(id) { it.copy(status = Status.RUNNING, error = null, speedBps = 0) } + delay(1500) + engine.download(profile, shareName, remotePath, temp, streams, chunkKB, progress) + } + val final = Files.uniqueTarget(downloadDir, name) + if (!temp.renameTo(final)) { + temp.copyTo(final, overwrite = true) + temp.delete() + } + val item = _items.value.find { it.id == id } + patch(id) { + it.copy( + status = Status.DONE, done = progress.done.get(), + total = maxOf(progress.total, progress.done.get()), + speedBps = 0, localPath = final.absolutePath, + ) + } + if (item?.openWhenDone == true) { + Files.openWithViewer(appContext, final) + } + } catch (ce: CancellationException) { + patch(id) { it.copy(status = Status.CANCELLED, speedBps = 0) } + temp.delete() + } catch (e: Exception) { + com.smbfast.app.util.CrashLog.append("download($remotePath)", e) + patch(id) { it.copy(status = Status.FAILED, error = friendlySmbError(e), speedBps = 0) } + temp.delete() + } finally { + ticker.cancel() + progresses.remove(id) + } + } + } + + fun enqueueUploadStream( + profile: ConnectionProfile, + shareName: String, + remotePath: String, + input: InputStream, + declaredSize: Long, + streams: Int, + chunkKB: Int, + ) { + val name = com.smbfast.app.smb.SmbRepo.displayNameOf(remotePath) + val id = newId() + progresses[id] = FastTransfer.Progress().also { it.total = declaredSize } + upsert( + TransferItem( + id = id, name = name, direction = Direction.UPLOAD, status = Status.RUNNING, + total = declaredSize, + profile = profile, shareName = shareName, remotePath = remotePath, + streams = streams, chunkKB = chunkKB, + ) + ) + jobs[id] = scope.launch { + val progress = progresses[id]!! + val ticker = launch { tickerLoop(id, progress) } + try { + engine.uploadStream(profile, shareName, remotePath, input, declaredSize, streams, chunkKB, progress) + patch(id) { + it.copy( + status = Status.DONE, done = progress.done.get(), + total = maxOf(progress.total, progress.done.get()), speedBps = 0, + ) + } + } catch (ce: CancellationException) { + patch(id) { it.copy(status = Status.CANCELLED, speedBps = 0) } + } catch (e: Exception) { + com.smbfast.app.util.CrashLog.append("uploadStream($remotePath)", e) + patch(id) { it.copy(status = Status.FAILED, error = friendlySmbError(e), speedBps = 0) } + } finally { + ticker.cancel() + progresses.remove(id) + } + } + } + + fun enqueueUploadFile( + profile: ConnectionProfile, + shareName: String, + remotePath: String, + local: File, + streams: Int, + chunkKB: Int, + ) { + val name = local.name + val id = newId() + progresses[id] = FastTransfer.Progress().also { it.total = local.length() } + upsert( + TransferItem( + id = id, name = name, direction = Direction.UPLOAD, status = Status.RUNNING, + total = local.length(), localPath = local.absolutePath, + profile = profile, shareName = shareName, remotePath = remotePath, + streams = streams, chunkKB = chunkKB, + ) + ) + jobs[id] = scope.launch { + val progress = progresses[id]!! + val ticker = launch { tickerLoop(id, progress) } + try { + engine.uploadFile(profile, shareName, remotePath, local, streams, chunkKB, progress) + patch(id) { + it.copy(status = Status.DONE, done = progress.done.get(), total = progress.total, speedBps = 0) + } + } catch (ce: CancellationException) { + patch(id) { it.copy(status = Status.CANCELLED, speedBps = 0) } + } catch (e: Exception) { + com.smbfast.app.util.CrashLog.append("uploadFile(${local.name})", e) + patch(id) { it.copy(status = Status.FAILED, error = friendlySmbError(e), speedBps = 0) } + } finally { + ticker.cancel() + progresses.remove(id) + } + } + } + + fun cancel(id: String) { + jobs.remove(id)?.cancel() + } + + /** 取消全部进行中的任务(通知栏"全部停止"按钮) */ + fun cancelAllRunning() { + _items.value.filter { it.status == Status.RUNNING }.forEach { jobs.remove(it.id)?.cancel() } + } + + /** 失败/取消的任务一键重试:下载从头重传;仅本地上传文件可重试(流不可重放) */ + fun retry(id: String) { + val t = _items.value.find { it.id == id } ?: return + val prof = t.profile ?: return + val share = t.shareName ?: return + val remote = t.remotePath ?: return + jobs.remove(id) + _items.update { list -> list.filterNot { it.id == id } } + when (t.direction) { + Direction.DOWNLOAD -> + enqueueDownload(prof, share, remote, t.total, t.streams, t.chunkKB, t.openWhenDone) + Direction.UPLOAD -> { + val local = t.localPath?.let { File(it) } + if (local != null && local.exists()) { + enqueueUploadFile(prof, share, remote, local, t.streams, t.chunkKB) + } + } + } + } + + fun clearFinished() { + _items.update { list -> list.filter { it.status == Status.RUNNING } } + } + + private suspend fun tickerLoop(id: String, progress: FastTransfer.Progress) { + var prev = 0L + var prevT = System.currentTimeMillis() + var ema = 0.0 + while (coroutineContext.isActive) { + delay(500) + val now = System.currentTimeMillis() + val d = progress.done.get() + val dt = (now - prevT).coerceAtLeast(1) + val inst = (d - prev) * 1000.0 / dt + ema = if (ema == 0.0) inst else ema * 0.7 + inst * 0.3 + prev = d + prevT = now + patch(id) { it.copy(done = d, total = progress.total, speedBps = ema.toLong()) } + } + } + + private fun newId(): String = UUID.randomUUID().toString() + + private fun upsert(item: TransferItem) { + _items.update { list -> list + item } + } + + private fun patch(id: String, f: (TransferItem) -> TransferItem) { + _items.update { list -> list.map { if (it.id == id) f(it) else it } } + } +} diff --git a/app/src/main/java/com/smbfast/app/transfer/TransferService.kt b/app/src/main/java/com/smbfast/app/transfer/TransferService.kt new file mode 100644 index 0000000..c0af2ab --- /dev/null +++ b/app/src/main/java/com/smbfast/app/transfer/TransferService.kt @@ -0,0 +1,283 @@ +package com.smbfast.app.transfer + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.WifiLock +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.smbfast.app.App +import com.smbfast.app.MainActivity +import com.smbfast.app.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.json.JSONObject + +/** + * 传输前台服务:应用退到后台时保活进程并持有 WiFi/CPU 锁, + * 避免系统省电机制掐断 SMB 连接(SocketException: Software caused connection abort)。 + * 有任务时由 TransferManager 调用方 start(),全部结束(或取消)后自动 stopSelf()。 + */ +class TransferService : Service() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var wifiLock: WifiLock? = null + private var wakeLock: PowerManager.WakeLock? = null + private var islandSequence = 0L + private var lastNotify = 0L + private var firstIslandNotify = true + + override fun onCreate() { + super.onCreate() + createChannel() + val type = if (Build.VERSION.SDK_INT >= 29) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(null), type) + acquireLocks() + val tm = App.transferManager + scope.launch { + tm.items.collect { items -> + val running = items.filter { it.status == Status.RUNNING } + if (running.isEmpty()) { + firstIslandNotify = true + releaseLocks() + stopSelf() + return@collect + } + // 限频刷新:高频 notify 会不停触发横幅/岛动画 + val now = System.currentTimeMillis() + if (now - lastNotify >= NOTIFY_INTERVAL_MS) { + lastNotify = now + notify(buildNotification(running)) + } + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_CANCEL_ALL) { + App.transferManager.cancelAllRunning() + } + return START_STICKY + } + + override fun onDestroy() { + releaseLocks() + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= 26) { + val ch = NotificationChannel(CHANNEL_ID, "传输进度", NotificationManager.IMPORTANCE_LOW) + ch.description = "SMB 后台传输进行中" + ch.setShowBadge(false) + getSystemService(NotificationManager::class.java).createNotificationChannel(ch) + } + } + + private fun notify(n: Notification) { + // 未授予通知权限时任务照跑,只是通知条不显示 + runCatching { NotificationManagerCompat.from(this).notify(NOTIF_ID, n) } + } + + private fun buildNotification(running: List?): Notification { + val contentIntent = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val up = running?.count { it.direction == Direction.UPLOAD } ?: 0 + val down = running?.count { it.direction == Direction.DOWNLOAD } ?: 0 + val speed = running?.sumOf { it.speedBps } ?: 0L + val title = when { + running.isNullOrEmpty() -> "正在准备传输…" + up > 0 && down > 0 -> "后台传输中(↑$up ↓$down)" + up > 0 -> "后台上传中 ×$up" + else -> "后台下载中 ×$down" + } + val text = when { + running.isNullOrEmpty() -> "SMB 传输服务已就绪" + running.size == 1 -> "${running[0].name} · ${human(speed)}" + else -> "${running[0].name} 等 ${running.size} 个任务 · ${human(speed)}" + } + val b = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(contentIntent) + if (!running.isNullOrEmpty()) { + val total = running.sumOf { it.total } + val done = running.sumOf { it.done } + b.setProgress( + 100, + if (total > 0) ((done.toDouble() / total).coerceIn(0.0, 1.0) * 100).toInt() else 0, + total <= 0, + ) + val stopIntent = PendingIntent.getService( + this, 1, + Intent(this, TransferService::class.java).setAction(ACTION_CANCEL_ALL), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent)) + runCatching { + val extras = Bundle() + extras.putBundle("miui.focus.pics", islandPics()) + b.addExtras(extras) + } + } + val n = b.build() + if (!running.isNullOrEmpty()) { + // 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。 + // 非小米系统会直接忽略该字段,不影响普通通知展示。 + // islandFirstFloat 只在第一条通知为 true,否则每次更新都会重新弹出岛。 + val first = firstIslandNotify + runCatching { n.extras.putString("miui.focus.param", islandParam(running, first)) } + firstIslandNotify = false + } + return n + } + + /** 岛内图标:直接复用应用图标 */ + private fun islandPics(): Bundle = Bundle().apply { + val icon = Icon.createWithResource(applicationContext, R.mipmap.ic_launcher) + putParcelable("miui.focus.pic_ticker", icon) + putParcelable("miui.focus.pic_imageText", icon) + } + + /** + * 超级岛/焦点通知参数(miui.focus.param): + * - HyperOS 3 读 param_v2.param_island(屏幕顶部大/小岛) + * - HyperOS 2 读 param.baseInfo(状态栏焦点) + * 双轨根节点同时携带,由系统按版本自行取用。 + * 每次更新同一通知 id + sequence 递增即可刷新岛内容。 + */ + private fun islandParam(running: List, firstFloat: Boolean): String { + islandSequence += 1 + val up = running.count { it.direction == Direction.UPLOAD } + val down = running.count { it.direction == Direction.DOWNLOAD } + val speed = running.sumOf { it.speedBps } + val total = running.sumOf { it.total } + val done = running.sumOf { it.done } + val pct = if (total > 0) ((done.toDouble() / total).coerceIn(0.0, 1.0) * 100).toInt() else 0 + val dirText = when { + up > 0 && down > 0 -> "SMB 传输中" + up > 0 -> "SMB 上传中" + else -> "SMB 下载中" + } + val speedText = human(speed) + val first = running.first().name + + val textInfo = JSONObject() + .put("frontTitle", dirText) + .put("title", "$pct%") + .put("content", "$speedText · ${running.size} 个任务") + .put("useHighLight", false) + + val picInfo = JSONObject().put("type", 1).put("pic", "miui.focus.pic_imageText") + val bigLeft = JSONObject() + .put("type", 1) + .put("picInfo", picInfo) + .put("textInfo", textInfo) + + val paramIsland = JSONObject() + .put("islandProperty", 1) + .put("bigIslandArea", JSONObject().put("imageTextInfoLeft", bigLeft).put("picInfo", picInfo)) + .put("smallIslandArea", JSONObject().put("picInfo", picInfo)) + + val baseInfo = JSONObject() + .put("title", dirText) + .put("content", "$first · $pct% · $speedText") + .put("colorTitle", "#22D3EE") + .put("type", 2) + + val paramV2 = JSONObject() + .put("protocol", 1) + .put("business", "smbfast_transfer") + .put("updatable", true) + .put("enableFloat", false) // 进度高频刷新,不自动展开,避免打扰 + .put("islandFirstFloat", firstFloat) // 仅首条通知展开一次 + .put("sequence", islandSequence) + .put("ticker", "$dirText $pct% · $speedText") + .put("aodTitle", "$dirText $pct% · $speedText") + .put("tickerPic", "miui.focus.pic_ticker") + .put("aodPic", "miui.focus.pic_ticker") + .put("param_island", paramIsland) + .put("baseInfo", baseInfo) + + val paramOs2 = JSONObject().put("baseInfo", baseInfo) + + return JSONObject() + .put("param", paramOs2) + .put("param_v2", paramV2) + .toString() + } + + private fun acquireLocks() { + runCatching { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "smbfast:transfer").apply { + setReferenceCounted(false) + acquire() + } + } + runCatching { + val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY + else @Suppress("DEPRECATION") WifiManager.WIFI_MODE_FULL_HIGH_PERF + wifiLock = wm.createWifiLock(mode, "smbfast:wifi").apply { + setReferenceCounted(false) + acquire() + } + } + } + + private fun releaseLocks() { + runCatching { wakeLock?.release() } + wakeLock = null + runCatching { wifiLock?.release() } + wifiLock = null + } + + private fun human(bps: Long): String { + val kb = bps / 1024.0 + val mb = kb / 1024.0 + val gb = mb / 1024.0 + return when { + gb >= 1 -> String.format("%.1f GB/s", gb) + mb >= 1 -> String.format("%.1f MB/s", mb) + kb >= 1 -> String.format("%.0f KB/s", kb) + else -> "$bps B/s" + } + } + + companion object { + private const val CHANNEL_ID = "smbfast_transfers" + private const val NOTIF_ID = 1001 + private const val NOTIFY_INTERVAL_MS = 1500L + const val ACTION_CANCEL_ALL = "com.smbfast.app.transfer.CANCEL_ALL" + + /** 有任务入队时调用;须在应用处于前台时启动(前台服务启动限制) */ + fun start(ctx: Context) { + ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java)) + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/AppRoot.kt b/app/src/main/java/com/smbfast/app/ui/AppRoot.kt new file mode 100644 index 0000000..698a8b2 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/AppRoot.kt @@ -0,0 +1,44 @@ +package com.smbfast.app.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smbfast.app.ui.components.AuroraBackground +import com.smbfast.app.ui.screens.BrowserScreen +import com.smbfast.app.ui.screens.ConnectionEditScreen +import com.smbfast.app.ui.screens.ConnectionsScreen +import com.smbfast.app.ui.screens.SettingsScreen +import com.smbfast.app.ui.screens.TransfersScreen + +@Composable +fun AppRoot(vm: AppViewModel) { + val screen by vm.screen.collectAsStateWithLifecycle() + BackHandler(enabled = screen !is Screen.Connections) { vm.goBack() } + + // 极光背景全局只画一层,页面切换时背景保持不动(防闪烁) + AuroraBackground { + AnimatedContent( + targetState = screen, + transitionSpec = { + (fadeIn(tween(200)) + slideInHorizontally(tween(240)) { it / 8 }) + .togetherWith(fadeOut(tween(140))) + }, + label = "screens", + ) { s -> + when (s) { + is Screen.Connections -> ConnectionsScreen(vm) + is Screen.Browser -> BrowserScreen(vm) + is Screen.Transfers -> TransfersScreen(vm) + is Screen.Settings -> SettingsScreen(vm) + is Screen.EditConnection -> ConnectionEditScreen(vm) + } + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/AppViewModel.kt b/app/src/main/java/com/smbfast/app/ui/AppViewModel.kt new file mode 100644 index 0000000..509c83d --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/AppViewModel.kt @@ -0,0 +1,428 @@ +package com.smbfast.app.ui + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.smbfast.app.App +import com.smbfast.app.data.ConnectionProfile +import com.smbfast.app.data.Prefs +import com.smbfast.app.data.SpeedSettings +import com.smbfast.app.smb.SmbClientPool +import com.smbfast.app.smb.SmbRepo +import com.smbfast.app.smb.ShareEnumerator +import com.smbfast.app.smb.friendlySmbError +import com.smbfast.app.transfer.TransferService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +sealed class Screen { + data object Connections : Screen() + data class Browser(val connectionId: String) : Screen() + data object Transfers : Screen() + data object Settings : Screen() + data object EditConnection : Screen() +} + +data class BrowserState( + val profile: ConnectionProfile? = null, + val share: String = "", + val path: String = "", // '\' 分隔,"" 为共享根目录 + val entries: List = emptyList(), + val loading: Boolean = false, +) + +class AppViewModel(private val appContext: Context) : ViewModel() { + + private val prefs = Prefs(appContext) + val transferManager = (appContext as App).transferManager + + private val _connections = MutableStateFlow(prefs.loadConnections()) + val connections: StateFlow> = _connections.asStateFlow() + + private val _settings = MutableStateFlow(prefs.loadSettings()) + val settings: StateFlow = _settings.asStateFlow() + + private val _screen = MutableStateFlow(Screen.Connections) + val screen: StateFlow = _screen.asStateFlow() + + val browser = MutableStateFlow(BrowserState()) + + /** 打开过浏览器后记录返回目标 */ + private var returnScreen: Screen = Screen.Connections + + val downloadDir: File by lazy { transferManager.downloadDir } + + fun navigate(s: Screen) { + if (s is Screen.Browser) returnScreen = Screen.Connections + _screen.value = s + } + + fun goBack() { + _screen.value = when (_screen.value) { + is Screen.Transfers, is Screen.Settings, is Screen.EditConnection -> returnScreen + else -> Screen.Connections + } + } + + // ---------- 连接管理 ---------- + + fun saveConnection(p: ConnectionProfile) { + val list = _connections.value.toMutableList() + val i = list.indexOfFirst { it.id == p.id } + if (i >= 0) list[i] = p else list.add(0, p) + _connections.value = list + prefs.saveConnections(list) + } + + fun deleteConnection(p: ConnectionProfile) { + SmbClientPool.invalidate(p) + val list = _connections.value.toMutableList() + list.removeAll { it.id == p.id } + _connections.value = list + prefs.saveConnections(list) + } + + fun newProfile(): ConnectionProfile = ConnectionProfile(id = UUID.randomUUID().toString()) + + // ---------- 连接编辑页 ---------- + + data class EditUiState( + val isNew: Boolean = false, + val selectedShares: List = emptyList(), + val discovering: Boolean = false, + val discovered: List? = null, + val testing: Boolean = false, + val connecting: Boolean = false, + val message: String? = null, + val messageOk: Boolean = false, + ) + + private val _editing = MutableStateFlow(null) + val editing: StateFlow = _editing.asStateFlow() + + private val _editUi = MutableStateFlow(EditUiState()) + val editUi: StateFlow = _editUi.asStateFlow() + + fun openEditor(existing: ConnectionProfile?) { + _editing.value = existing ?: ConnectionProfile(id = UUID.randomUUID().toString()) + _editUi.value = EditUiState( + isNew = existing == null, + // 编辑模式预选中当前共享,可继续勾选其他共享批量添加 + selectedShares = existing?.share?.takeIf { it.isNotBlank() }?.let { listOf(it) } ?: emptyList(), + ) + _screen.value = Screen.EditConnection + } + + fun closeEditor() { + _screen.value = Screen.Connections + } + + fun updateDraft(f: (ConnectionProfile) -> ConnectionProfile) { + _editing.update { cur -> f(cur ?: ConnectionProfile(id = UUID.randomUUID().toString())) } + } + + /** 新建模式下点选/取消一个共享(多选);选中后共享名称输入框让位于多选 */ + fun toggleDraftShare(name: String) { + _editUi.update { s -> + val sel = s.selectedShares.toMutableList() + if (!sel.remove(name)) sel.add(name) + s.copy(selectedShares = sel) + } + if (_editUi.value.selectedShares.isNotEmpty()) { + _editing.update { it?.copy(share = "") } + } + } + + fun clearDraftShareSelection() { + _editUi.update { it.copy(selectedShares = emptyList()) } + } + + fun showEditorMessage(msg: String) { + _editUi.update { it.copy(message = msg, messageOk = false) } + } + + fun discoverDraftShares() { + val p = _editing.value ?: return + if (p.host.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + _editUi.update { it.copy(discovering = true, message = null) } + val result = runCatching { ShareEnumerator.listShares(p) } + withContext(Dispatchers.Main) { + _editUi.update { cur -> + result.fold( + onSuccess = { list -> + if (list.isEmpty()) { + cur.copy( + discovering = false, + message = "没有发现任何可见共享,请手动输入", + messageOk = false, + ) + } else { + cur.copy( + discovering = false, + discovered = list, + message = "发现 ${list.size} 个共享,点击即可选用", + messageOk = true, + ) + } + }, + onFailure = { e -> + cur.copy(discovering = false, message = friendlySmbError(e), messageOk = false) + }, + ) + } + } + } + } + + fun testDraft() { + val p = _editing.value ?: return + val share = _editUi.value.selectedShares.firstOrNull() ?: p.share + if (p.host.isBlank() || share.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + _editUi.update { it.copy(testing = true, message = null) } + val err = SmbRepo.testConnect(p, share) + withContext(Dispatchers.Main) { + if (err != null) toast("测试失败:$err") + _editUi.update { it.copy(testing = false, message = err ?: "连接成功", messageOk = err == null) } + } + } + } + + /** 测试并连接:新建模式选了多个共享时,一次创建多条连接并打开第一条 */ + fun connectDraft(save: Boolean) { + val p = _editing.value ?: return + if (p.host.isBlank()) return + val uiNow = _editUi.value + val shares = uiNow.selectedShares.ifEmpty { listOf(p.share).filter { it.isNotBlank() } } + if (shares.isEmpty()) return + val isNew = uiNow.isNew + _editUi.update { it.copy(connecting = true, message = null) } + viewModelScope.launch(Dispatchers.IO) { + val err = SmbRepo.testConnect(p, shares.first()) + if (err != null) { + withContext(Dispatchers.Main) { + toast("连接失败:$err") + _editUi.update { it.copy(connecting = false, message = err, messageOk = false) } + } + return@launch + } + var open = p.copy(share = shares.first()) + if (save) { + if (!isNew) { + // 编辑模式:当前连接采用第一个选中的共享,其余勾选的各新建一条连接 + open = p.copy(share = shares.first(), name = p.name.ifBlank { shares.first() }) + saveConnection(open) + if (shares.size > 1) { + val base = p.name + shares.drop(1).forEach { sh -> + val nm = if (base.isBlank()) sh else "$base·$sh" + saveConnection(p.copy(id = UUID.randomUUID().toString(), share = sh, name = nm)) + } + } + } else { + shares.forEachIndexed { idx, sh -> + val nm = when { + p.name.isBlank() -> sh + shares.size == 1 -> p.name + else -> "${p.name}·$sh" + } + val sp = p.copy(id = UUID.randomUUID().toString(), share = sh, name = nm) + saveConnection(sp) + if (idx == 0) open = sp + } + } + } + browser.update { it.copy(profile = open, share = open.share, path = "", entries = emptyList(), loading = true) } + val entries = runCatching { SmbRepo.list(open, open.share, "") }.getOrElse { listOf() } + browser.update { it.copy(entries = entries, loading = false) } + returnScreen = Screen.Connections + withContext(Dispatchers.Main) { + _editUi.update { it.copy(connecting = false) } + _screen.value = Screen.Browser(open.id) + } + } + } + + fun connect(p: ConnectionProfile, save: Boolean, onResult: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val err = SmbRepo.testConnect(p, p.share) + if (err != null) { + withContext(Dispatchers.Main) { onResult(err) } + return@launch + } + if (save) saveConnection(p) + browser.update { it.copy(profile = p, share = p.share, path = "", entries = emptyList(), loading = true) } + val entries = runCatching { SmbRepo.list(p, p.share, "") } + .getOrElse { listOf() } + browser.update { it.copy(entries = entries, loading = false) } + returnScreen = Screen.Connections + withContext(Dispatchers.Main) { + onResult(null) + _screen.value = Screen.Browser(p.id) + } + } + } + + // ---------- 文件浏览 ---------- + + private fun requireProfile(): ConnectionProfile? = browser.value.profile + + fun openDir(path: String) { + val p = requireProfile() ?: return + val share = browser.value.share + browser.update { it.copy(path = path, loading = true, entries = emptyList()) } + viewModelScope.launch(Dispatchers.IO) { + val entries = runCatching { SmbRepo.list(p, share, path) } + .getOrElse { + toast("打开目录失败:${com.smbfast.app.smb.friendlySmbError(it)}") + emptyList() + } + browser.update { it.copy(entries = entries, loading = false) } + } + } + + fun refresh() = openDir(browser.value.path) + + fun up() { + val cur = browser.value.path + if (cur.isNotBlank()) openDir(SmbRepo.parentPath(cur)) + } + + fun mkdir(name: String) { + val p = requireProfile() ?: return + val b = browser.value + viewModelScope.launch(Dispatchers.IO) { + runCatching { SmbRepo.mkdir(p, b.share, SmbRepo.joinPath(b.path, name)) } + .onFailure { toast("新建文件夹失败:${com.smbfast.app.smb.friendlySmbError(it)}") } + .onSuccess { refreshOnIo() } + } + } + + fun delete(entry: com.smbfast.app.smb.SmbEntry) { + val p = requireProfile() ?: return + val b = browser.value + viewModelScope.launch(Dispatchers.IO) { + runCatching { SmbRepo.delete(p, b.share, SmbRepo.joinPath(b.path, entry.name), entry.isDir) } + .onFailure { toast("删除失败:${com.smbfast.app.smb.friendlySmbError(it)}") } + .onSuccess { refreshOnIo() } + } + } + + fun rename(entry: com.smbfast.app.smb.SmbEntry, newName: String) { + val p = requireProfile() ?: return + val b = browser.value + viewModelScope.launch(Dispatchers.IO) { + runCatching { SmbRepo.rename(p, b.share, SmbRepo.joinPath(b.path, entry.name), newName) } + .onFailure { toast("重命名失败:${com.smbfast.app.smb.friendlySmbError(it)}") } + .onSuccess { refreshOnIo() } + } + } + + private suspend fun refreshOnIo() { + val p = requireProfile() ?: return + val path = browser.value.path + val entries = runCatching { SmbRepo.list(p, browser.value.share, path) }.getOrElse { emptyList() } + browser.update { it.copy(entries = entries, loading = false) } + } + + // ---------- 传输 ---------- + + fun download(entry: com.smbfast.app.smb.SmbEntry, openWhenDone: Boolean) { + val p = requireProfile() ?: return + val b = browser.value + val s = settings.value + val remote = SmbRepo.joinPath(b.path, entry.name) + transferManager.enqueueDownload( + profile = p, shareName = b.share, remotePath = remote, + sizeHint = entry.size, streams = s.streams, chunkKB = s.chunkKB, + openWhenDone = openWhenDone, + ) + TransferService.start(appContext) + toast("已加入下载:${entry.name}") + } + + fun uploadUris(uris: List) { + val p = requireProfile() ?: return + val b = browser.value + val s = settings.value + uris.forEach { uri -> + viewModelScope.launch(Dispatchers.IO) { + val (name0, size0) = com.smbfast.app.util.Files.queryNameAndSize(appContext, uri) + val name = name0 ?: "upload_${System.currentTimeMillis()}" + val input = appContext.contentResolver.openInputStream(uri) + if (input == null) { + toast("无法读取所选文件:$name") + return@launch + } + // 远端已有同名文件时自动改名,避免静默覆盖 + val baseRemote = SmbRepo.joinPath(b.path, name) + val remote = runCatching { + SmbRepo.uniqueRemotePath(SmbClientPool.diskShare(p, b.share), baseRemote) + }.getOrDefault(baseRemote) + transferManager.enqueueUploadStream( + profile = p, shareName = b.share, + remotePath = remote, + input = input, declaredSize = size0 ?: -1L, + streams = s.streams, chunkKB = s.chunkKB, + ) + TransferService.start(appContext) + if (remote != baseRemote) toast("远端已有同名文件,已改名为 ${SmbRepo.displayNameOf(remote)}") + } + } + } + + fun uploadLocalFile(file: File) { + val p = requireProfile() ?: return + val b = browser.value + val s = settings.value + // 远端已有同名文件时自动改名,避免静默覆盖 + val baseRemote = SmbRepo.joinPath(b.path, file.name) + val remote = runCatching { + SmbRepo.uniqueRemotePath(SmbClientPool.diskShare(p, b.share), baseRemote) + }.getOrDefault(baseRemote) + transferManager.enqueueUploadFile( + profile = p, shareName = b.share, + remotePath = remote, + local = file, streams = s.streams, chunkKB = s.chunkKB, + ) + TransferService.start(appContext) + } + + /** 用系统查看器打开已下载的文件 */ + fun openLocal(path: String) { + val ok = com.smbfast.app.util.Files.openWithViewer(appContext, File(path)) + if (!ok) toast("没有可打开此文件的应用") + } + + // ---------- 设置 ---------- + + fun updateSettings(f: (SpeedSettings) -> SpeedSettings) { + val next = f(_settings.value) + _settings.value = next + prefs.saveSettings(next) + } + + private fun toast(msg: String) { + Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show() + } + + companion object { + fun factory(ctx: Context): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + AppViewModel(ctx.applicationContext) as T + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/components/Form.kt b/app/src/main/java/com/smbfast/app/ui/components/Form.kt new file mode 100644 index 0000000..8939355 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/components/Form.kt @@ -0,0 +1,154 @@ +package com.smbfast.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smbfast.app.ui.theme.Neon + +/** 霓虹玻璃输入框(紧凑版) */ +@Composable +fun GlassTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + singleLine: Boolean = true, + leading: ImageVector? = null, + trailing: @Composable (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardType: KeyboardType = KeyboardType.Text, + enabled: Boolean = true, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label, fontSize = 12.sp) }, + singleLine = singleLine, + enabled = enabled, + shape = RoundedCornerShape(12.dp), + textStyle = TextStyle(fontSize = 14.sp), + leadingIcon = leading?.let { { Icon(it, null, tint = Neon.TextDim, modifier = Modifier.size(17.dp)) } }, + trailingIcon = trailing, + visualTransformation = visualTransformation, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + modifier = modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Neon.Cyan, + unfocusedBorderColor = Color(0xFF2A3854), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + unfocusedLabelColor = Neon.TextFaint, + focusedLabelColor = Neon.Cyan, + cursorColor = Neon.Cyan, + focusedContainerColor = Color.White.copy(alpha = 0.05f), + unfocusedContainerColor = Color.White.copy(alpha = 0.05f), + ), + ) +} + +/** 可点选的共享胶囊:选中态为霓虹渐变填充 */ +@Composable +fun SharePill( + name: String, + selected: Boolean, + onClick: () -> Unit, +) { + Box( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (selected) Brush.linearGradient(listOf(Neon.Cyan, Neon.Violet)) + else Brush.linearGradient(listOf(Color.White.copy(0.07f), Color.White.copy(0.04f))), + ) + .border( + 1.dp, + if (selected) Brush.linearGradient(listOf(Neon.Cyan, Neon.Violet)) + else Brush.linearGradient(listOf(Color.White.copy(0.14f), Color.White.copy(0.05f))), + RoundedCornerShape(12.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = if (selected) Color(0xFF04121E) else Neon.Amber, + modifier = Modifier.size(13.dp), + ) + Spacer(Modifier.width(5.dp)) + Text( + name, + color = if (selected) Color(0xFF04121E) else Color.White, + fontSize = 12.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +/** 玻璃分段卡片头 */ +@Composable +fun FormSection( + icon: ImageVector, + brush: Brush, + title: String, + subtitle: String = "", + content: @Composable () -> Unit, +) { + androidx.compose.foundation.layout.Column( + Modifier + .background( + Brush.verticalGradient(listOf(Color.White.copy(0.07f), Color.White.copy(0.03f))), + ) + .border( + 1.dp, + Brush.linearGradient(listOf(Color.White.copy(alpha = 0.22f), Color.White.copy(alpha = 0.05f))), + RoundedCornerShape(18.dp), + ) + .padding(13.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NeonIconBox(size = 32.dp, brush = brush, corner = 10.dp) { + Icon(icon, null, tint = Color(0xFF06101C), modifier = Modifier.size(16.dp)) + } + Spacer(Modifier.width(9.dp)) + Text(title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(6.dp)) + Text(subtitle, color = Neon.TextDim, fontSize = 11.sp, lineHeight = 15.sp) + } + Spacer(Modifier.height(2.dp)) + content() + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/components/Glass.kt b/app/src/main/java/com/smbfast/app/ui/components/Glass.kt new file mode 100644 index 0000000..d26fec6 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/components/Glass.kt @@ -0,0 +1,336 @@ +package com.smbfast.app.ui.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smbfast.app.ui.theme.Neon +import kotlin.math.sin + +/** 圆角玻璃容器:半透明渐变底 + 霓虹描边 */ +val GlassShape = RoundedCornerShape(22.dp) + +@Composable +fun GlassCard( + modifier: Modifier = Modifier, + shape: androidx.compose.ui.graphics.Shape = GlassShape, + edge: Color = Neon.Cyan, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .clip(shape) + .background(Brush.verticalGradient(listOf(Neon.GlassHi, Neon.GlassLo))) + .border( + 1.dp, + Brush.linearGradient(listOf(edge.copy(alpha = 0.45f), Neon.Violet.copy(alpha = 0.16f), Color.White.copy(alpha = 0.06f))), + shape, + ), + content = content, + ) +} + +/** 全局是否已存在极光背景(由 AppRoot 注入一次) */ +val LocalAuroraHost = staticCompositionLocalOf { false } + +/** 极光流动背景:两团缓慢漂移的霓虹光斑 + 深空底色。 + * 全局只画一层(AppRoot 持有):页面内再调用时直接透传内容, + * 这样页面切换动画期间背景保持不动、极光相位不重置,不再闪烁。 */ +@Composable +fun AuroraBackground(content: @Composable BoxScope.() -> Unit) { + if (LocalAuroraHost.current) { + Box(Modifier.fillMaxSize(), content = content) + return + } + val t = rememberInfiniteTransition(label = "aurora") + val p1 by t.animateFloat( + 0f, 1f, + infiniteRepeatable(tween(17000, easing = LinearEasing)), label = "p1", + ) + val p2 by t.animateFloat( + 0f, 1f, + infiniteRepeatable(tween(23000, easing = LinearEasing)), label = "p2", + ) + Box( + Modifier + .fillMaxSize() + .background(Neon.Bg0), + ) { + Blob(p1, 0.22f, 0.10f, 0.55f, Neon.Cyan.copy(alpha = 0.20f)) + Blob(p2, 0.80f, 0.72f, 0.62f, Neon.Violet.copy(alpha = 0.20f)) + Blob((p1 + p2) / 2f, 0.95f, 0.20f, 0.42f, Neon.Pink.copy(alpha = 0.10f)) + CompositionLocalProvider(LocalAuroraHost provides true) { + Box(Modifier.fillMaxSize(), content = content) + } + } +} + +@Composable +private fun BoxScope.Blob(phase: Float, fx: Float, fy: Float, frac: Float, color: Color) { + val d = sin(phase * 2f * Math.PI.toFloat()) + Box( + Modifier + .align(Alignment.Center) + .fillMaxSize() + .drawBehind { + val r = size.minDimension * frac + val cx = size.width * fx + d * size.width * 0.10f + val cy = size.height * fy - d * size.height * 0.08f + drawCircle(Brush.radialGradient(listOf(color, Color.Transparent), center = Offset(cx, cy), radius = r), radius = r, center = Offset(cx, cy)) + }, + ) +} + +/** 渐变大标题文字 */ +@Composable +fun GradientText( + text: String, + brush: Brush = Neon.Primary, + fontSize: Int = 22, + modifier: Modifier = Modifier, +) { + Text( + text, + modifier = modifier, + style = TextStyle( + brush = brush, + fontSize = fontSize.sp, + fontWeight = FontWeight.Bold, + ), + ) +} + +/** 霓虹渐变图标底座 */ +@Composable +fun NeonIconBox( + size: Dp = 42.dp, + brush: Brush = Neon.Primary, + corner: Dp = 14.dp, + content: @Composable BoxScope.() -> Unit, +) { + Box( + Modifier + .size(size) + .clip(RoundedCornerShape(corner)) + .background(brush), + contentAlignment = Alignment.Center, + content = content, + ) +} + +/** 霓虹渐变按钮(胶囊形) */ +@Composable +fun GradientButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + brush: Brush = Neon.Primary, +) { + val fill = if (enabled) brush else Brush.linearGradient(listOf(Color(0xFF39435C), Color(0xFF2A3247))) + Box( + modifier + .clip(RoundedCornerShape(50)) + .background(fill) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text, + color = if (enabled) Color(0xFF04121E) else Neon.TextFaint, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + } +} + +/** 流光进度条:底轨 + 渐变进度 + 高光扫过 */ +@Composable +fun NeonProgress( + fraction: Float?, + modifier: Modifier = Modifier, + brush: Brush = Neon.Primary, + height: Dp = 7.dp, +) { + val sweep = rememberInfiniteTransition(label = "shimmer") + val shim by sweep.animateFloat( + 0f, 1f, + infiniteRepeatable(tween(1400, easing = LinearEasing)), label = "shim", + ) + Box( + modifier + .height(height) + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.09f)), + ) { + Canvas(Modifier.fillMaxSize()) { + if (fraction != null && fraction > 0f) { + drawRoundRect( + brush = brush, + size = androidx.compose.ui.geometry.Size(size.width * fraction.coerceIn(0f, 1f), size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.height / 2), + ) + } + // 高光扫过 + val x = (shim * 1.6f - 0.3f) * size.width + drawRect( + brush = Brush.linearGradient( + listOf(Color.Transparent, Color.White.copy(alpha = 0.45f), Color.Transparent), + start = Offset(x, 0f), + end = Offset(x + size.width * 0.45f, size.height), + ), + size = size, + ) + } + } +} + +/** 发光圆环进度(中心可放内容) */ +@Composable +fun GlowRing( + fraction: Float?, + modifier: Modifier = Modifier, + sizeDp: Dp = 140.dp, + stroke: Dp = 10.dp, + brush: Brush = Neon.Primary, + content: @Composable BoxScope.() -> Unit, +) { + val pulse by rememberInfiniteTransition(label = "pulse").animateFloat( + 0.25f, 0.75f, + infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "pv", + ) + Box(modifier.size(sizeDp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val s = stroke.toPx() + val inset = s / 2 + s * 0.6f + val arcSize = androidx.compose.ui.geometry.Size(size.width - inset * 2, size.height - inset * 2) + val tl = Offset(inset, inset) + val f = fraction ?: pulse + // 外发光 + drawArc( + brush = brush, startAngle = -90f, sweepAngle = 360f * f.coerceIn(0f, 1f), useCenter = false, + topLeft = tl, size = arcSize, style = Stroke(s * 2.6f, cap = StrokeCap.Round), alpha = 0.16f, + ) + // 底轨 + drawArc( + color = Color.White.copy(alpha = 0.10f), startAngle = -90f, sweepAngle = 360f, useCenter = false, + topLeft = tl, size = arcSize, style = Stroke(s, cap = StrokeCap.Round), + ) + // 主环 + drawArc( + brush = brush, startAngle = -90f, sweepAngle = 360f * f.coerceIn(0f, 1f), useCenter = false, + topLeft = tl, size = arcSize, style = Stroke(s, cap = StrokeCap.Round), + ) + } + content() + } +} + +/** 传输等化器动效 */ +@Composable +fun EqualizerBars(color: Color = Neon.Cyan, barWidth: Dp = 4.dp, max: Dp = 16.dp) { + Row(verticalAlignment = Alignment.Bottom) { + repeat(4) { i -> + val t = rememberInfiniteTransition(label = "eq$i") + val v by t.animateFloat( + 0.25f, 1f, + infiniteRepeatable(tween(420 + i * 150, easing = FastOutSlowInEasing), RepeatMode.Reverse), + label = "eqv$i", + ) + Box( + Modifier + .padding(horizontal = 2.dp) + .width(barWidth) + .height(max * v) + .clip(RoundedCornerShape(50)) + .background(Brush.verticalGradient(listOf(color, color.copy(alpha = 0.35f)))), + ) + } + } +} + +/** 状态圆点(带光晕) */ +@Composable +fun StatusDot(color: Color, dotSize: Dp = 8.dp, modifier: Modifier = Modifier) { + Box( + modifier + .size(dotSize) + .drawBehind { + drawCircle(color.copy(alpha = 0.28f), radius = this.size.minDimension / 2 * 2.1f) + drawCircle(color) + }, + ) +} + +/** 中心旋转的霓虹环 + 图标(首页 Hero 装饰) */ +@Composable +fun GlowMark(icon: ImageVector, sizeDp: Dp = 72.dp) { + val t = rememberInfiniteTransition(label = "mark") + val angle by t.animateFloat( + 0f, 360f, + infiniteRepeatable(tween(9000, easing = LinearEasing)), label = "angle", + ) + Box(Modifier.size(sizeDp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val s = 3.dp.toPx() + val inset = s * 1.6f + drawArc( + brush = Brush.sweepGradient(listOf(Neon.Cyan, Neon.Violet, Neon.Pink, Neon.Cyan)), + startAngle = angle, sweepAngle = 300f, useCenter = false, + topLeft = Offset(inset, inset), + size = androidx.compose.ui.geometry.Size(size.width - inset * 2, size.height - inset * 2), + style = Stroke(s, cap = StrokeCap.Round), alpha = 0.9f, + ) + } + Box( + Modifier + .size(sizeDp * 0.62f) + .clip(CircleShape) + .background(Brush.radialGradient(listOf(Neon.Violet.copy(alpha = 0.4f), Color.Transparent))), + contentAlignment = Alignment.Center, + ) { + Icon(icon, null, tint = Color.White, modifier = Modifier.size(sizeDp * 0.32f)) + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/screens/BrowserScreen.kt b/app/src/main/java/com/smbfast/app/ui/screens/BrowserScreen.kt new file mode 100644 index 0000000..481e3bc --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/screens/BrowserScreen.kt @@ -0,0 +1,440 @@ +package com.smbfast.app.ui.screens + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smbfast.app.smb.SmbEntry +import com.smbfast.app.ui.AppViewModel +import com.smbfast.app.ui.components.AuroraBackground +import com.smbfast.app.ui.components.GlassCard +import com.smbfast.app.ui.components.NeonIconBox +import com.smbfast.app.ui.components.NeonProgress +import com.smbfast.app.ui.theme.Neon +import com.smbfast.app.util.Files +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + +private fun iconFor(entry: SmbEntry): ImageVector { + if (entry.isDir) return Icons.Default.Folder + return when (entry.name.substringAfterLast('.', "").lowercase()) { + "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic" -> Icons.Default.Image + "mp4", "mkv", "avi", "mov", "wmv", "flv", "ts", "webm" -> Icons.Default.Movie + "mp3", "flac", "wav", "aac", "ogg", "m4a" -> Icons.Default.MusicNote + "zip", "rar", "7z", "tar", "gz" -> Icons.Default.Archive + "txt", "md", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx" -> Icons.Default.Description + else -> Icons.Default.InsertDriveFile + } +} + +private fun brushFor(entry: SmbEntry) = when { + entry.isDir -> Neon.Folder + entry.name.substringAfterLast('.', "").lowercase() in setOf( + "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", + ) -> Neon.Image + entry.name.substringAfterLast('.', "").lowercase() in setOf( + "mp4", "mkv", "avi", "mov", "wmv", "flv", "ts", "webm", + ) -> Neon.Video + entry.name.substringAfterLast('.', "").lowercase() in setOf( + "mp3", "flac", "wav", "aac", "ogg", "m4a", + ) -> Neon.Music + entry.name.substringAfterLast('.', "").lowercase() in setOf("zip", "rar", "7z", "tar", "gz") -> Neon.Archive + entry.name.substringAfterLast('.', "").lowercase() in setOf( + "txt", "md", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + ) -> Neon.Doc + else -> Neon.File +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BrowserScreen(vm: AppViewModel) { + val b by vm.browser.collectAsStateWithLifecycle() + val profile = b.profile ?: return + + var menuEntry by remember { mutableStateOf(null) } + var renameEntry by remember { mutableStateOf(null) } + var deleteEntry by remember { mutableStateOf(null) } + var showNewFolder by remember { mutableStateOf(false) } + + val uploadLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> if (uris.isNotEmpty()) vm.uploadUris(uris.toList()) } + + val segments = remember(b.path, b.share) { + mutableListOf(b.share).apply { + b.path.split('\\').filter { it.isNotBlank() }.forEach { add(it) } + } + } + + AuroraBackground { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White.copy(alpha = 0.85f), + ), + navigationIcon = { + IconButton(onClick = { vm.navigate(com.smbfast.app.ui.Screen.Connections) }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + title = { + Column { + Text( + "\\\\${profile.displayHost()}\\${b.share}", + style = androidx.compose.ui.text.TextStyle( + brush = Neon.Primary, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + b.path.ifBlank { "根目录" }, + color = Neon.TextDim, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + actions = { + IconButton(onClick = { uploadLauncher.launch(arrayOf("*/*")) }) { + Icon(Icons.Default.Upload, contentDescription = "上传") + } + IconButton(onClick = { showNewFolder = true }) { + Icon(Icons.Default.CreateNewFolder, contentDescription = "新建文件夹") + } + IconButton(onClick = { vm.navigate(com.smbfast.app.ui.Screen.Transfers) }) { + Icon(Icons.Default.Download, contentDescription = "传输") + } + IconButton(onClick = { vm.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "刷新") + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding)) { + // 面包屑胶囊 + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + segments.forEachIndexed { i, seg -> + val last = i == segments.lastIndex + Box( + Modifier + .clip(RoundedCornerShape(50)) + .background( + if (last) Brush.linearGradient(listOf(Neon.Cyan, Neon.Violet)) + else androidx.compose.ui.graphics.SolidColor(Color.White.copy(alpha = 0.07f)), + ) + .clickable { + val target = + if (i == 0) "" else segments.subList(1, i + 1).joinToString("\\") + vm.openDir(target) + } + .padding(horizontal = 12.dp, vertical = 5.dp), + ) { + Text( + seg, + color = if (last) Color(0xFF04121E) else Neon.TextDim, + fontSize = 12.sp, + fontWeight = if (last) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + ) + } + if (!last) { + Text(" › ", color = Neon.TextFaint, fontSize = 12.sp) + } + } + } + + if (b.loading) { + NeonProgress(fraction = null, modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp)) + } + + if (!b.loading && b.entries.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.FolderOpen, + null, + tint = Neon.TextFaint, + modifier = Modifier.size(44.dp), + ) + Spacer(Modifier.height(8.dp)) + Text("空目录", color = Neon.TextFaint, fontSize = 13.sp) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, vertical = 6.dp, + ), + ) { + items(b.entries, key = { it.name }) { entry -> + val shape = RoundedCornerShape(18.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.045f)) + .clickable { + if (entry.isDir) { + vm.openDir(com.smbfast.app.smb.SmbRepo.joinPath(b.path, entry.name)) + } else { + menuEntry = entry + } + } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NeonIconBox(brush = brushFor(entry)) { + Icon(iconFor(entry), null, tint = Color(0xFF06101C), modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.name, + color = Color.White, + fontSize = 14.sp, + fontWeight = if (entry.isDir) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + if (entry.isDir) "文件夹" + else "${Files.humanSize(entry.size)} · ${ + if (entry.lastModified > 0) dateFormat.format(Date(entry.lastModified)) else "-" + }", + color = Neon.TextDim, + fontSize = 11.sp, + maxLines = 1, + ) + } + Box { + IconButton(onClick = { menuEntry = entry }) { + Icon( + Icons.Default.MoreVert, contentDescription = "操作", + tint = Neon.TextDim, + ) + } + DropdownMenu( + expanded = menuEntry == entry, + onDismissRequest = { menuEntry = null }, + containerColor = Color(0xFF141D33), + ) { + if (!entry.isDir) { + DropdownMenuItem( + text = { Text("下载", color = Color.White) }, + leadingIcon = { Icon(Icons.Default.Download, null, tint = Neon.Cyan) }, + onClick = { + menuEntry = null + vm.download(entry, openWhenDone = false) + }, + ) + DropdownMenuItem( + text = { Text("下载并打开", color = Color.White) }, + leadingIcon = { Icon(Icons.Default.Download, null, tint = Neon.Violet) }, + onClick = { + menuEntry = null + vm.download(entry, openWhenDone = true) + }, + ) + } + DropdownMenuItem( + text = { Text("重命名", color = Color.White) }, + leadingIcon = { Icon(Icons.Default.Edit, null, tint = Neon.Blue) }, + onClick = { + menuEntry = null + renameEntry = entry + }, + ) + DropdownMenuItem( + text = { Text("删除", color = Neon.Red) }, + leadingIcon = { Icon(Icons.Default.Delete, null, tint = Neon.Red) }, + onClick = { + menuEntry = null + deleteEntry = entry + }, + ) + } + } + } + } + } + } + } + } + + if (showNewFolder) { + NameInputDialog( + title = "新建文件夹", + initial = "", + confirmText = "创建", + onDismiss = { showNewFolder = false }, + onConfirm = { + vm.mkdir(it) + showNewFolder = false + }, + ) + } + + renameEntry?.let { e -> + NameInputDialog( + title = "重命名", + initial = e.name, + confirmText = "确定", + onDismiss = { renameEntry = null }, + onConfirm = { + vm.rename(e, it) + renameEntry = null + }, + ) + } + + deleteEntry?.let { e -> + AlertDialog( + onDismissRequest = { deleteEntry = null }, + containerColor = Color(0xFF141D33), + titleContentColor = Color.White, + textContentColor = Neon.TextDim, + title = { Text("删除") }, + text = { Text("确定删除「${e.name}」吗?此操作不可恢复。") }, + confirmButton = { + TextButton(onClick = { + vm.delete(e) + deleteEntry = null + }) { Text("删除", color = Neon.Red) } + }, + dismissButton = { + TextButton(onClick = { deleteEntry = null }) { Text("取消", color = Neon.TextDim) } + }, + ) + } +} + +@Composable +fun NameInputDialog( + title: String, + initial: String, + confirmText: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color(0xFF141D33), + titleContentColor = Color.White, + textContentColor = Neon.TextDim, + title = { Text(title) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Neon.Cyan, + unfocusedBorderColor = Color(0xFF2A3854), + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = Neon.Cyan, + focusedContainerColor = Color.White.copy(alpha = 0.05f), + unfocusedContainerColor = Color.White.copy(alpha = 0.05f), + ), + ) + }, + confirmButton = { + TextButton( + onClick = { if (text.isNotBlank()) onConfirm(text.trim()) }, + enabled = text.isNotBlank(), + ) { Text(confirmText, color = if (text.isNotBlank()) Neon.Cyan else Neon.TextFaint) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消", color = Neon.TextDim) } }, + ) +} diff --git a/app/src/main/java/com/smbfast/app/ui/screens/ConfirmDialog.kt b/app/src/main/java/com/smbfast/app/ui/screens/ConfirmDialog.kt new file mode 100644 index 0000000..1614d76 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/screens/ConfirmDialog.kt @@ -0,0 +1,32 @@ +package com.smbfast.app.ui.screens + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.smbfast.app.ui.theme.Neon + +@Composable +fun ConfirmDialog( + title: String, + text: String, + confirmText: String = "确定", + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color(0xFF141D33), + titleContentColor = Color.White, + textContentColor = Neon.TextDim, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(confirmText, color = Neon.Red) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("取消", color = Neon.TextDim) } + }, + ) +} diff --git a/app/src/main/java/com/smbfast/app/ui/screens/ConnectionEditScreen.kt b/app/src/main/java/com/smbfast/app/ui/screens/ConnectionEditScreen.kt new file mode 100644 index 0000000..e0e22e4 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/screens/ConnectionEditScreen.kt @@ -0,0 +1,392 @@ +package com.smbfast.app.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Domain +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Label +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smbfast.app.ui.AppViewModel +import com.smbfast.app.ui.components.AuroraBackground +import com.smbfast.app.ui.components.FormSection +import com.smbfast.app.ui.components.GlassTextField +import com.smbfast.app.ui.components.GradientText +import com.smbfast.app.ui.components.NeonProgress +import com.smbfast.app.ui.components.SharePill +import com.smbfast.app.ui.components.StatusDot +import com.smbfast.app.ui.theme.Neon + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ConnectionEditScreen(vm: AppViewModel) { + val p by vm.editing.collectAsStateWithLifecycle() + val ui by vm.editUi.collectAsStateWithLifecycle() + val profile = p ?: return + val isNew = ui.isNew + val hasTarget = profile.share.isNotBlank() || ui.selectedShares.isNotEmpty() + + var save by rememberSaveable { mutableStateOf(true) } + var showPassword by rememberSaveable { mutableStateOf(false) } + + AuroraBackground { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + navigationIcon = { + IconButton(onClick = { vm.closeEditor() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + title = { GradientText(if (isNew) "添加连接" else "编辑连接", fontSize = 20) }, + ) + }, + bottomBar = { + Column { + NeonProgress(fraction = null, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.08f)) + .clickable( + enabled = hasTarget && !ui.connecting, + ) { vm.testDraft() } + .padding(horizontal = 15.dp, vertical = 10.dp), + ) { + Text( + if (ui.testing) "测试中…" else "测试连接", + color = if (ui.testing) Neon.TextFaint else Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ) + } + Box( + Modifier + .weight(1f) + .clip(RoundedCornerShape(50)) + .background( + if (hasTarget) Neon.Primary + else Brush.linearGradient(listOf(Color(0xFF39435C), Color(0xFF2A3247))), + ) + .clickable( + enabled = hasTarget && !ui.connecting, + ) { vm.connectDraft(save) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + if (ui.selectedShares.size > 1) "测试并连接(${ui.selectedShares.size} 个)" + else if (ui.connecting) "连接中…" else "测试并连接", + color = Color(0xFF04121E), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + } + } + } + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + AnimatedVisibility(visible = ui.message != null) { + GlassMessageCard(message = ui.message ?: "", ok = ui.messageOk) + } + + // ---- 服务器 ---- + FormSection(icon = Icons.Default.Language, brush = Neon.Primary, title = "服务器") { + GlassTextField( + value = profile.host, + onValueChange = { v -> vm.updateDraft { cur -> cur.copy(host = v.trim()) } }, + label = "服务器地址(IP 或主机名)", + leading = Icons.Default.Language, + ) + Spacer(Modifier.height(6.dp)) + Row { + GlassTextField( + value = profile.port.takeIf { it != 0 }?.toString() ?: "445", + onValueChange = { v -> vm.updateDraft { cur -> cur.copy(port = v.toIntOrNull() ?: 445) } }, + label = "端口", + keyboardType = KeyboardType.Number, + modifier = Modifier.width(96.dp), + ) + Spacer(Modifier.width(7.dp)) + GlassTextField( + value = profile.share, + onValueChange = { v -> + vm.updateDraft { cur -> cur.copy(share = v.trim()) } + vm.clearDraftShareSelection() + }, + label = "共享名称", + leading = Icons.Default.FolderOpen, + modifier = Modifier.weight(1f), + ) + } + } + + // ---- 共享发现 ---- + FormSection( + icon = Icons.Default.Folder, + brush = Neon.Fresh, + title = "共享发现", + subtitle = "自动枚举服务器上所有共享,点选多个可一次添加多条连接", + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .clip(RoundedCornerShape(50)) + .background(Neon.Cyan.copy(alpha = 0.16f)) + .clickable(enabled = profile.host.isNotBlank() && !ui.discovering) { + vm.discoverDraftShares() + } + .padding(horizontal = 12.dp, vertical = 7.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Refresh, null, + tint = Neon.Cyan, modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(5.dp)) + Text( + if (ui.discovering) "发现中…" else "自动发现共享", + color = Neon.Cyan, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ) + } + } + Spacer(Modifier.width(9.dp)) + Text( + if (profile.host.isBlank()) "先填写服务器地址" else "点击枚举全部共享", + color = Neon.TextFaint, + fontSize = 10.5.sp, + ) + } + AnimatedVisibility( + visible = ui.discovering, + enter = fadeIn() + expandVertically(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + NeonProgress(fraction = null, modifier = Modifier.fillMaxWidth()) + } + } + AnimatedVisibility( + visible = !ui.discovered.isNullOrEmpty(), + enter = fadeIn() + expandVertically(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + if (ui.selectedShares.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusDot(Neon.Cyan, dotSize = 6.dp) + Spacer(Modifier.width(6.dp)) + Text( + "已选 ${ui.selectedShares.size} 个共享", + color = Neon.Cyan, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.weight(1f)) + Text( + "清空选择", + color = Neon.TextFaint, + fontSize = 11.sp, + modifier = Modifier.clickable { vm.clearDraftShareSelection() }, + ) + } + Spacer(Modifier.height(6.dp)) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + (ui.discovered ?: emptyList()).forEach { name -> + SharePill( + name = name, + selected = name in ui.selectedShares, + onClick = { vm.toggleDraftShare(name) }, + ) + } + } + } + } + } + + // ---- 身份验证 ---- + FormSection(icon = Icons.Default.Key, brush = Neon.Hot, title = "身份验证") { + Row { + GlassTextField( + value = profile.domain, + onValueChange = { v -> vm.updateDraft { cur -> cur.copy(domain = v.trim()) } }, + label = "域(可选)", + leading = Icons.Default.Domain, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(7.dp)) + GlassTextField( + value = profile.username, + onValueChange = { v -> vm.updateDraft { cur -> cur.copy(username = v.trim()) } }, + label = "用户名", + leading = Icons.Default.Person, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(6.dp)) + GlassTextField( + value = profile.password, + onValueChange = { v -> vm.updateDraft { cur -> cur.copy(password = v) } }, + label = "密码", + leading = Icons.Default.Key, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailing = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null, + tint = Neon.TextDim, + ) + } + }, + ) + Spacer(Modifier.height(3.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = profile.anonymous, + onCheckedChange = { checked -> + vm.updateDraft { cur -> cur.copy(anonymous = checked) } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color(0xFF04121E), + checkedTrackColor = Neon.Cyan, + uncheckedTrackColor = Color.White.copy(alpha = 0.12f), + ), + ) + Text("匿名 / 访客登录", color = Neon.TextDim, fontSize = 12.sp) + Spacer(Modifier.width(16.dp)) + Switch( + checked = save, + onCheckedChange = { save = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color(0xFF04121E), + checkedTrackColor = Neon.Violet, + uncheckedTrackColor = Color.White.copy(alpha = 0.12f), + ), + ) + Text("保存连接", color = Neon.TextDim, fontSize = 12.sp) + } + } + + // ---- 连接名称 ---- + FormSection( + icon = Icons.Default.Label, + brush = Neon.Sunset, + title = "连接名称", + subtitle = if (isNew) "选多个共享时按「名称·共享」逐条创建;不填则用共享名" + else "当前连接用第一个选中的共享,其余按「名称·共享」新建;不填则用共享名", + ) { + GlassTextField( + value = profile.name, + onValueChange = { v -> vm.updateDraft { cur -> cur.copy(name = v) } }, + label = "名称", + leading = Icons.Default.Label, + ) + } + + Spacer(Modifier.height(12.dp)) + } + } + } +} + +@Composable +private fun GlassMessageCard(message: String, ok: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusDot(if (ok) Neon.Lime else Neon.Red, dotSize = 7.dp) + Spacer(Modifier.width(8.dp)) + Text( + message, + color = if (ok) Neon.Lime else Neon.Red, + fontSize = 11.sp, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/screens/ConnectionsScreen.kt b/app/src/main/java/com/smbfast/app/ui/screens/ConnectionsScreen.kt new file mode 100644 index 0000000..f880464 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/screens/ConnectionsScreen.kt @@ -0,0 +1,249 @@ +package com.smbfast.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smbfast.app.data.ConnectionProfile +import com.smbfast.app.ui.AppViewModel +import com.smbfast.app.ui.Screen +import com.smbfast.app.ui.components.AuroraBackground +import com.smbfast.app.ui.components.GlassCard +import com.smbfast.app.ui.components.GlowMark +import com.smbfast.app.ui.components.GradientText +import com.smbfast.app.ui.components.EqualizerBars +import com.smbfast.app.ui.theme.Neon + +private val avatarBrushes = listOf(Neon.Primary, Neon.Hot, Neon.Fresh, Neon.Sunset) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionsScreen(vm: AppViewModel) { + val connections by vm.connections.collectAsStateWithLifecycle() + var deleting by remember { mutableStateOf(null) } + + AuroraBackground { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + actionIconContentColor = Color.White.copy(alpha = 0.85f), + ), + title = { GradientText("SMB 极速", fontSize = 24) }, + actions = { + IconButton(onClick = { vm.navigate(Screen.Transfers) }) { + Icon(Icons.Default.Sync, contentDescription = "传输") + } + IconButton(onClick = { vm.navigate(Screen.Settings) }) { + Icon(Icons.Default.Settings, contentDescription = "设置") + } + }, + ) + }, + floatingActionButton = { + // 渐变霓虹 FAB + Box( + Modifier + .size(58.dp) + .clip(RoundedCornerShape(50)) + .background(Neon.Primary) + .clickable { vm.openEditor(null) }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Add, contentDescription = "添加连接", tint = Color(0xFF04121E), modifier = Modifier.size(28.dp)) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + GlassCard( + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + edge = Neon.Violet, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GlowMark(icon = Icons.Default.Sync) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text("多流并行 · 全速传输", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.height(3.dp)) + Text( + "SMB3 大 MTU · credits 流水线 · 连接全程复用", + color = Neon.TextDim, + fontSize = 12.sp, + ) + Spacer(Modifier.height(8.dp)) + EqualizerBars(max = 14.dp) + } + } + } + } + + if (connections.isEmpty()) { + item { + GlassCard(Modifier.fillMaxWidth(), edge = Neon.Pink) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 44.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("还没有连接", color = Color.White, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "点击右下角 + 添加 NAS 或 Windows 共享", + color = Neon.TextDim, + fontSize = 12.sp, + ) + } + } + } + } + + items(connections, key = { it.id }) { c -> + ConnectionCard( + profile = c, + onClick = { + vm.connect(c, save = false, onResult = { err -> + if (err != null) { + vm.openEditor(c) + vm.showEditorMessage(err) + } + }) + }, + onEdit = { vm.openEditor(c) }, + onDelete = { deleting = c }, + ) + } + item { Spacer(Modifier.height(70.dp)) } + } + } + } + + deleting?.let { target -> + ConfirmDialog( + title = "删除连接", + text = "确定删除「${target.name.ifBlank { target.host }}」吗?(不会删除服务器上的文件)", + confirmText = "删除", + onDismiss = { deleting = null }, + onConfirm = { + vm.deleteConnection(target) + deleting = null + }, + ) + } +} + +@Composable +private fun ConnectionCard( + profile: ConnectionProfile, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val brush = remember(profile.id) { + avatarBrushes[(profile.id.hashCode().mod(avatarBrushes.size))] + } + GlassCard(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 渐变字母头像 + Box( + Modifier + .size(46.dp) + .clip(RoundedCornerShape(16.dp)) + .background(brush), + contentAlignment = Alignment.Center, + ) { + Text( + (profile.name.ifBlank { profile.host }).take(1).uppercase(), + color = Color(0xFF04121E), + fontWeight = FontWeight.Black, + fontSize = 20.sp, + ) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + profile.name.ifBlank { profile.host }, + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + ) + Spacer(Modifier.height(2.dp)) + Text( + "\\\\${profile.displayHost()}\\${profile.share} · ${profile.username.ifBlank { "匿名" }}", + color = Neon.TextDim, + fontSize = 12.sp, + maxLines = 1, + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = "编辑", tint = Neon.TextDim) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "删除", tint = Neon.TextFaint) + } + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt b/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..a2ea1aa --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt @@ -0,0 +1,260 @@ +package com.smbfast.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smbfast.app.ui.AppViewModel +import com.smbfast.app.ui.components.AuroraBackground +import com.smbfast.app.ui.components.GlassCard +import com.smbfast.app.ui.components.GradientText +import com.smbfast.app.ui.components.NeonIconBox +import com.smbfast.app.ui.theme.Neon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(vm: AppViewModel) { + val s by vm.settings.collectAsStateWithLifecycle() + var showLog by remember { mutableStateOf(false) } + + AuroraBackground { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + title = { GradientText("设置", fontSize = 24) }, + navigationIcon = { + IconButton(onClick = { vm.goBack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SectionCard( + icon = Icons.Default.Bolt, + brush = Neon.Primary, + title = "并行流数:${s.streams}", + subtitle = "下载大文件时同时使用的 SMB 流数量。千兆内网建议 4~8;万兆可提高", + ) { + Slider( + value = s.streams.toFloat(), + onValueChange = { v -> + vm.updateSettings { cur -> cur.copy(streams = v.toInt().coerceIn(1, 16)) } + }, + valueRange = 1f..16f, + steps = 14, + colors = SliderDefaults.colors( + thumbColor = Neon.Cyan, + activeTrackColor = Neon.Blue, + inactiveTrackColor = Color.White.copy(alpha = 0.12f), + ), + ) + } + + SectionCard( + icon = Icons.Default.Speed, + brush = Neon.Hot, + title = "分块大小:" + (if (s.chunkKB < 1024) "${s.chunkKB}KB" else "${s.chunkKB / 1024}MB"), + subtitle = "每个 SMB 请求读取的数据量(实际受服务器 MaxReadSize 限制)", + ) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf(512, 1024, 2048, 4096, 8192).forEach { kb -> + FilterChip( + selected = s.chunkKB == kb, + onClick = { vm.updateSettings { it.copy(chunkKB = kb) } }, + label = { Text(if (kb < 1024) "${kb}KB" else "${kb / 1024}MB") }, + colors = FilterChipDefaults.filterChipColors( + containerColor = Color.White.copy(alpha = 0.06f), + labelColor = Neon.TextDim, + selectedContainerColor = Neon.Cyan.copy(alpha = 0.22f), + selectedLabelColor = Neon.Cyan, + ), + ) + } + } + } + + SectionCard( + icon = Icons.Default.Folder, + brush = Neon.Fresh, + title = "下载目录", + subtitle = vm.downloadDir.absolutePath, + ) {} + + SectionCard( + icon = Icons.Default.Speed, + brush = Neon.Sunset, + title = "提速建议", + subtitle = "· 服务器开启 SMB3(Windows 8+/Server 2012+ 默认开启),协商大 MTU 后单请求可达 8MB\n" + + "· 手机与 NAS 使用 5GHz Wi-Fi 6 或有线更佳\n" + + "· 默认关闭了 SMB 签名/加密以换取最高吞吐;若服务器强制要求会自动回退\n" + + "· 小文件大量场景瓶颈在每文件握手,多流主要加速单大文件", + ) {} + + SectionCard( + icon = Icons.Default.Info, + brush = Neon.File, + title = "隐私提示", + subtitle = "连接密码保存在本机应用私有数据中(未加密)。请勿在共享设备上保存敏感凭据。", + ) {} + + SectionCard( + icon = Icons.Default.BugReport, + brush = Neon.Hot, + title = "错误日志", + subtitle = "闪退或传输失败时的完整堆栈会记录在这里;点「查看 / 复制」后可直接粘贴反馈", + ) { + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiniChip("查看 / 复制") { showLog = true } + MiniChip("清空日志") { com.smbfast.app.util.CrashLog.clear() } + } + } + + Spacer(Modifier.height(20.dp)) + } + } + } + + if (showLog) { + val log = remember { com.smbfast.app.util.CrashLog.readTail(20000) } + val ctx = androidx.compose.ui.platform.LocalContext.current + AlertDialog( + onDismissRequest = { showLog = false }, + containerColor = Color(0xFF101828), + titleContentColor = Color.White, + textContentColor = Neon.TextDim, + title = { Text("错误日志") }, + text = { + androidx.compose.foundation.text.selection.SelectionContainer { + Text( + log ?: "(日志为空 —— 没有记录到错误)", + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + lineHeight = 12.sp, + ) + } + }, + confirmButton = { + TextButton(onClick = { + runCatching { + val cm = ctx.getSystemService(android.content.Context.CLIPBOARD_SERVICE) + as android.content.ClipboardManager + cm.setPrimaryClip( + android.content.ClipData.newPlainText("smbfast-log", log ?: "(日志为空)") + ) + } + showLog = false + }) { Text("复制全部", color = Neon.Cyan) } + }, + dismissButton = { + TextButton(onClick = { showLog = false }) { Text("关闭", color = Neon.TextDim) } + }, + ) + } +} + +@Composable +private fun MiniChip(text: String, onClick: () -> Unit) { + Box( + Modifier + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 7.dp), + ) { + Text(text, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun SectionCard( + icon: ImageVector, + brush: Brush, + title: String, + subtitle: String, + content: @Composable () -> Unit, +) { + GlassCard(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + NeonIconBox(brush = brush, corner = 12.dp) { + Icon(icon, null, tint = Color(0xFF06101C), modifier = Modifier.size(20.dp)) + } + Spacer(Modifier.width(10.dp)) + Text(title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text(subtitle, color = Neon.TextDim, fontSize = 12.sp, lineHeight = 18.sp) + } + content() + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/screens/TransfersScreen.kt b/app/src/main/java/com/smbfast/app/ui/screens/TransfersScreen.kt new file mode 100644 index 0000000..e8b756f --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/screens/TransfersScreen.kt @@ -0,0 +1,286 @@ +package com.smbfast.app.ui.screens + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.smbfast.app.transfer.Direction +import com.smbfast.app.transfer.Status +import com.smbfast.app.ui.AppViewModel +import com.smbfast.app.ui.components.AuroraBackground +import com.smbfast.app.ui.components.EqualizerBars +import com.smbfast.app.ui.components.GlassCard +import com.smbfast.app.ui.components.GlowRing +import com.smbfast.app.ui.components.GradientText +import com.smbfast.app.ui.components.NeonIconBox +import com.smbfast.app.ui.components.NeonProgress +import com.smbfast.app.ui.components.StatusDot +import com.smbfast.app.ui.theme.Neon +import com.smbfast.app.util.Files +import kotlin.math.log10 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransfersScreen(vm: AppViewModel) { + val items by vm.transferManager.items.collectAsStateWithLifecycle() + + val running = items.filter { it.status == Status.RUNNING } + val doneCount = items.count { it.status == Status.DONE } + val failedCount = items.count { it.status == Status.FAILED } + val curSpeed = running.maxOfOrNull { it.speedBps } ?: 0L + + // 会话峰值速度 + var peak by remember { mutableStateOf(0L) } + LaunchedEffect(curSpeed) { if (curSpeed > peak) peak = curSpeed } + + val gaugeFrac = remember(curSpeed) { + (log10(1f + curSpeed.toFloat()) / log10(1f + 120f * 1024f * 1024f)).coerceIn(0f, 1f) + } + + AuroraBackground { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + actionIconContentColor = Color.White.copy(alpha = 0.85f), + ), + title = { GradientText("传输", fontSize = 24) }, + actions = { + TextButton(onClick = { vm.transferManager.clearFinished() }) { + Text("清除已完成", color = Neon.TextDim) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // ---- 速度仪表盘 ---- + item { + GlassCard(Modifier.fillMaxWidth(), edge = Neon.Cyan) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GlowRing( + fraction = if (running.isEmpty()) 0f else gaugeFrac, + sizeDp = 128.dp, + stroke = 9.dp, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + Files.humanSize(curSpeed), + color = Color.White, + fontWeight = FontWeight.Black, + fontSize = 17.sp, + ) + Text("字节/秒", color = Neon.TextDim, fontSize = 10.sp) + } + } + Spacer(Modifier.width(20.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + "${Files.humanSize(peak)}/s", + style = androidx.compose.ui.text.TextStyle( + brush = Neon.Primary, fontSize = 20.sp, fontWeight = FontWeight.Bold, + ), + ) + Spacer(Modifier.width(6.dp)) + if (running.isNotEmpty()) EqualizerBars(max = 14.dp) + } + Text("本次会话峰值速度", color = Neon.TextDim, fontSize = 11.sp) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + StatChip(Neon.Lime, "进行中", running.size.toString()) + StatChip(Neon.Cyan, "完成", doneCount.toString()) + StatChip(Neon.Red, "失败", failedCount.toString()) + } + } + } + } + } + + if (items.isEmpty()) { + item { + GlassCard(Modifier.fillMaxWidth(), edge = Neon.Violet) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 50.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("暂无传输任务", color = Color.White, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text("去文件页挑个大文件试试多流加速", color = Neon.TextDim, fontSize = 12.sp) + } + } + } + } + + items(items, key = { it.id }) { t -> + TransferCard( + t, + onCancel = { vm.transferManager.cancel(t.id) }, + onOpen = { t.localPath?.let { vm.openLocal(it) } }, + onRetry = { vm.transferManager.retry(t.id) }, + ) + } + item { Spacer(Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun StatChip(color: Color, label: String, value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusDot(color, dotSize = 7.dp) + Spacer(Modifier.width(4.dp)) + Text(value, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.sp) + Spacer(Modifier.width(3.dp)) + Text(label, color = Neon.TextDim, fontSize = 11.sp) + } +} + +@Composable +private fun TransferCard( + t: com.smbfast.app.transfer.TransferItem, + onCancel: () -> Unit, + onOpen: () -> Unit, + onRetry: () -> Unit, +) { + val frac: Float? = if (t.total > 0) (t.done.toFloat() / t.total.toFloat()).coerceIn(0f, 1f) else null + val animFrac by animateFloatAsState(targetValue = frac ?: 0f, label = "frac") + + GlassCard(Modifier.fillMaxWidth(), edge = if (t.direction == Direction.DOWNLOAD) Neon.Cyan else Neon.Pink) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NeonIconBox( + brush = if (t.direction == Direction.DOWNLOAD) Neon.Primary else Neon.Hot, + corner = 12.dp, + ) { + Icon( + if (t.direction == Direction.DOWNLOAD) Icons.Default.Download else Icons.Default.Upload, + contentDescription = null, + tint = Color(0xFF06101C), + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(10.dp)) + Text( + t.name, + modifier = Modifier.weight(1f), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when { + t.status == Status.RUNNING -> + IconButton(onClick = onCancel) { + Icon(Icons.Default.Close, contentDescription = "取消", tint = Neon.TextDim) + } + t.status == Status.DONE && t.localPath != null -> + IconButton(onClick = onOpen) { + Icon(Icons.Default.OpenInNew, contentDescription = "打开", tint = Neon.Lime) + } + t.status == Status.FAILED && t.profile != null -> + IconButton(onClick = onRetry) { + Icon( + Icons.Default.Refresh, contentDescription = "重试", + tint = Neon.Cyan, + ) + } + } + } + Spacer(Modifier.height(8.dp)) + when (t.status) { + Status.RUNNING -> { + NeonProgress( + fraction = if (t.total > 0) animFrac else null, + modifier = Modifier.fillMaxWidth(), + brush = if (t.direction == Direction.DOWNLOAD) Neon.Primary else Neon.Hot, + ) + Spacer(Modifier.height(6.dp)) + Text( + "${Files.humanSize(t.done)} / ${Files.humanSize(t.total)}" + + if (t.speedBps > 0) " · ${Files.humanSize(t.speedBps)}/s" else "", + color = Neon.TextDim, + fontSize = 11.sp, + ) + } + Status.DONE -> Text( + "✔ 完成 · ${Files.humanSize(t.done)}" + (t.localPath?.let { " · $it" } ?: ""), + color = Neon.Lime, + fontSize = 11.sp, + ) + Status.FAILED -> Text( + "✘ 失败:${t.error ?: "未知错误"}", + color = Neon.Red, + fontSize = 11.sp, + ) + Status.CANCELLED -> Text( + "已取消", color = Neon.TextFaint, fontSize = 11.sp, + ) + } + } + } +} diff --git a/app/src/main/java/com/smbfast/app/ui/theme/Theme.kt b/app/src/main/java/com/smbfast/app/ui/theme/Theme.kt new file mode 100644 index 0000000..f86c580 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/ui/theme/Theme.kt @@ -0,0 +1,62 @@ +package com.smbfast.app.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +/** + * 「极光玻璃」视觉体系:深夜空底色 + 霓虹渐变。 + */ +object Neon { + val Cyan = Color(0xFF22D3EE) + val Blue = Color(0xFF60A5FA) + val Violet = Color(0xFF8B5CF6) + val Pink = Color(0xFFF472B6) + val Lime = Color(0xFFA3E635) + val Amber = Color(0xFFFBBF24) + val Red = Color(0xFFFF7A93) + + val Bg0 = Color(0xFF070B16) + val Bg1 = Color(0xFF0D1424) + val GlassHi = Color.White.copy(alpha = 0.08f) + val GlassLo = Color.White.copy(alpha = 0.035f) + val TextDim = Color(0xFF93A7CC) + val TextFaint = Color(0xFF5C6E93) + + val Primary = Brush.linearGradient(listOf(Cyan, Blue, Violet)) + val Hot = Brush.linearGradient(listOf(Violet, Pink)) + val Fresh = Brush.linearGradient(listOf(Lime, Cyan)) + val Sunset = Brush.linearGradient(listOf(Amber, Pink)) + + // 逐类型图标渐变:文件夹/视频/图片/音乐/压缩/文档/其他 + val Folder = Brush.linearGradient(listOf(Amber, Color(0xFFF97316))) + val Video = Brush.linearGradient(listOf(Violet, Color(0xFF6D28D9))) + val Image = Brush.linearGradient(listOf(Pink, Color(0xFFDB2777))) + val Music = Brush.linearGradient(listOf(Cyan, Color(0xFF0891B2))) + val Archive = Brush.linearGradient(listOf(Lime, Color(0xFF16A34A))) + val Doc = Brush.linearGradient(listOf(Blue, Color(0xFF2563EB))) + val File = Brush.linearGradient(listOf(Color(0xFF94A3B8), Color(0xFF64748B))) +} + +private val DarkScheme = darkColorScheme( + primary = Neon.Cyan, + onPrimary = Color(0xFF04121E), + secondary = Neon.Violet, + tertiary = Neon.Pink, + background = Neon.Bg0, + onBackground = Color(0xFFEAF2FF), + surface = Neon.Bg1, + onSurface = Color(0xFFEAF2FF), + surfaceVariant = Color(0xFF131C32), + onSurfaceVariant = Neon.TextDim, + outline = Color(0xFF2A3854), + error = Neon.Red, +) + +/** 应用强制使用暗夜霓虹主题(不随系统切换) */ +@Composable +fun SmbFastTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = DarkScheme, content = content) +} diff --git a/app/src/main/java/com/smbfast/app/util/CrashLog.kt b/app/src/main/java/com/smbfast/app/util/CrashLog.kt new file mode 100644 index 0000000..da133c6 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/util/CrashLog.kt @@ -0,0 +1,50 @@ +package com.smbfast.app.util + +import android.content.Context +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** 应用内错误日志:所有未捕获崩溃与 SMB 异常的完整堆栈都会追加到 files/crash.log */ +object CrashLog { + private var file: File? = null + private val ts = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + + fun init(ctx: Context) { + file = File(ctx.filesDir, "crash.log") + val prev = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { t, e -> + append("uncaught@${t.name}", e) + prev?.uncaughtException(t, e) + } + } + + @Synchronized + fun append(source: String, e: Throwable) { + runCatching { + file?.appendText( + "===== $source ${ts.format(Date())} =====\n" + + Log.getStackTraceString(e) + "\n\n", + ) + } + } + + @Synchronized + fun note(source: String, message: String) { + runCatching { + file?.appendText("===== $source ${ts.format(Date())} =====\n$message\n\n") + } + } + + fun readTail(maxChars: Int = 8000): String? = + file?.takeIf { it.exists() }?.let { f -> + runCatching { f.readText().takeLast(maxChars) }.getOrNull() + } + + @Synchronized + fun clear() { + runCatching { file?.delete() } + } +} diff --git a/app/src/main/java/com/smbfast/app/util/Files.kt b/app/src/main/java/com/smbfast/app/util/Files.kt new file mode 100644 index 0000000..76e9cb9 --- /dev/null +++ b/app/src/main/java/com/smbfast/app/util/Files.kt @@ -0,0 +1,97 @@ +package com.smbfast.app.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import java.io.File + +object Files { + + /** 下载目录:优先公共 Download/SMBFast,不可写(旧系统/受限存储)则回退应用私有目录 */ + fun resolveDownloadDir(ctx: Context): File { + val fallback = File(ctx.getExternalFilesDir(null) ?: ctx.filesDir, "SMBFast") + return try { + val pub = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "SMBFast", + ) + pub.mkdirs() + val probe = File(pub, ".probe") + if (probe.createNewFile()) { + probe.delete() + pub + } else { + fallback.apply { mkdirs() } + } + } catch (e: Exception) { + fallback.apply { mkdirs() } + } + } + + /** 目标重名时自动追加序号,避免覆盖已有文件 */ + fun uniqueTarget(dir: File, name: String): File { + var f = File(dir, name) + if (!f.exists()) return f + val dot = name.lastIndexOf('.') + val base = if (dot > 0) name.substring(0, dot) else name + val ext = if (dot > 0) name.substring(dot) else "" + var i = 1 + while (f.exists()) { + f = File(dir, "$base ($i)$ext") + i++ + } + return f + } + + fun mimeFor(file: File): String = + MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension.lowercase()) ?: "*/*" + + fun openWithViewer(ctx: Context, file: File): Boolean { + return try { + val uri: Uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeFor(file)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + false + } catch (e: Exception) { + false + } + } + + /** 查询 SAF Uri 的显示名与大小 */ + fun queryNameAndSize(ctx: Context, uri: Uri): Pair { + var name: String? = null + var size: Long? = null + runCatching { + ctx.contentResolver.query(uri, null, null, null, null)?.use { c -> + val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val si = c.getColumnIndex(OpenableColumns.SIZE) + if (c.moveToFirst()) { + if (ni >= 0) name = c.getString(ni) + if (si >= 0 && !c.isNull(si)) size = c.getLong(si) + } + } + } + return name to size + } + + fun humanSize(bytes: Long): String { + if (bytes < 0) return "未知大小" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + var v = bytes.toDouble() + var u = 0 + while (v >= 1024.0 && u < units.size - 1) { + v /= 1024.0 + u++ + } + return if (u == 0) "${bytes} B" else String.format("%.1f %s", v, units[u]) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..9aa851d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3cf8b29 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0D47A1 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a0ed27d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + SMB 极速 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..43965e1 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..e1f3dc3 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/test/java/com/smbfast/app/PathsTest.kt b/app/src/test/java/com/smbfast/app/PathsTest.kt new file mode 100644 index 0000000..0fa044a --- /dev/null +++ b/app/src/test/java/com/smbfast/app/PathsTest.kt @@ -0,0 +1,31 @@ +package com.smbfast.app + +import com.smbfast.app.smb.SmbRepo +import org.junit.Assert.assertEquals +import org.junit.Test + +/** SMB 路径工具回归测试 */ +class PathsTest { + + @Test + fun joinPath() { + assertEquals("a\\b", SmbRepo.joinPath("a", "b")) + assertEquals("a\\b\\c", SmbRepo.joinPath("a\\b", "c")) + assertEquals("b", SmbRepo.joinPath("", "b")) + } + + @Test + fun parentPath() { + assertEquals("a", SmbRepo.parentPath("a\\b")) + assertEquals("a\\b", SmbRepo.parentPath("a\\b\\c")) + assertEquals("", SmbRepo.parentPath("b")) + assertEquals("", SmbRepo.parentPath("")) + } + + @Test + fun displayNameOf() { + assertEquals("b.txt", SmbRepo.displayNameOf("dir\\b.txt")) + assertEquals("file.zip", SmbRepo.displayNameOf("file.zip")) + assertEquals("x", SmbRepo.displayNameOf("a\\b\\x")) + } +} diff --git a/app/src/test/java/com/smbfast/app/ShareParseTest.kt b/app/src/test/java/com/smbfast/app/ShareParseTest.kt new file mode 100644 index 0000000..c692ec4 --- /dev/null +++ b/app/src/test/java/com/smbfast/app/ShareParseTest.kt @@ -0,0 +1,29 @@ +package com.smbfast.app + +import com.smbfast.app.smb.ShareEnumerator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 用真实 fnOS 服务器捕获的响应验证共享枚举解析(防移植回归) */ +class ShareParseTest { + @Test + fun parseRealFnOsResponse() { + val bytes = javaClass.classLoader!! + .getResourceAsStream("netshareenum_resp.bin")!! + .readBytes() + assertEquals(884, bytes.size) + + val page = ShareEnumerator.parse(bytes) + assertEquals(13, page.count) + val expected = listOf( + "CBB-003备份", "FNOS日志归档", "Music", "Photos", "model", "movies", + "openclaw_workzone", "原百度盘", "原阿里盘", "手机备份", "来自:百度网盘", "远程存储", "IPC$", + ) + assertEquals(expected, page.names) + // 对外可见列表应过滤 IPC$ + val visible = page.names.filter { !it.endsWith("$") } + assertEquals(12, visible.size) + assertTrue(page.names.contains("IPC$")) + } +} diff --git a/app/src/test/resources/netshareenum_resp.bin b/app/src/test/resources/netshareenum_resp.bin new file mode 100644 index 0000000000000000000000000000000000000000..b0cc484e666b8931f468f8d961e7b18a60bb4dfb GIT binary patch literal 884 zcmaiz%S!@L6vn?4%U&SSBBI4CToe>Y*^{8ih@v!87eHIJ z<2A+l0nDIh|0`mzNi4E98ND_vZXkkP#PMOireo<`Q64!yhu!KIhH;J>j*vr1gbW?vRNJ`z#@Y3#1uUoZ$pbvchNW z>-6aFg^%&8#?#?jL#CXb9sc&MJiOd@_HAdE3ifHV=ksb^p;`F69sQQtd)JcHsp+t_ z_WNCbGkNeF?bPEIPa_1sRVARcg!a6_(>J*@va>lVgYR(;SzgnaW6Uz<5o9bf4DP+{ emb-Z4+}|?Y8GcJ3#;jpX@PhUKxzu-7%KiYpv`CWx literal 0 HcmV?d00001 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b9658bf --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..04894fe --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..02f06ab2a396743f0052e4328b0e5ac2cdd8b3d2 GIT binary patch literal 44309 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

c5y0{ce=(6`v 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 dO5@F&^RnGiJs4tyT8mVElx{{eaL49frj literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d6d2988 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https://mirrors.cloud.tencent.com/gradle/gradle-8.14.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ba764e8 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + repositories { + maven("https://maven.aliyun.com/repository/gradle-plugin") + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/central") + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/central") + google() + mavenCentral() + } +} + +rootProject.name = "SmbFast" +include(":app") diff --git a/smbfast.keystore b/smbfast.keystore new file mode 100644 index 0000000000000000000000000000000000000000..020a8c49e0235120839802189793c329ac697267 GIT binary patch literal 2666 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 literal 0 HcmV?d00001