Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ with the pre-1.0 rules described in [docs/VERSIONING.md](docs/VERSIONING.md).

## [Unreleased]

## [0.3.13] - 2026-10-17

### Fixed

- Desktop: 显示方式 / right-click overlays no longer eat the pane area; the card hangs under its
button and the five modes keep their marks.
- Desktop: notices are drawn over the window instead of taking a row of their own.
- Desktop: a pane takes focus when clicked, and 显示方式 acts on the focused pane.
- Desktop favourites: grouped by 本端 / 远端 and protocol, opened in the focused pane, deletable,
and `+` adds an address rather than only the current view.
- Desktop: USB works through `adb forward`; the address box is split into host and port with the
protocol's port as the default.
- Accent: one list on both ends, the first chip is the brand indigo called 默认; the broken
「回到品牌靛蓝」 button is gone.
- Android: the system back button closes panels, leaves screens, returns to 传输, then goes up one
folder, and only then exits.
- Android: 停止共享 updates the card; breadcrumbs show every layer and scroll to the current one.
- Android: 分栏方向 uses the real screen size, so 自动 is two columns in landscape.
- Android: no root, ← stops at the exposed root instead of climbing to `/`.
- Android: 连接另一台设备 is a visible card with host and port; connecting opens the remote pane on
the first shared folder, and an unshared path lists what is shared instead of 读不了这个文件夹.
- 本机 → 本端 throughout: the far end may be another computer, another disk, or a WebDAV mount.

