SmbFast v20 (1.18.0):SMB 高速传输安卓客户端
- SMB 3.1.1 多流条带传输(下载/上传),轮转交错写入、48MB 内存护栏 - 前台服务保活 + WiFi/CPU 锁,后台传输不断连 - HyperOS 焦点通知/超级岛适配(miui.focus.param 双轨协议) - 全屏连接编辑器:共享多选、批量建连;错误中文提示 + TCP 预探测 - 凭据 AndroidKeyStore 加密存储;失败自动/手动重试;空间预检 - 全局单层极光背景(修复切页闪烁)+ 单元测试
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
captures/
|
||||
|
||||
# Android
|
||||
local.properties
|
||||
|
||||
# 构建产物(APK 由分发目录/发版流程管理,不进 git)
|
||||
dist/
|
||||
*.apk
|
||||
*.aab
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+21
@@ -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.** { <fields>; }
|
||||
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken { <fields>; }
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SmbFast">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".transfer.TransferService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<ConnectionProfile> {
|
||||
val raw = loadJson("connections") ?: return mutableListOf()
|
||||
val type = object : TypeToken<MutableList<ConnectionProfile>>() {}.type
|
||||
val list = runCatching { gson.fromJson<MutableList<ConnectionProfile>>(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<ConnectionProfile>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String>()
|
||||
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<String>, 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<String>(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<String> {
|
||||
val found = ArrayList<String>()
|
||||
val session = SmbClientPool.session(p)
|
||||
for (name in COMMON) {
|
||||
runCatching {
|
||||
val sh = session.connectShare(name)
|
||||
found.add(name)
|
||||
runCatching { sh.close() }
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -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<String, Session>()
|
||||
private val shares = ConcurrentHashMap<String, ShareEntry>()
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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<SmbEntry> {
|
||||
val sh: DiskShare = SmbClientPool.diskShare(p, share)
|
||||
val raw: List<FileIdBothDirectoryInformation> = 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<SmbEntry> { 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,
|
||||
)
|
||||
}
|
||||
@@ -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<List<TransferItem>>(emptyList())
|
||||
val items: StateFlow<List<TransferItem>> = _items.asStateFlow()
|
||||
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
private val progresses = ConcurrentHashMap<String, FastTransfer.Progress>()
|
||||
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
@@ -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<TransferItem>?): 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<TransferItem>, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<com.smbfast.app.smb.SmbEntry> = 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<List<ConnectionProfile>> = _connections.asStateFlow()
|
||||
|
||||
private val _settings = MutableStateFlow(prefs.loadSettings())
|
||||
val settings: StateFlow<SpeedSettings> = _settings.asStateFlow()
|
||||
|
||||
private val _screen = MutableStateFlow<Screen>(Screen.Connections)
|
||||
val screen: StateFlow<Screen> = _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<String> = emptyList(),
|
||||
val discovering: Boolean = false,
|
||||
val discovered: List<String>? = null,
|
||||
val testing: Boolean = false,
|
||||
val connecting: Boolean = false,
|
||||
val message: String? = null,
|
||||
val messageOk: Boolean = false,
|
||||
)
|
||||
|
||||
private val _editing = MutableStateFlow<ConnectionProfile?>(null)
|
||||
val editing: StateFlow<ConnectionProfile?> = _editing.asStateFlow()
|
||||
|
||||
private val _editUi = MutableStateFlow(EditUiState())
|
||||
val editUi: StateFlow<EditUiState> = _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<Uri>) {
|
||||
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 <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
AppViewModel(ctx.applicationContext) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SmbEntry?>(null) }
|
||||
var renameEntry by remember { mutableStateOf<SmbEntry?>(null) }
|
||||
var deleteEntry by remember { mutableStateOf<SmbEntry?>(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) } },
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ConnectionProfile?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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<String?, Long?> {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- 文件夹 -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,40 h14 l6,6 h26 a4,4 0 0 1 4,4 v24 a4,4 0 0 1 -4,4 h-46 a4,4 0 0 1 -4,-4 v-30 a4,4 0 0 1 4,-4 z" />
|
||||
<!-- 下载箭头 -->
|
||||
<path
|
||||
android:fillColor="#0D47A1"
|
||||
android:pathData="M50,48 h8 v10 h6 l-10,10 -10,-10 h6 z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0D47A1</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SMB 极速</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.SmbFast" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path name="external_download" path="Download/SMBFast/" />
|
||||
<external-files-path name="app_download" path="SMBFast/" />
|
||||
<files-path name="internal_files" path="SMBFast/" />
|
||||
</paths>
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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$"))
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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")
|
||||
Binary file not shown.
Reference in New Issue
Block a user