SmbFast v20 (1.18.0):SMB 高速传输安卓客户端

- SMB 3.1.1 多流条带传输(下载/上传),轮转交错写入、48MB 内存护栏
- 前台服务保活 + WiFi/CPU 锁,后台传输不断连
- HyperOS 焦点通知/超级岛适配(miui.focus.param 双轨协议)
- 全屏连接编辑器:共享多选、批量建连;错误中文提示 + TCP 预探测
- 凭据 AndroidKeyStore 加密存储;失败自动/手动重试;空间预检
- 全局单层极光背景(修复切页闪烁)+ 单元测试
This commit is contained in:
cbb
2026-10-11 20:59:05 +08:00
commit 8c1383fe4f
46 changed files with 5130 additions and 0 deletions
+85
View File
@@ -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")
}
+21
View File
@@ -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>; }
+47
View File
@@ -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>
+38
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0D47A1</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SMB 极速</string>
</resources>
+7
View File
@@ -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>
+6
View File
@@ -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.