## [0.3.12] - 2026-10-17

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.cubsync.android

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -55,6 +56,11 @@ import kotlinx.coroutines.delay
fun CubAndroidApp(state: AndroidAppState) {
val colors = CubTheme.colors
val route = state.route
state.revision

// Back undoes the last thing, and only leaves when there is nothing left to undo.
// Without this every screen exited the application, including a folder.
BackHandler(enabled = state.canGoBack) { state.handleBack() }

Box(Modifier.fillMaxSize().background(colors.background)) {
// The system bars sit over the app (MainActivity calls enableEdgeToEdge), so the
Expand Down Expand Up @@ -341,11 +347,12 @@ private fun Drawer(state: AndroidAppState) {
detail = item.path,
leadingIcon = IconPaths.FOLDER,
modifier = Modifier.padding(horizontal = 8.dp),
onClick = {
state.drawerOpen = false
state.go(Section.TRANSFER)
state.openPane(io.github.cubsync.service.Side.LEFT, item.path, item.backendId)
trailing = {
IconButton(IconPaths.CLOSE, size = 30, mark = 13) {
state.removeFavourite(item)
}
},
onClick = { state.openFavourite(item) },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class AndroidAppState(
var peers by mutableStateOf<List<PeerSession>>(emptyList())
var peerError by mutableStateOf<String?>(null)
var peerDraft by mutableStateOf("")
var peerHost by mutableStateOf("")
var peerPortText by mutableStateOf("")
var connecting by mutableStateOf(false)

var running by mutableStateOf<RunningJob?>(null)
Expand Down Expand Up @@ -180,7 +182,11 @@ class AndroidAppState(
usableRoot()?.let { service.settings.sharedRoots = listOf(it) }
}
rootDraft = service.settings.sharedRoots
rootDraft = service.settings.sharedRoots

// Tell the backend what this phone can actually open. Without this the local
// backend exposes no roots, so 上一层 had no boundary to stop at and walked from
// /storage/emulated/0 up to / — three levels the process cannot read.
localRoots().forEach { service.browsePool.registerLocalRoot(it) }
}

fun close() {
Expand Down Expand Up @@ -218,9 +224,20 @@ class AndroidAppState(
* cannot race one the caller asked for.
*/
private fun openSomethingToLookAt() {
if (service.browsing.current(Side.LEFT).path.isNotBlank()) return
(rootDraft.firstOrNull() ?: usableRoot())?.let {
service.browsing.open(Side.LEFT, EndpointId.LOCAL, it)
val left = service.browsing.current(Side.LEFT)
if (left.path.isBlank() || left.backendId != EndpointId.LOCAL) {
(rootDraft.firstOrNull() ?: usableRoot())?.let {
service.browsing.open(Side.LEFT, EndpointId.LOCAL, it)
}
}
// The right pane is 远端. When a peer is connected it opens on that peer rather
// than on this phone's own disk, which is what made 远端栏 show "/" and then refuse
// a path from the other machine: it was still the local backend.
val right = service.browsing.current(Side.RIGHT)
if (right.backendId == EndpointId.LOCAL) {
peers.firstOrNull()?.let { peer ->
service.browsing.open(Side.RIGHT, peer.backendId, peer.roots.firstOrNull().orEmpty())
}
}
}

Expand All @@ -232,6 +249,52 @@ class AndroidAppState(
route = Route.Tabs
}

/**
* The system back button, in the order a person would undo things.
*
* Android's back used to leave the application from every screen, so a folder entered
* in 传输 closed CubSync instead of going back to the folder. Panels close, then the
* screen, then the section, then one folder up — and only when there is nothing left to
* undo does the system get the event and finish the activity.
*/
fun handleBack() {
when {
drawerOpen -> drawerOpen = false
moreSheet -> moreSheet = false
connectSheet -> connectSheet = false
sharedSheet -> sharedSheet = false
screenMenu -> screenMenu = false
paneMenu != null -> paneMenu = null
actionSheet != null -> actionSheet = null
route !is Route.Tabs -> route = Route.Tabs
tab != Section.TRANSFER -> go(Section.TRANSFER)
else -> {
val side = liveSettings.activeSide
service.browsing.up(side)
touch()
}
}
}

/** Whether back has something to undo; when it does not, the application closes. */
val canGoBack: Boolean
get() {
if (drawerOpen || moreSheet || connectSheet || sharedSheet || screenMenu ||
paneMenu != null || actionSheet != null || route !is Route.Tabs ||
tab != Section.TRANSFER
) {
return true
}
val side = liveSettings.activeSide
val pane = service.browsing.current(side)
if (pane.path.isBlank()) return false
val roots = service.browsing.exposedRoots(pane.backendId)
val root = roots.filter { pane.path == it || pane.path.startsWith("$it/") }
.maxByOrNull { it.length }
// At the exposed root there is no parent to show, so back means "leave".
return root == null || pane.path != root
}

val selectedConfig: SyncConfig?
get() = selectedConfigId?.let { id -> configs.firstOrNull { it.configId == id } }

Expand Down Expand Up @@ -434,6 +497,20 @@ class AndroidAppState(

fun drawer(): DrawerModel = service.drawer(serverState)

/** Opens a favourite in the focused pane, which is where the next tap would go. */
fun openFavourite(favourite: Favorite) {
drawerOpen = false
go(Section.TRANSFER)
service.browsing.open(liveSettings.activeSide, favourite.backendId, favourite.path)
touch()
}

fun removeFavourite(favourite: Favorite) {
service.settings.removeFavourite(favourite)
revision++
notify("已取消收藏 ${favourite.path}", NoticeKind.INFO)
}

/** 收藏 a configuration — the star in the top bar of its screen. */
fun toggleConfigStar() {
val config = selectedConfig ?: return
Expand Down Expand Up @@ -612,6 +689,23 @@ class AndroidAppState(

// ---------------------------------------------------------------------- peers

/** A host and a port typed apart; an empty port means the protocol default. */
fun connectPeerAddress() {
val host = peerHost.trim()
if (host.isEmpty()) {
peerError = "先填地址"
return
}
val port = peerPortText.trim().let {
if (it.isEmpty()) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else it.toIntOrNull()
}
if (port == null || port !in 1..65535) {
peerError = "端口要填 1–65535,留空就用默认 ${io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT}"
return
}
connectPeer("$host:$port")
}

fun connectPeer(spec: String) {
val text = spec.trim()
if (text.isEmpty()) {
Expand All @@ -631,7 +725,17 @@ class AndroidAppState(
peers = peers.filterNot { it.backendId == session.backendId } + session
service.settings.lastPeer = text
revision++
notify("已连接 ${session.nodeName}", NoticeKind.SUCCESS)
// A handshake that leaves both panes as they were is a handshake the person
// cannot see. The right pane becomes the peer and opens on the first folder
// it offers — the same thing the desktop does when it connects out.
val first = session.roots.firstOrNull()
service.browsing.open(Side.RIGHT, session.backendId, first.orEmpty())
liveSettings.activeSide = Side.RIGHT
notify(
if (first == null) "已连接 ${session.nodeName},但对面没有共享文件夹"
else "已连接 ${session.nodeName} · ${first}",
if (first == null) NoticeKind.WARNING else NoticeKind.SUCCESS,
)
} catch (failure: Throwable) {
peerError = failure.message ?: "连接失败"
notify("连接失败:$peerError", NoticeKind.ERROR)
Expand Down Expand Up @@ -700,16 +804,45 @@ class AndroidAppState(

fun stopServer() {
server.stop()
// The screen reads `serverState` off the controller, which is not Compose state;
// without this the button stayed 停止共享 and the address stayed on screen after
// the server had already stopped.
revision++
notify("已停止共享", NoticeKind.INFO)
}

fun setRunMode(mode: RunMode) {
changeSettings { it.runMode = mode }
if (mode == RunMode.ROOT) {
notify("已切到 Root 模式:设备需要已获取 Root 权限,普通模式下这一档不会生效", NoticeKind.WARNING)
// Say what is true rather than what the label promises. The two privileged modes
// need a backend that speaks through `su` or Shizuku, and this build does not have
// one — so the setting is recorded, and the sentence beside it says so.
when (mode) {
RunMode.NORMAL -> notify("已切到普通模式", NoticeKind.INFO)
RunMode.ROOT -> notify(
if (suAvailable()) "已记录 Root 模式;本版引擎仍按应用权限读文件,su 只用于探测"
else "没有拿到 Root 权限:su 不可用或被拒绝",
NoticeKind.WARNING,
)
RunMode.SHIZUKU -> notify("Shizuku 需要 Shizuku 应用与它的 API;本版还没有接入", NoticeKind.WARNING)
}
}

/** Whether `su` answers with uid 0. A probe, not a promise. */
private fun suAvailable(): Boolean = runCatching {
val process = ProcessBuilder("su", "-c", "id").redirectErrorStream(true).start()
// Close its input: `su` that wants a password otherwise waits for one, and reading
// its output first blocks the caller forever. That is what hung CI's test step —
// the JVM there has `su` and no root.
runCatching { process.outputStream.close() }
val finished = process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
return@runCatching false
}
process.exitValue() == 0 &&
process.inputStream.bufferedReader().use { it.readText() }.contains("uid=0")
}.getOrDefault(false)

// ------------------------------------------------------------ 传输通道

/** The cards 传输通道 offers, taken from the system rather than invented. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -47,7 +48,9 @@ import io.github.cubsync.service.Words
@Composable
fun ConnectScreen(state: AndroidAppState) {
val colors = CubTheme.colors
val server = state.serverState
// Collected, not read off the controller: `serverState` is a plain getter, so the card
// stayed 停止共享 after the server had already stopped.
val server by state.server.state.collectAsState()
val settings = state.liveSettings
var modeOpen by remember { mutableStateOf(false) }
val copy = rememberClipboard()
Expand Down Expand Up @@ -160,6 +163,35 @@ fun ConnectScreen(state: AndroidAppState) {
}
}

SectionLabel("连接另一台设备")
Panel(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Field(
value = state.peerHost,
onValueChange = { state.peerHost = it },
modifier = Modifier.weight(2f),
placeholder = "地址,如 192.168.1.20",
onSubmit = { state.connectPeerAddress() },
)
Spacer(Modifier.width(6.dp))
Field(
value = state.peerPortText,
onValueChange = { state.peerPortText = it },
modifier = Modifier.weight(1f),
placeholder = "${settings.sharePort}",
onSubmit = { state.connectPeerAddress() },
)
}
Spacer(Modifier.height(8.dp))
PrimaryButton(if (state.connecting) "连接中…" else "连接", enabled = !state.connecting) {
state.connectPeerAddress()
}
state.peerError?.let {
Text(it, style = CubType.rowDetail, color = colors.danger)
}
CardDesc("端口留空就用对面共享时显示的那个;连上后远端栏会打开它共享的第一个目录。")
}

// The prototype's 连接 screen stops after the two buttons and 最近. An
// 账户与连接状态 card was added here; accounts are a thing the drawer already
// lists, and a screen that says the same thing twice is a screen with one
Expand Down
Loading
Loading