aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/invalid/lena/rsend/Config.kt125
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt49
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt13
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt94
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt98
-rw-r--r--app/src/main/java/invalid/lena/rsend/RsyncRunner.kt2
-rw-r--r--app/src/main/java/invalid/lena/rsend/Scheduler.kt4
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncWorker.kt25
-rw-r--r--app/src/main/res/layout/activity_folder.xml15
-rw-r--r--app/src/main/res/layout/activity_main.xml98
-rw-r--r--app/src/main/res/layout/activity_remote.xml24
-rw-r--r--app/src/main/res/layout/item_row.xml (renamed from app/src/main/res/layout/item_folder.xml)7
-rw-r--r--app/src/test/java/invalid/lena/rsend/ConfigTest.kt111
-rw-r--r--app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt12
14 files changed, 507 insertions, 170 deletions
diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt
index 2662ea3..69f3869 100644
--- a/app/src/main/java/invalid/lena/rsend/Config.kt
+++ b/app/src/main/java/invalid/lena/rsend/Config.kt
@@ -7,10 +7,20 @@ import org.json.JSONObject
import java.io.File
import java.io.FileNotFoundException
-// Config is rsend's whole state: the remote target, the schedule, and the
-// folders to push. It is stored as plain JSON in app-private storage.
+// Config is rsend's whole state: the named remote targets, the schedule, and
+// the folders to push. It is stored as plain JSON in app-private storage.
+// config.json owns the host-key pins (Remote.hostKey); the known_hosts file
+// rsh reads is regenerated from them before every sync.
-data class Remote(val host: String = "", val port: Int = 22, val user: String = "")
+data class Remote(
+ val name: String = "",
+ val host: String = "",
+ val port: Int = 22,
+ val user: String = "",
+ val hostKey: String = "",
+) {
+ fun pinned(): Boolean = hostKey.isNotEmpty()
+}
data class Schedule(
val enabled: Boolean = false,
@@ -26,21 +36,37 @@ data class Schedule(
data class Folder(
val name: String = "",
val local: String = "",
- val remote: String = "",
+ val remoteName: String = "",
+ val remotePath: String = "",
val delete: Boolean = false,
val excludes: List<String> = emptyList(),
)
data class Config(
- val remote: Remote = Remote(),
+ val remotes: List<Remote> = emptyList(),
val schedule: Schedule = Schedule(),
val folders: List<Folder> = emptyList(),
) {
+ fun remote(name: String): Remote? = remotes.firstOrNull { it.name == name }
+
+ // syncReady reports whether a sync can do useful work: an identity key,
+ // at least one folder, and at least one complete, pinned remote. Folders
+ // pointing at a broken remote fail individually during the run.
+ fun syncReady(keyExists: Boolean): Boolean =
+ keyExists && folders.isNotEmpty() &&
+ remotes.any { it.host.isNotEmpty() && it.user.isNotEmpty() && it.pinned() }
+
fun toJson(): JSONObject = JSONObject().apply {
- put("remote", JSONObject().apply {
- put("host", remote.host)
- put("port", remote.port)
- put("user", remote.user)
+ put("remotes", JSONArray().apply {
+ remotes.forEach { r ->
+ put(JSONObject().apply {
+ put("name", r.name)
+ put("host", r.host)
+ put("port", r.port)
+ put("user", r.user)
+ put("hostKey", r.hostKey)
+ })
+ }
})
put("schedule", JSONObject().apply {
put("enabled", schedule.enabled)
@@ -53,7 +79,8 @@ data class Config(
put(JSONObject().apply {
put("name", f.name)
put("local", f.local)
- put("remote", f.remote)
+ put("remoteName", f.remoteName)
+ put("remotePath", f.remotePath)
put("delete", f.delete)
put("excludes", JSONArray(f.excludes))
})
@@ -64,16 +91,38 @@ data class Config(
companion object {
fun file(ctx: Context): File = File(ctx.filesDir, "config.json")
+ // legacyShape reports whether o is a pre-0.2 config: no "remotes" array,
+ // but the single-remote "remote" object 0.1.x always wrote.
+ fun legacyShape(o: JSONObject): Boolean =
+ o.optJSONArray("remotes") == null && o.optJSONObject("remote") != null
+
fun load(ctx: Context): Config {
val f = file(ctx)
- return try {
+ val migrated: Boolean
+ val c = try {
val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() }
- fromJson(JSONObject(text))
+ val o = JSONObject(text)
+ migrated = legacyShape(o)
+ // Only the legacy single-remote shape needs the old pin file.
+ var pin = ""
+ if (migrated) {
+ val kh = Keys.knownHosts(ctx)
+ if (kh.exists()) pin = kh.readText().trim()
+ }
+ fromJson(o, pin)
} catch (_: FileNotFoundException) {
- Config()
+ return Config()
} catch (e: Exception) {
throw IllegalStateException("could not read ${f.name}: ${e.message}", e)
}
+ // Retire the old shape on the first read rather than waiting for the
+ // user to edit something. Without this a working 0.1.x setup keeps
+ // its pre-0.2 config.json indefinitely, and deleting the fallback in
+ // a later release would silently strand it. Writing the new shape
+ // also makes the fallback unreachable from here on: toJson always
+ // emits "remotes", so legacyShape can never match again.
+ if (migrated) save(ctx, c)
+ return c
}
// AtomicFile keeps the previous complete config if a write is interrupted.
@@ -89,8 +138,42 @@ data class Config(
}
}
- fun fromJson(o: JSONObject): Config {
- val r = o.optJSONObject("remote") ?: JSONObject()
+ // fromJson also reads the pre-0.2 single-remote shape once: the old
+ // "remote" object becomes the sole entry, named after its host, every
+ // folder points at it (the folder's old "remote" field was the
+ // destination path), and legacyPin carries the known_hosts line over.
+ // Remove this fallback after a release or two.
+ fun fromJson(o: JSONObject, legacyPin: String = ""): Config {
+ val ra = o.optJSONArray("remotes")
+ val legacy = if (ra == null) o.optJSONObject("remote") else null
+ val legacyName = legacy?.optString("host") ?: ""
+
+ val remotes = ArrayList<Remote>()
+ if (ra != null) {
+ for (i in 0 until ra.length()) {
+ val ro = ra.getJSONObject(i)
+ remotes.add(
+ Remote(
+ name = ro.optString("name"),
+ host = ro.optString("host"),
+ port = ro.optInt("port", 22),
+ user = ro.optString("user"),
+ hostKey = ro.optString("hostKey"),
+ )
+ )
+ }
+ } else if (legacyName.isNotEmpty()) {
+ remotes.add(
+ Remote(
+ name = legacyName,
+ host = legacyName,
+ port = legacy!!.optInt("port", 22),
+ user = legacy.optString("user"),
+ hostKey = legacyPin,
+ )
+ )
+ }
+
val s = o.optJSONObject("schedule") ?: JSONObject()
val fa = o.optJSONArray("folders") ?: JSONArray()
val folders = ArrayList<Folder>(fa.length())
@@ -103,18 +186,18 @@ data class Config(
Folder(
name = fo.optString("name"),
local = fo.optString("local"),
- remote = fo.optString("remote"),
+ // Gate on the legacy shape, not on the legacy host: a
+ // 0.1.x config with folders but no host yet still has
+ // real paths in the folders' old "remote" field.
+ remoteName = if (legacy != null) legacyName else fo.optString("remoteName"),
+ remotePath = if (legacy != null) fo.optString("remote") else fo.optString("remotePath"),
delete = fo.optBoolean("delete", false),
excludes = excludes,
)
)
}
return Config(
- remote = Remote(
- host = r.optString("host"),
- port = r.optInt("port", 22),
- user = r.optString("user"),
- ),
+ remotes = remotes,
schedule = Schedule(
enabled = s.optBoolean("enabled", false),
intervalMinutes = s.optInt("intervalMinutes", 120)
diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
index 466a859..14e04c9 100644
--- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
@@ -4,8 +4,10 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
+import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
+import android.widget.Spinner
import android.widget.Switch
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -19,10 +21,15 @@ class FolderEditActivity : AppCompatActivity() {
private var index: Int = -1
private lateinit var name: EditText
private lateinit var local: EditText
- private lateinit var remote: EditText
+ private lateinit var remoteName: Spinner
+ private lateinit var remotePath: EditText
private lateinit var excludes: EditText
private lateinit var delete: Switch
+ // remoteNames[i] is the real name behind spinner position i; the label
+ // differs only for a reference to a remote that no longer exists.
+ private var remoteNames: List<String> = emptyList()
+
// The picker returns a real filesystem path; drop it into the local field and
// name the folder after its basename if the name is still blank.
private val pickFolder =
@@ -42,20 +49,34 @@ class FolderEditActivity : AppCompatActivity() {
fitSystemBars()
name = findViewById(R.id.name)
local = findViewById(R.id.local)
- remote = findViewById(R.id.remote)
+ remoteName = findViewById(R.id.remoteName)
+ remotePath = findViewById(R.id.remotePath)
excludes = findViewById(R.id.excludes)
delete = findViewById(R.id.delete)
index = intent.getIntExtra("index", -1)
val cfg = Config.load(this)
- if (index in cfg.folders.indices) {
- val f = cfg.folders[index]
- name.setText(f.name)
- local.setText(f.local)
- remote.setText(f.remote)
- excludes.setText(f.excludes.joinToString(", "))
- delete.isChecked = f.delete
+ val f = if (index in cfg.folders.indices) cfg.folders[index] else Folder()
+ name.setText(f.name)
+ local.setText(f.local)
+ remotePath.setText(f.remotePath)
+ excludes.setText(f.excludes.joinToString(", "))
+ delete.isChecked = f.delete
+
+ // A reference to a missing remote (hand-edited config) stays selectable
+ // as "name (missing)" so Save cannot silently repoint the folder, which
+ // with mirror on would --delete into the wrong host.
+ val names = cfg.remotes.map { it.name }.toMutableList()
+ val labels = names.toMutableList()
+ if (f.remoteName.isNotEmpty() && f.remoteName !in names) {
+ names.add(0, f.remoteName)
+ labels.add(0, "${f.remoteName} (missing)")
}
+ remoteNames = names
+ remoteName.adapter =
+ ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, labels)
+ val sel = names.indexOf(f.remoteName)
+ if (sel >= 0) remoteName.setSelection(sel)
findViewById<Button>(R.id.browse).setOnClickListener {
pickFolder.launch(
@@ -71,16 +92,22 @@ class FolderEditActivity : AppCompatActivity() {
}
private fun saveFolder(): Boolean {
+ val pos = remoteName.selectedItemPosition
+ if (pos !in remoteNames.indices) {
+ Toast.makeText(this, "Add a remote first.", Toast.LENGTH_LONG).show()
+ return false
+ }
val f = Folder(
name = name.text.toString().trim(),
local = local.text.toString().trim(),
- remote = remote.text.toString().trim(),
+ remoteName = remoteNames[pos],
+ remotePath = remotePath.text.toString().trim(),
delete = delete.isChecked,
excludes = excludes.text.toString().split(",").map { it.trim() }.filter { it.isNotEmpty() },
)
// An empty path would rsync / (local) or the rrsync root (remote):
// refuse rather than sync the world.
- if (f.local.isEmpty() || f.remote.isEmpty()) {
+ if (f.local.isEmpty() || f.remotePath.isEmpty()) {
Toast.makeText(this, "Set both local and remote paths first.", Toast.LENGTH_LONG).show()
return false
}
diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt
index 501a124..5ba6d67 100644
--- a/app/src/main/java/invalid/lena/rsend/Keys.kt
+++ b/app/src/main/java/invalid/lena/rsend/Keys.kt
@@ -68,12 +68,11 @@ object Keys {
return Scan(true, lines[0], lines[1], "")
}
- fun pin(ctx: Context, line: String) {
- atomicWrite(knownHosts(ctx), (line + "\n").toByteArray())
- }
-
- fun clearPin(ctx: Context) {
- knownHosts(ctx).delete()
+ // writeKnownHosts regenerates the known_hosts file rsh verifies against.
+ // The pins live in config.json (Remote.hostKey); this file is derived
+ // state, rewritten from all pinned remotes before every sync.
+ fun writeKnownHosts(ctx: Context, lines: List<String>) {
+ atomicWrite(knownHosts(ctx), lines.joinToString("") { it + "\n" }.toByteArray())
}
// AtomicFile keeps the previous complete file if a write is interrupted.
@@ -88,6 +87,4 @@ object Keys {
throw e
}
}
-
- fun pinned(ctx: Context): Boolean = knownHosts(ctx).let { it.exists() && it.length() > 0 }
}
diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
index 1154449..2f300c9 100644
--- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
@@ -14,6 +14,7 @@ import android.os.PowerManager
import android.provider.Settings
import android.view.View
import android.widget.Button
+import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
@@ -30,12 +31,11 @@ import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
// MainActivity is the dashboard: a status hero, a primary Sync action, grouped
-// Setup and Permissions rows, and the list of folders. Editing happens in
-// RemoteActivity, ScheduleActivity, and FolderEditActivity.
+// Setup and Permissions rows, and the lists of remotes and folders. Editing
+// happens in RemoteActivity, ScheduleActivity, and FolderEditActivity.
class MainActivity : AppCompatActivity() {
private lateinit var lastSync: TextView
- private lateinit var remoteValue: TextView
private lateinit var keyValue: TextView
private lateinit var scheduleValue: TextView
private lateinit var dotAllFiles: View
@@ -44,6 +44,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var valNotif: TextView
private lateinit var dotBattery: View
private lateinit var valBattery: TextView
+ private lateinit var remotes: LinearLayout
private lateinit var folders: LinearLayout
private lateinit var btnSync: Button
private lateinit var syncBar: ProgressBar
@@ -63,7 +64,6 @@ class MainActivity : AppCompatActivity() {
setSupportActionBar(findViewById(R.id.toolbar))
fitSystemBars()
lastSync = findViewById(R.id.lastSync)
- remoteValue = findViewById(R.id.remoteValue)
keyValue = findViewById(R.id.keyValue)
scheduleValue = findViewById(R.id.scheduleValue)
dotAllFiles = findViewById(R.id.dotAllFiles)
@@ -72,6 +72,7 @@ class MainActivity : AppCompatActivity() {
valNotif = findViewById(R.id.valNotif)
dotBattery = findViewById(R.id.dotBattery)
valBattery = findViewById(R.id.valBattery)
+ remotes = findViewById(R.id.remotes)
folders = findViewById(R.id.folders)
btnSync = findViewById(R.id.btnSync)
syncBar = findViewById(R.id.syncBar)
@@ -81,9 +82,6 @@ class MainActivity : AppCompatActivity() {
btnSync.setOnClickListener { syncNow() }
observeSync()
- findViewById<LinearLayout>(R.id.rowRemote).setOnClickListener {
- startActivity(Intent(this, RemoteActivity::class.java))
- }
findViewById<LinearLayout>(R.id.rowKey).setOnClickListener { showKey() }
findViewById<LinearLayout>(R.id.rowSchedule).setOnClickListener {
startActivity(Intent(this, ScheduleActivity::class.java))
@@ -120,8 +118,15 @@ class MainActivity : AppCompatActivity() {
)
}
}
+ findViewById<Button>(R.id.btnAddRemote).setOnClickListener {
+ startActivity(Intent(this, RemoteActivity::class.java).putExtra("index", -1))
+ }
findViewById<Button>(R.id.btnAddFolder).setOnClickListener {
- startActivity(Intent(this, FolderEditActivity::class.java).putExtra("index", -1))
+ if (Config.load(this).remotes.isEmpty()) {
+ Toast.makeText(this, "Add a remote first.", Toast.LENGTH_LONG).show()
+ } else {
+ startActivity(Intent(this, FolderEditActivity::class.java).putExtra("index", -1))
+ }
}
findViewById<Button>(R.id.btnLog).setOnClickListener {
startActivity(Intent(this, LogActivity::class.java))
@@ -130,9 +135,8 @@ class MainActivity : AppCompatActivity() {
private fun syncNow() {
val cfg = Config.load(this)
- if (cfg.remote.host.isEmpty() || cfg.remote.user.isEmpty() || cfg.folders.isEmpty() ||
- !Keys.exists(this) || !Keys.pinned(this)) {
- Toast.makeText(this, "Set the remote, key, host pin, and at least one folder first.", Toast.LENGTH_LONG)
+ if (!cfg.syncReady(Keys.exists(this))) {
+ Toast.makeText(this, "Set up a key, a pinned remote, and at least one folder first.", Toast.LENGTH_LONG)
.show()
return
}
@@ -202,13 +206,6 @@ class MainActivity : AppCompatActivity() {
lastSync.text = "Last sync: ${LastSync.get(this)}"
- val r = cfg.remote
- remoteValue.text = if (r.host.isEmpty()) {
- "Not set"
- } else {
- "${r.user}@${r.host}:${r.port}, ${if (Keys.pinned(this)) "pinned" else "not pinned"}"
- }
-
keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create"
val s = cfg.schedule
@@ -225,30 +222,65 @@ class MainActivity : AppCompatActivity() {
setPerm(dotNotif, valNotif, notif(), "Granted", "Tap to grant")
setPerm(dotBattery, valBattery, batteryUnrestricted(), "Unrestricted", "Optimized, tap to fix")
+ remotes.removeAllViews()
+ if (cfg.remotes.isEmpty()) {
+ remotes.addView(emptyHint("No remotes yet. Tap Add remote below."))
+ } else {
+ cfg.remotes.forEachIndexed { i, r ->
+ addRow(
+ remotes, R.drawable.ic_remote, r.name,
+ "${r.user}@${r.host}:${r.port}",
+ if (r.pinned()) "pinned" else "no pin",
+ ) {
+ startActivity(
+ Intent(this, RemoteActivity::class.java).putExtra("index", i)
+ )
+ }
+ }
+ }
+
folders.removeAllViews()
if (cfg.folders.isEmpty()) {
- folders.addView(TextView(this).apply {
- text = "No folders yet. Tap Add folder below."
- setTextAppearance(R.style.TextAppearance_Rsend_Caption)
- setPadding(0, 0, 0, resources.getDimensionPixelSize(R.dimen.space_m))
- })
+ folders.addView(emptyHint("No folders yet. Tap Add folder below."))
} else {
cfg.folders.forEachIndexed { i, f ->
- val row = layoutInflater.inflate(R.layout.item_folder, folders, false)
- row.findViewById<TextView>(R.id.folderName).text = f.name.ifEmpty { f.local }
- row.findViewById<TextView>(R.id.folderPath).text = "${f.local} -> ${f.remote}"
- row.findViewById<TextView>(R.id.folderChip).text = if (f.delete) "mirror" else "add"
- row.setOnClickListener {
+ addRow(
+ folders, R.drawable.ic_folder, f.name.ifEmpty { f.local },
+ "${f.local} -> ${f.remoteName}:${f.remotePath}",
+ if (f.delete) "mirror" else "add",
+ ) {
startActivity(
- Intent(this@MainActivity, FolderEditActivity::class.java)
- .putExtra("index", i)
+ Intent(this, FolderEditActivity::class.java).putExtra("index", i)
)
}
- folders.addView(row)
}
}
}
+ // addRow inflates one tappable list row: icon, title, subtitle, chip.
+ private fun addRow(
+ list: LinearLayout,
+ icon: Int,
+ title: String,
+ sub: String,
+ chip: String,
+ onClick: () -> Unit,
+ ) {
+ val row = layoutInflater.inflate(R.layout.item_row, list, false)
+ row.findViewById<ImageView>(R.id.icon).setImageResource(icon)
+ row.findViewById<TextView>(R.id.rowTitle).text = title
+ row.findViewById<TextView>(R.id.rowSub).text = sub
+ row.findViewById<TextView>(R.id.rowChip).text = chip
+ row.setOnClickListener { onClick() }
+ list.addView(row)
+ }
+
+ private fun emptyHint(msg: String): TextView = TextView(this).apply {
+ text = msg
+ setTextAppearance(R.style.TextAppearance_Rsend_Caption)
+ setPadding(0, 0, 0, resources.getDimensionPixelSize(R.dimen.space_m))
+ }
+
private fun setPerm(dot: View, value: TextView, ok: Boolean, okText: String, badText: String) {
dot.setBackgroundResource(if (ok) R.drawable.dot_on else R.drawable.dot_off)
value.text = if (ok) okText else badText
diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
index 5ff53f9..0d77e6c 100644
--- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
@@ -1,6 +1,7 @@
package invalid.lena.rsend
import android.os.Bundle
+import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
@@ -8,10 +9,13 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import kotlin.concurrent.thread
-// RemoteActivity edits the SSH target and runs Test connection, which scans the
-// host key and pins it on the user's confirmation.
+// RemoteActivity adds or edits one named remote and runs Test connection,
+// which scans the host key and stores the pin on this remote when the user
+// accepts it. index < 0 means a new remote.
class RemoteActivity : AppCompatActivity() {
+ private var index: Int = -1
+ private lateinit var name: EditText
private lateinit var host: EditText
private lateinit var port: EditText
private lateinit var user: EditText
@@ -23,44 +27,104 @@ class RemoteActivity : AppCompatActivity() {
setSupportActionBar(findViewById(R.id.toolbar))
title = "Remote"
fitSystemBars()
+ name = findViewById(R.id.name)
host = findViewById(R.id.host)
port = findViewById(R.id.port)
user = findViewById(R.id.user)
status = findViewById(R.id.status)
+ index = intent.getIntExtra("index", -1)
val cfg = Config.load(this)
- host.setText(cfg.remote.host)
- port.setText(cfg.remote.port.toString())
- user.setText(cfg.remote.user)
+ if (index in cfg.remotes.indices) {
+ val r = cfg.remotes[index]
+ name.setText(r.name)
+ host.setText(r.host)
+ port.setText(r.port.toString())
+ user.setText(r.user)
+ } else {
+ port.setText("22")
+ }
- findViewById<Button>(R.id.save).setOnClickListener { if (save()) finish() }
+ findViewById<Button>(R.id.save).setOnClickListener { if (save() >= 0) finish() }
findViewById<Button>(R.id.test).setOnClickListener { test() }
+ val del = findViewById<Button>(R.id.removeRemote)
+ del.visibility = if (index < 0) View.GONE else View.VISIBLE
+ del.setOnClickListener { if (remove()) finish() }
}
private fun current(): Remote? {
+ val n = name.text.toString().trim()
+ if (n.isEmpty()) {
+ status.text = "Set a name; folders pick their remote by it."
+ return null
+ }
val p = port.text.toString().toIntOrNull()
if (p == null || p !in 1..65535) {
status.text = "Port must be between 1 and 65535."
return null
}
return Remote(
+ name = n,
host = host.text.toString().trim(),
port = p,
user = user.text.toString().trim(),
)
}
- private fun save(remote: Remote? = current()): Boolean {
- if (remote == null) return false
+ // save validates, keeps the pin only while host and port are unchanged,
+ // renames folder references along with the remote, and persists. Returns
+ // the saved entry's index (a new remote is appended), or -1 on a
+ // validation failure.
+ private fun save(remote: Remote? = current()): Int {
+ if (remote == null) return -1
+ val cfg = Config.load(this)
+ if (cfg.remotes.withIndex().any { (i, r) -> i != index && r.name == remote.name }) {
+ status.text = "A remote named \"${remote.name}\" already exists."
+ return -1
+ }
+ val old = cfg.remotes.getOrNull(index)
+ val hostKey =
+ if (old != null && old.host == remote.host && old.port == remote.port) old.hostKey else ""
+ val entry = remote.copy(hostKey = hostKey)
+ val remotes = cfg.remotes.toMutableList()
+ val at = if (index in remotes.indices) {
+ remotes[index] = entry
+ index
+ } else {
+ remotes.add(entry)
+ remotes.size - 1
+ }
+ var folders = cfg.folders
+ if (old != null && old.name != entry.name) {
+ folders = folders.map {
+ if (it.remoteName == old.name) it.copy(remoteName = entry.name) else it
+ }
+ }
+ Config.save(this, cfg.copy(remotes = remotes, folders = folders))
+ return at
+ }
+
+ // remove refuses while folders still reference this remote: a dangling
+ // name would need special-casing on every other screen.
+ private fun remove(): Boolean {
val cfg = Config.load(this)
- if (cfg.remote.host != remote.host || cfg.remote.port != remote.port) Keys.clearPin(this)
- Config.save(this, cfg.copy(remote = remote))
+ val r = cfg.remotes.getOrNull(index) ?: return true
+ val used = cfg.folders.count { it.remoteName == r.name }
+ if (used > 0) {
+ status.text = "Cannot remove: $used folder(s) sync to this remote."
+ return false
+ }
+ Config.save(this, cfg.copy(remotes = cfg.remotes.filterIndexed { i, _ -> i != index }))
return true
}
private fun test() {
val r = current() ?: return
- save(r)
+ val saved = save(r)
+ if (saved < 0) return
+ // Testing a new remote adopts the appended entry, so Pin and a second
+ // Save update it instead of appending again.
+ index = saved
when {
!Keys.exists(this) ->
status.text = "No key yet. Generate one on the main screen and add it to the server first."
@@ -83,7 +147,7 @@ class RemoteActivity : AppCompatActivity() {
.setTitle("Verify host key")
.setMessage("Fingerprint:\n${scan.fingerprint}\n\nPin this host?")
.setPositiveButton("Pin") { _, _ ->
- Keys.pin(this, scan.line)
+ pin(scan.line)
status.text = "Host key pinned. Connection OK."
}
.setNegativeButton("Cancel", null)
@@ -94,4 +158,14 @@ class RemoteActivity : AppCompatActivity() {
}
}
}
+
+ // pin re-loads the config before writing: the scan ran in the background
+ // and the dialog may have sat open for a while.
+ private fun pin(line: String) {
+ val cfg = Config.load(this)
+ val remotes = cfg.remotes.toMutableList()
+ if (index !in remotes.indices) return
+ remotes[index] = remotes[index].copy(hostKey = line)
+ Config.save(this, cfg.copy(remotes = remotes))
+ }
}
diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
index 2868e25..c4c0536 100644
--- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
+++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
@@ -37,7 +37,7 @@ object RsyncRunner {
for (e in f.excludes) a.add("--exclude=$e")
if (f.delete) a.add("--delete")
a.add(withSlash(f.local))
- a.add("${remote.user}@${remote.host}:${withSlash(f.remote)}")
+ a.add("${remote.user}@${remote.host}:${withSlash(f.remotePath)}")
return a
}
diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
index 8f20498..4b0e687 100644
--- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt
+++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
@@ -20,9 +20,7 @@ object Scheduler {
val s = cfg.schedule
val wm = WorkManager.getInstance(ctx)
- val ready = cfg.remote.host.isNotEmpty() && cfg.remote.user.isNotEmpty() && cfg.folders.isNotEmpty() &&
- Keys.exists(ctx) && Keys.pinned(ctx)
- if (!s.enabled || !ready) {
+ if (!s.enabled || !cfg.syncReady(Keys.exists(ctx))) {
wm.cancelUniqueWork(NAME)
return
}
diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
index d884a6b..b8c9a9d 100644
--- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
+++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
@@ -33,15 +33,17 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
withContext(Dispatchers.IO) {
val ctx = applicationContext
val cfg = Config.load(ctx)
- if (cfg.remote.host.isEmpty() || cfg.remote.user.isEmpty() || cfg.folders.isEmpty() ||
- !Keys.exists(ctx) || !Keys.pinned(ctx)) {
- SyncLog(ctx).line("sync skipped: remote, folders, key, or pinned host missing")
+ if (!cfg.syncReady(Keys.exists(ctx))) {
+ SyncLog(ctx).line("sync skipped: key, folders, or a pinned remote missing")
return@withContext Result.failure()
}
val log = SyncLog(ctx)
log.rotateIfBig()
log.line("sync started (${cfg.folders.size} folders)")
+ // known_hosts is derived from the config's pins; regenerate it here,
+ // under the sync mutex, before the first rsync reads it.
+ Keys.writeKnownHosts(ctx, cfg.remotes.map { it.hostKey }.filter { it.isNotEmpty() })
// A foreground service lets long syncs survive, but Android 12+ forbids
// starting one when the periodic job fires in the background. Try it, and
// fall back to a plain background job when it is not allowed.
@@ -59,13 +61,24 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
val label = f.name.ifEmpty { f.local }
// Publish progress the dashboard observes (current folder, i of n).
setProgress(workDataOf(KEY_FOLDER to label, KEY_INDEX to i + 1, KEY_TOTAL to total))
- log.line("=== $label ===")
+ log.line("=== $label -> ${f.remoteName} ===")
+ val remote = cfg.remote(f.remoteName)
+ // A broken folder fails alone; the others still sync.
// Guard against a hand-edited config: an empty path would rsync /.
- val code = if (f.local.isEmpty() || f.remote.isEmpty()) {
+ val code = if (f.local.isEmpty() || f.remotePath.isEmpty()) {
log.line("error: local or remote path not set")
1
+ } else if (remote == null) {
+ log.line("error: remote \"${f.remoteName}\" not found")
+ 1
+ } else if (remote.host.isEmpty() || remote.user.isEmpty()) {
+ log.line("error: remote \"${f.remoteName}\" has no host or user")
+ 1
+ } else if (!remote.pinned()) {
+ log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection")
+ 1
} else try {
- RsyncRunner.runFolder(ctx, cfg.remote, f, log)
+ RsyncRunner.runFolder(ctx, remote, f, log)
} catch (e: CancellationException) {
log.line("cancelled")
throw e
diff --git a/app/src/main/res/layout/activity_folder.xml b/app/src/main/res/layout/activity_folder.xml
index 9709312..a67af55 100644
--- a/app/src/main/res/layout/activity_folder.xml
+++ b/app/src/main/res/layout/activity_folder.xml
@@ -76,10 +76,23 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_m"
android:textAppearance="@style/TextAppearance.Rsend.Caption"
+ android:text="Remote" />
+
+ <Spinner
+ android:id="@+id/remoteName"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_m"
+ android:textAppearance="@style/TextAppearance.Rsend.Caption"
android:text="Remote path" />
<EditText
- android:id="@+id/remote"
+ android:id="@+id/remotePath"
style="@style/Widget.Rsend.EditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index d58f907..20fe99b 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -95,51 +95,6 @@
android:text="Setup" />
<LinearLayout
- android:id="@+id/rowRemote"
- style="@style/Widget.Rsend.Row"
- android:layout_width="match_parent"
- android:layout_height="wrap_content">
-
- <ImageView
- android:layout_width="24dp"
- android:layout_height="24dp"
- android:layout_marginEnd="@dimen/space_m"
- android:src="@drawable/ic_remote"
- android:contentDescription="@null" />
-
- <LinearLayout
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:orientation="vertical">
-
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:textAppearance="@style/TextAppearance.Rsend.Body"
- android:text="Remote" />
-
- <TextView
- android:id="@+id/remoteValue"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:textAppearance="@style/TextAppearance.Rsend.Caption" />
- </LinearLayout>
-
- <ImageView
- android:layout_width="20dp"
- android:layout_height="20dp"
- android:src="@drawable/ic_chevron"
- android:contentDescription="@null" />
- </LinearLayout>
-
- <View
- android:layout_width="match_parent"
- android:layout_height="@dimen/stroke"
- android:layout_marginStart="40dp"
- android:background="@color/outline" />
-
- <LinearLayout
android:id="@+id/rowKey"
style="@style/Widget.Rsend.Row"
android:layout_width="match_parent"
@@ -371,6 +326,29 @@
</LinearLayout>
</LinearLayout>
+ <!-- Remotes -->
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/space_s"
+ android:layout_marginBottom="@dimen/space_s"
+ android:textAppearance="@style/TextAppearance.Rsend.SectionHeader"
+ android:text="Remotes" />
+
+ <LinearLayout
+ android:id="@+id/remotes"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ <Button
+ android:id="@+id/btnAddRemote"
+ style="@style/Widget.Rsend.Button.Secondary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="@dimen/space_m"
+ android:text="+ Add remote" />
+
<!-- Folders -->
<TextView
android:layout_width="match_parent"
@@ -386,28 +364,20 @@
android:layout_height="wrap_content"
android:orientation="vertical" />
- <LinearLayout
+ <Button
+ android:id="@+id/btnAddFolder"
+ style="@style/Widget.Rsend.Button.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal">
+ android:layout_marginBottom="@dimen/space_m"
+ android:text="+ Add folder" />
- <Button
- android:id="@+id/btnAddFolder"
- style="@style/Widget.Rsend.Button.Secondary"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:layout_marginEnd="@dimen/space_s"
- android:text="+ Add folder" />
-
- <Button
- android:id="@+id/btnLog"
- style="@style/Widget.Rsend.Button.Secondary"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text="View log" />
- </LinearLayout>
+ <Button
+ android:id="@+id/btnLog"
+ style="@style/Widget.Rsend.Button.Secondary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="View log" />
</LinearLayout>
</ScrollView>
</LinearLayout>
diff --git a/app/src/main/res/layout/activity_remote.xml b/app/src/main/res/layout/activity_remote.xml
index 2c3ba4b..ab7690c 100644
--- a/app/src/main/res/layout/activity_remote.xml
+++ b/app/src/main/res/layout/activity_remote.xml
@@ -28,6 +28,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Rsend.Caption"
+ android:text="Name" />
+
+ <EditText
+ android:id="@+id/name"
+ style="@style/Widget.Rsend.EditText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:inputType="text"
+ android:hint="nas" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_m"
+ android:textAppearance="@style/TextAppearance.Rsend.Caption"
android:text="Host" />
<EditText
@@ -95,6 +111,14 @@
android:text="Save" />
</LinearLayout>
+ <Button
+ android:id="@+id/removeRemote"
+ style="@style/Widget.Rsend.Button.Secondary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_s"
+ android:text="Remove" />
+
<TextView
android:id="@+id/status"
android:layout_width="match_parent"
diff --git a/app/src/main/res/layout/item_folder.xml b/app/src/main/res/layout/item_row.xml
index bb5a2bf..a436874 100644
--- a/app/src/main/res/layout/item_folder.xml
+++ b/app/src/main/res/layout/item_row.xml
@@ -10,6 +10,7 @@
android:foreground="?attr/selectableItemBackground">
<ImageView
+ android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="@dimen/space_m"
@@ -23,7 +24,7 @@
android:orientation="vertical">
<TextView
- android:id="@+id/folderName"
+ android:id="@+id/rowTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
@@ -31,7 +32,7 @@
android:textAppearance="@style/TextAppearance.Rsend.Title" />
<TextView
- android:id="@+id/folderPath"
+ android:id="@+id/rowSub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
@@ -40,7 +41,7 @@
</LinearLayout>
<TextView
- android:id="@+id/folderChip"
+ android:id="@+id/rowChip"
style="@style/Widget.Rsend.Chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
diff --git a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
index a63582d..3449a8c 100644
--- a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
+++ b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
@@ -2,6 +2,8 @@ package invalid.lena.rsend
import org.json.JSONObject
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
import org.junit.Test
class ConfigTest {
@@ -9,11 +11,14 @@ class ConfigTest {
@Test
fun roundTrip() {
val c = Config(
- remote = Remote("home", 2222, "backup"),
+ remotes = listOf(
+ Remote("nas", "home", 2222, "backup", "home ssh-ed25519 AAAA"),
+ Remote("reader", "kobo.lan", 22, "sync"),
+ ),
schedule = Schedule(enabled = true, intervalMinutes = 30, wifiOnly = false, requireCharging = true),
folders = listOf(
- Folder("DCIM", "/storage/emulated/0/DCIM", "/b/DCIM", false, listOf(".thumbnails/")),
- Folder("W", "/w", "/b/w", true, emptyList()),
+ Folder("DCIM", "/storage/emulated/0/DCIM", "nas", "/b/DCIM", false, listOf(".thumbnails/")),
+ Folder("Books", "/books", "reader", "/sd/books", true, emptyList()),
),
)
assertEquals(c, Config.fromJson(c.toJson()))
@@ -29,4 +34,104 @@ class ConfigTest {
val json = JSONObject("""{"schedule":{"intervalMinutes":1}}""")
assertEquals(Schedule.MIN_INTERVAL_MINUTES, Config.fromJson(json).schedule.intervalMinutes)
}
+
+ @Test
+ fun readsLegacySingleRemoteShape() {
+ val json = JSONObject(
+ """
+ {
+ "remote": {"host": "home", "port": 2222, "user": "backup"},
+ "folders": [
+ {"name": "DCIM", "local": "/d", "remote": "/b/DCIM", "delete": true, "excludes": [".x/"]}
+ ]
+ }
+ """
+ )
+ val c = Config.fromJson(json, legacyPin = "home ssh-ed25519 AAAA")
+ assertEquals(listOf(Remote("home", "home", 2222, "backup", "home ssh-ed25519 AAAA")), c.remotes)
+ assertEquals(
+ listOf(Folder("DCIM", "/d", "home", "/b/DCIM", true, listOf(".x/"))),
+ c.folders,
+ )
+ }
+
+ @Test
+ fun legacyShapeWithoutHostStaysEmpty() {
+ val json = JSONObject("""{"remote": {"host": "", "port": 22, "user": ""}}""")
+ assertEquals(emptyList<Remote>(), Config.fromJson(json, legacyPin = "unused").remotes)
+ }
+
+ // A 0.1.x config always wrote a "remote" object, even before the host was
+ // filled in. Such a config still has folders with real destination paths;
+ // migrating must keep them rather than drop them on the floor.
+ @Test
+ fun legacyShapeWithoutHostKeepsFolderPaths() {
+ val json = JSONObject(
+ """
+ {
+ "remote": {"host": "", "port": 22, "user": ""},
+ "folders": [
+ {"name": "DCIM", "local": "/d", "remote": "/b/DCIM", "delete": false, "excludes": []}
+ ]
+ }
+ """
+ )
+ val c = Config.fromJson(json)
+ assertEquals(listOf(Folder("DCIM", "/d", "", "/b/DCIM", false, emptyList())), c.folders)
+ }
+
+ // The migration must run exactly once. load() writes the converted config
+ // back, and the written shape must no longer look legacy, or a later read
+ // would migrate an already-migrated config and re-apply the legacy field
+ // mapping to fields that no longer carry those meanings.
+ @Test
+ fun migratingTwiceIsNotPossible() {
+ val legacy = JSONObject(
+ """
+ {
+ "remote": {"host": "home", "port": 2222, "user": "backup"},
+ "folders": [{"name": "DCIM", "local": "/d", "remote": "/b/DCIM", "delete": true, "excludes": []}]
+ }
+ """
+ )
+ assertTrue(Config.legacyShape(legacy))
+
+ val once = Config.fromJson(legacy, legacyPin = "home ssh-ed25519 AAAA")
+ val written = once.toJson()
+ assertFalse(Config.legacyShape(written))
+ assertEquals(once, Config.fromJson(written))
+ }
+
+ @Test
+ fun freshAndCurrentConfigsAreNotLegacy() {
+ assertFalse(Config.legacyShape(JSONObject("{}")))
+ assertFalse(Config.legacyShape(Config().toJson()))
+ assertFalse(Config.legacyShape(JSONObject("""{"remotes": [], "remote": {"host": "x"}}""")))
+ }
+
+ // An empty remotes array is still the new shape: a 0.2.x user who deleted
+ // their only remote must not be dragged back through the migration.
+ @Test
+ fun emptyRemotesArrayIsNotLegacy() {
+ val json = JSONObject(
+ """{"remotes": [], "folders": [{"name": "DCIM", "local": "/d", "remotePath": "/b", "remoteName": "gone"}]}"""
+ )
+ assertFalse(Config.legacyShape(json))
+ val c = Config.fromJson(json, legacyPin = "should be ignored")
+ assertEquals(emptyList<Remote>(), c.remotes)
+ assertEquals(listOf(Folder("DCIM", "/d", "gone", "/b", false, emptyList())), c.folders)
+ }
+
+ @Test
+ fun syncReadyNeedsKeyFolderAndPinnedRemote() {
+ val pinned = Remote("nas", "home", 22, "backup", "line")
+ val folder = Folder("d", "/d", "nas", "/b")
+ assertTrue(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = true))
+ assertFalse(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = false))
+ assertFalse(Config(remotes = listOf(pinned)).syncReady(keyExists = true))
+ assertFalse(
+ Config(remotes = listOf(pinned.copy(hostKey = "")), folders = listOf(folder))
+ .syncReady(keyExists = true)
+ )
+ }
}
diff --git a/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt b/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt
index 8edf9fa..9729f62 100644
--- a/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt
+++ b/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt
@@ -6,11 +6,11 @@ import org.junit.Test
class RsyncRunnerTest {
- private val remote = Remote("host", 22, "user")
+ private val remote = Remote("nas", "host", 22, "user")
@Test
fun basicArgs() {
- val a = RsyncRunner.args("RSH", remote, Folder(name = "n", local = "/a", remote = "/b"))
+ val a = RsyncRunner.args("RSH", remote, Folder(name = "n", local = "/a", remoteName = "nas", remotePath = "/b"))
assertEquals(
listOf(
"-rt", "--partial", "--timeout=300",
@@ -23,26 +23,26 @@ class RsyncRunnerTest {
@Test
fun mirrorAddsDelete() {
- val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remote = "/b", delete = true))
+ val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = true))
assertTrue(a.contains("--delete"))
}
@Test
fun additiveOmitsDelete() {
- val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remote = "/b", delete = false))
+ val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = false))
assertTrue(!a.contains("--delete"))
}
@Test
fun excludesBecomeFlags() {
- val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remote = "/b", excludes = listOf(".x/", ".y")))
+ val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", excludes = listOf(".x/", ".y")))
assertTrue(a.contains("--exclude=.x/"))
assertTrue(a.contains("--exclude=.y"))
}
@Test
fun trailingSlashIdempotent() {
- val a = RsyncRunner.args("RSH", remote, Folder(local = "/a/", remote = "/b/"))
+ val a = RsyncRunner.args("RSH", remote, Folder(local = "/a/", remotePath = "/b/"))
assertTrue(a.contains("/a/"))
assertTrue(a.contains("user@host:/b/"))
}