summaryrefslogtreecommitdiff
path: root/libs
diff options
context:
space:
mode:
Diffstat (limited to 'libs')
-rw-r--r--libs/io/build.gradle.kts12
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/Directory.kt20
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/File.kt12
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/Link.kt13
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableDirectory.kt28
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableFile.kt7
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableLink.kt15
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableTree.kt3
-rw-r--r--libs/io/src/main/java/org/the_jk/cleversync/io/Tree.kt7
-rw-r--r--libs/local/build.gradle.kts12
-rw-r--r--libs/local/src/main/java/org/the_jk/cleversync/io/local/PathDirectory.kt188
-rw-r--r--libs/local/src/main/java/org/the_jk/cleversync/io/local/PathFile.kt66
-rw-r--r--libs/local/src/main/java/org/the_jk/cleversync/io/local/PathLink.kt62
-rw-r--r--libs/local/src/main/java/org/the_jk/cleversync/io/local/PathTree.kt10
-rw-r--r--libs/local/src/main/java/org/the_jk/cleversync/io/local/PathWatcher.kt82
-rw-r--r--libs/local/src/main/java/org/the_jk/cleversync/local/LocalTreeFactory.kt16
-rw-r--r--libs/local/src/main/res/values/strings.xml3
-rw-r--r--libs/local/src/test/java/org/the_jk/cleversync/local/LocalTreeTest.kt327
-rw-r--r--libs/utils/build.gradle.kts12
-rw-r--r--libs/utils/src/main/java/org/the_jk/cleversync/LiveDataUtils.kt14
-rw-r--r--libs/utils/src/main/java/org/the_jk/cleversync/StringUtils.kt32
-rw-r--r--libs/utils/src/test/java/org/the_jk/cleversync/StringUtilsTest.kt40
22 files changed, 981 insertions, 0 deletions
diff --git a/libs/io/build.gradle.kts b/libs/io/build.gradle.kts
new file mode 100644
index 0000000..c091a90
--- /dev/null
+++ b/libs/io/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "org.the_jk.cleversync.io"
+}
+
+dependencies {
+ api(libs.androidx.livedata)
+ api(libs.androidx.livedata.ktx)
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Directory.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Directory.kt
new file mode 100644
index 0000000..e653059
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Directory.kt
@@ -0,0 +1,20 @@
+package org.the_jk.cleversync.io
+
+import androidx.lifecycle.LiveData
+
+interface Directory {
+ val name: String
+
+ fun openDir(name: String): Directory?
+ fun openFile(name: String): File?
+ fun openLink(name: String): Link?
+
+ fun list(): Content
+ fun liveList(): LiveData<Content>
+
+ data class Content(
+ val directories: List<Directory>,
+ val files: List<File>,
+ val links: List<Link>,
+ )
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/File.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/File.kt
new file mode 100644
index 0000000..17f142a
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/File.kt
@@ -0,0 +1,12 @@
+package org.the_jk.cleversync.io
+
+import java.io.InputStream
+import java.time.Instant
+
+interface File {
+ val name: String
+ val size: ULong
+ val lastModified: Instant
+
+ fun read(): InputStream
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Link.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Link.kt
new file mode 100644
index 0000000..c05f29e
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Link.kt
@@ -0,0 +1,13 @@
+package org.the_jk.cleversync.io
+
+interface Link {
+ val name: String
+
+ fun resolve(): LinkTarget
+
+ sealed class LinkTarget
+
+ data class DirectoryTarget(val directory: Directory): LinkTarget()
+ data class FileTarget(val file: File): LinkTarget()
+ data object NoTarget: LinkTarget()
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableDirectory.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableDirectory.kt
new file mode 100644
index 0000000..8bddc2c
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableDirectory.kt
@@ -0,0 +1,28 @@
+package org.the_jk.cleversync.io
+
+import androidx.lifecycle.LiveData
+
+interface ModifiableDirectory : Directory {
+ fun modifiableOpenDir(name: String): ModifiableDirectory?
+ fun modifiableOpenFile(name: String): ModifiableFile?
+ fun modifiableOpenLink(name: String): ModifiableLink?
+
+ fun modifiableList(): Content
+ fun modifiableLiveList(): LiveData<Content>
+
+ fun createDirectory(name: String): ModifiableDirectory
+ fun createFile(name: String): ModifiableFile
+ fun createLink(name: String, target: Directory): ModifiableLink
+ fun createLink(name: String, target: File): ModifiableLink
+ fun createLink(name: String, target: String): ModifiableLink
+
+ fun removeDirectory(name: String): Boolean
+ fun removeFile(name: String): Boolean
+ fun removeLink(name: String): Boolean
+
+ data class Content(
+ val directories: List<ModifiableDirectory>,
+ val files: List<ModifiableFile>,
+ val links: List<ModifiableLink>,
+ )
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableFile.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableFile.kt
new file mode 100644
index 0000000..8675dae
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableFile.kt
@@ -0,0 +1,7 @@
+package org.the_jk.cleversync.io
+
+import java.io.OutputStream
+
+interface ModifiableFile : File {
+ fun write(): OutputStream
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableLink.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableLink.kt
new file mode 100644
index 0000000..7dd565b
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableLink.kt
@@ -0,0 +1,15 @@
+package org.the_jk.cleversync.io
+
+interface ModifiableLink : Link {
+ fun modifiableResolve(): ModifiableLinkTarget
+
+ fun target(directory: Directory)
+ fun target(file: File)
+ fun target(name: String)
+
+ sealed class ModifiableLinkTarget
+
+ data class ModifiableDirectoryTarget(val directory: ModifiableDirectory): ModifiableLinkTarget()
+ data class ModifiableFileTarget(val file: ModifiableFile): ModifiableLinkTarget()
+ data object NoTarget: ModifiableLinkTarget()
+}
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableTree.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableTree.kt
new file mode 100644
index 0000000..383360d
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/ModifiableTree.kt
@@ -0,0 +1,3 @@
+package org.the_jk.cleversync.io
+
+interface ModifiableTree : Tree, ModifiableDirectory
diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Tree.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Tree.kt
new file mode 100644
index 0000000..b6f2d54
--- /dev/null
+++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Tree.kt
@@ -0,0 +1,7 @@
+package org.the_jk.cleversync.io
+
+import android.content.res.Resources
+
+interface Tree : Directory {
+ fun description(resources: Resources): CharSequence
+}
diff --git a/libs/local/build.gradle.kts b/libs/local/build.gradle.kts
new file mode 100644
index 0000000..46d8128
--- /dev/null
+++ b/libs/local/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "org.the_jk.cleversync.local"
+}
+
+dependencies {
+ implementation(project(":libs:io"))
+ testImplementation(project(":libs:utils"))
+}
diff --git a/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathDirectory.kt b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathDirectory.kt
new file mode 100644
index 0000000..9899f02
--- /dev/null
+++ b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathDirectory.kt
@@ -0,0 +1,188 @@
+package org.the_jk.cleversync.io.local
+
+import androidx.annotation.AnyThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.map
+import org.the_jk.cleversync.io.Directory
+import org.the_jk.cleversync.io.File
+import org.the_jk.cleversync.io.ModifiableDirectory
+import org.the_jk.cleversync.io.ModifiableFile
+import org.the_jk.cleversync.io.ModifiableLink
+import java.nio.file.LinkOption
+import java.nio.file.NoSuchFileException
+import java.nio.file.Path
+import kotlin.io.path.ExperimentalPathApi
+import kotlin.io.path.createDirectory
+import kotlin.io.path.createSymbolicLinkPointingTo
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.deleteRecursively
+import kotlin.io.path.isDirectory
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.isSymbolicLink
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.name
+import kotlin.io.path.readSymbolicLink
+
+@OptIn(ExperimentalPathApi::class)
+internal open class PathDirectory(
+ internal val path: Path,
+ private val pathWatcher: PathWatcher,
+) : ModifiableDirectory {
+ private val watcher: DirectoryWatcher by lazy {
+ DirectoryWatcher()
+ }
+
+ private val modifiableLiveContent: LiveData<ModifiableDirectory.Content> by lazy {
+ watcher.content
+ }
+
+ private val liveContent: LiveData<Directory.Content> by lazy {
+ modifiableLiveContent.map {
+ Directory.Content(
+ it.directories,
+ it.files,
+ it.links,
+ )
+ }
+ }
+
+ override fun modifiableOpenDir(name: String): ModifiableDirectory? {
+ val path = path.resolve(name)
+ if (path.isDirectory(LinkOption.NOFOLLOW_LINKS)) return PathDirectory(path, pathWatcher)
+ if (path.isSymbolicLink()) {
+ val target = path.readSymbolicLink()
+ if (target.isDirectory()) return PathDirectory(target.toRealPath(), pathWatcher)
+ }
+ return null
+ }
+
+ override fun modifiableOpenFile(name: String): ModifiableFile? {
+ val path = path.resolve(name)
+ if (path.isRegularFile(LinkOption.NOFOLLOW_LINKS)) return PathFile(path)
+ if (path.isSymbolicLink()) {
+ val target = path.readSymbolicLink()
+ if (target.isRegularFile()) return PathFile(target.toRealPath())
+ }
+ return null
+ }
+
+ override fun modifiableOpenLink(name: String): ModifiableLink? {
+ val path = path.resolve(name)
+ return if (path.isSymbolicLink()) PathLink(path, pathWatcher) else null
+ }
+
+ override fun modifiableList() = makeContent(path.listDirectoryEntries())
+ override fun modifiableLiveList() = modifiableLiveContent
+
+ override fun createDirectory(name: String): ModifiableDirectory {
+ val path = path.resolve(name)
+ return PathDirectory(path.createDirectory(), pathWatcher)
+ }
+
+ override fun createFile(name: String): ModifiableFile {
+ val path = path.resolve(name)
+ return PathFile(path)
+ }
+
+ override fun createLink(name: String, target: Directory): ModifiableLink {
+ val path = path.resolve(name)
+ return PathLink(path.createSymbolicLinkPointingTo((target as PathDirectory).path), pathWatcher)
+ }
+
+ override fun createLink(name: String, target: File): ModifiableLink {
+ val path = path.resolve(name)
+ return PathLink(path.createSymbolicLinkPointingTo((target as PathFile).path), pathWatcher)
+ }
+
+ override fun createLink(name: String, target: String): ModifiableLink {
+ val targetPath = path.resolve(target)
+ val path = path.resolve(name)
+ return PathLink(path.createSymbolicLinkPointingTo(targetPath), pathWatcher)
+ }
+
+ override fun removeDirectory(name: String): Boolean {
+ val path = path.resolve(name)
+ return if (path.isDirectory(LinkOption.NOFOLLOW_LINKS)) {
+ path.deleteRecursively()
+ true
+ } else false
+ }
+
+ override fun removeFile(name: String): Boolean {
+ val path = path.resolve(name)
+ return path.isRegularFile(LinkOption.NOFOLLOW_LINKS) && path.deleteIfExists()
+ }
+
+ override fun removeLink(name: String): Boolean {
+ val path = path.resolve(name)
+ return path.isSymbolicLink() && path.deleteIfExists()
+ }
+
+ override val name: String
+ get() = path.name
+
+ override fun list(): Directory.Content {
+ val modifiable = modifiableList()
+ return Directory.Content(modifiable.directories, modifiable.files, modifiable.links)
+ }
+
+ override fun liveList() = liveContent
+
+ override fun openDir(name: String) = modifiableOpenDir(name)
+ override fun openFile(name: String) = modifiableOpenFile(name)
+ override fun openLink(name: String) = modifiableOpenLink(name)
+
+ override fun equals(other: Any?) = other is PathDirectory && other.path == path
+ override fun hashCode() = path.hashCode()
+ override fun toString() = path.toString()
+
+ private inner class DirectoryWatcher : PathWatcher.Delegate {
+ val content: LiveData<ModifiableDirectory.Content>
+ get() = _content
+
+ @AnyThread
+ override fun update(added: List<Path>, removed: List<Path>) {
+ try {
+ _content.postValue(makeContent(path.listDirectoryEntries()))
+ } catch (ignored: NoSuchFileException) {
+ }
+ }
+
+ private val _content = object : MutableLiveData<ModifiableDirectory.Content>() {
+ override fun onActive() {
+ setup()
+ }
+
+ override fun onInactive() {
+ clear()
+ }
+ }
+
+ private fun setup() {
+ val entries = path.listDirectoryEntries()
+ pathWatcher.add(path, this)
+ _content.value = makeContent(entries)
+ }
+
+ private fun clear() {
+ pathWatcher.remove(path)
+ }
+ }
+
+ private fun makeContent(entries: List<Path>): ModifiableDirectory.Content {
+ val directories = mutableListOf<ModifiableDirectory>()
+ val files = mutableListOf<ModifiableFile>()
+ val links = mutableListOf<ModifiableLink>()
+ entries.forEach {
+ if (it.isDirectory(LinkOption.NOFOLLOW_LINKS)) {
+ directories.add(PathDirectory(it, pathWatcher))
+ } else if (it.isRegularFile(LinkOption.NOFOLLOW_LINKS)) {
+ files.add(PathFile(it))
+ } else if (it.isSymbolicLink()) {
+ links.add(PathLink(it, pathWatcher))
+ }
+ }
+ return ModifiableDirectory.Content(directories, files, links)
+ }
+}
diff --git a/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathFile.kt b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathFile.kt
new file mode 100644
index 0000000..6aeb895
--- /dev/null
+++ b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathFile.kt
@@ -0,0 +1,66 @@
+package org.the_jk.cleversync.io.local
+
+import org.the_jk.cleversync.io.ModifiableFile
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import java.nio.file.StandardOpenOption
+import java.time.Instant
+import kotlin.io.path.exists
+import kotlin.io.path.fileSize
+import kotlin.io.path.getLastModifiedTime
+import kotlin.io.path.inputStream
+import kotlin.io.path.name
+import kotlin.io.path.outputStream
+
+internal class PathFile(internal val path: Path) : ModifiableFile {
+ override fun write(): OutputStream {
+ // If file doesn't exist, write to it directly.
+ if (!path.exists(LinkOption.NOFOLLOW_LINKS))
+ return path.outputStream(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
+
+ // Otherwise, write to temp file, only overwriting when done.
+ val tmp = path.parent.resolve(".#" + path.name)
+ val os = tmp.outputStream(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
+ return object : OutputStream() {
+ override fun write(value: Int) {
+ os.write(value)
+ }
+
+ override fun write(b: ByteArray) {
+ os.write(b)
+ }
+
+ override fun write(b: ByteArray, off: Int, len: Int) {
+ os.write(b, off, len)
+ }
+
+ override fun flush() {
+ os.flush()
+ }
+
+ override fun close() {
+ os.close()
+ Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE)
+ }
+ }
+ }
+
+ override val name: String
+ get() = path.name
+ override val size: ULong
+ get() = path.fileSize().toULong()
+ override val lastModified: Instant
+ get() = path.getLastModifiedTime().toInstant()
+
+ override fun read(): InputStream {
+ return path.inputStream(StandardOpenOption.READ)
+ }
+
+ override fun equals(other: Any?) = other is PathFile && other.path == path
+ override fun hashCode() = path.hashCode()
+ override fun toString() = path.toString()
+}
diff --git a/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathLink.kt b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathLink.kt
new file mode 100644
index 0000000..97cd117
--- /dev/null
+++ b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathLink.kt
@@ -0,0 +1,62 @@
+package org.the_jk.cleversync.io.local
+
+import org.the_jk.cleversync.io.Directory
+import org.the_jk.cleversync.io.File
+import org.the_jk.cleversync.io.Link
+import org.the_jk.cleversync.io.ModifiableLink
+import java.nio.file.Path
+import kotlin.io.path.createSymbolicLinkPointingTo
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.name
+import kotlin.io.path.readSymbolicLink
+
+internal class PathLink(
+ private val path: Path,
+ private val pathWatcher: PathWatcher,
+) : ModifiableLink {
+ override fun modifiableResolve(): ModifiableLink.ModifiableLinkTarget {
+ val target = path.readSymbolicLink()
+ return if (target.isDirectory()) {
+ ModifiableLink.ModifiableDirectoryTarget(PathDirectory(target.toRealPath(), pathWatcher))
+ } else if (target.isRegularFile()) {
+ ModifiableLink.ModifiableFileTarget(PathFile(target.toRealPath()))
+ } else {
+ ModifiableLink.NoTarget
+ }
+ }
+
+ override fun target(directory: Directory) {
+ path.deleteIfExists()
+ path.createSymbolicLinkPointingTo((directory as PathDirectory).path)
+ }
+
+ override fun target(file: File) {
+ path.deleteIfExists()
+ path.createSymbolicLinkPointingTo((file as PathFile).path)
+ }
+
+ override fun target(name: String) {
+ path.deleteIfExists()
+ path.createSymbolicLinkPointingTo(path.parent.resolve(name))
+ }
+
+ override val name: String
+ get() = path.name
+
+ override fun equals(other: Any?) = other is PathLink && other.path == path
+ override fun hashCode() = path.hashCode()
+ override fun toString() = path.toString()
+
+ override fun resolve(): Link.LinkTarget {
+ val target = path.readSymbolicLink()
+ return if (target.isDirectory()) {
+ Link.DirectoryTarget(PathDirectory(target.toRealPath(), pathWatcher))
+ } else if (target.isRegularFile()) {
+ Link.FileTarget(PathFile(target.toRealPath()))
+ } else {
+ Link.NoTarget
+ }
+ }
+}
diff --git a/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathTree.kt b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathTree.kt
new file mode 100644
index 0000000..23442a1
--- /dev/null
+++ b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathTree.kt
@@ -0,0 +1,10 @@
+package org.the_jk.cleversync.io.local
+
+import android.content.res.Resources
+import org.the_jk.cleversync.io.ModifiableTree
+import org.the_jk.cleversync.local.R
+import java.nio.file.Path
+
+internal class PathTree(root: Path) : PathDirectory(root, PathWatcher()), ModifiableTree {
+ override fun description(resources: Resources) = resources.getString(R.string.local_directory)
+}
diff --git a/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathWatcher.kt b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathWatcher.kt
new file mode 100644
index 0000000..0fa9f03
--- /dev/null
+++ b/libs/local/src/main/java/org/the_jk/cleversync/io/local/PathWatcher.kt
@@ -0,0 +1,82 @@
+package org.the_jk.cleversync.io.local
+
+import androidx.annotation.GuardedBy
+import androidx.annotation.WorkerThread
+import java.nio.file.ClosedWatchServiceException
+import java.nio.file.FileSystems
+import java.nio.file.Path
+import java.nio.file.StandardWatchEventKinds
+import java.nio.file.WatchKey
+import java.nio.file.WatchService
+import java.util.concurrent.Executors
+
+internal class PathWatcher {
+ private val executor = Executors.newSingleThreadExecutor()
+ private var service: WatchService? = null
+ private val keys = mutableMapOf<Path, WatchKey>()
+ private val lock = Object()
+ @GuardedBy("lock")
+ private val delegates = mutableMapOf<WatchKey, Delegate>()
+
+ fun add(path: Path, delegate: Delegate) {
+ if (keys.isEmpty()) {
+ val service = FileSystems.getDefault().newWatchService()
+ executor.execute{ runner(service) }
+ this.service = service
+ }
+ val key = path.register(service!!, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE)
+ delegates[key] = delegate
+ keys[path] = key
+ }
+
+ fun remove(path: Path) {
+ val key = keys.remove(path) ?: return
+ key.cancel()
+ synchronized(lock) {
+ delegates.remove(key)
+ }
+ if (keys.isEmpty()) {
+ service?.close()
+ service = null
+ }
+ }
+
+ interface Delegate {
+ fun update(added: List<Path>, removed: List<Path>)
+ }
+
+ @WorkerThread
+ private fun runner(service: WatchService) {
+ while (true) {
+ val key: WatchKey
+ try {
+ key = service.take()
+ } catch (ignored: InterruptedException) {
+ return
+ } catch (ignored: ClosedWatchServiceException) {
+ return
+ }
+
+ val added = mutableListOf<Path>()
+ val removed = mutableListOf<Path>()
+ var overflow = false
+
+ for (event in key.pollEvents()) {
+ when (event.kind()) {
+ StandardWatchEventKinds.OVERFLOW -> overflow = true
+ StandardWatchEventKinds.ENTRY_CREATE -> added.add(event.context() as Path)
+ StandardWatchEventKinds.ENTRY_DELETE -> removed.add(event.context() as Path)
+ }
+ }
+
+ if (overflow || added.isNotEmpty() || removed.isNotEmpty()) {
+ val delegate = synchronized(lock) {
+ delegates[key]
+ }
+ delegate?.update(added, removed)
+ }
+
+ key.reset()
+ }
+ }
+}
diff --git a/libs/local/src/main/java/org/the_jk/cleversync/local/LocalTreeFactory.kt b/libs/local/src/main/java/org/the_jk/cleversync/local/LocalTreeFactory.kt
new file mode 100644
index 0000000..3990416
--- /dev/null
+++ b/libs/local/src/main/java/org/the_jk/cleversync/local/LocalTreeFactory.kt
@@ -0,0 +1,16 @@
+package org.the_jk.cleversync.local
+
+import org.the_jk.cleversync.io.ModifiableTree
+import org.the_jk.cleversync.io.Tree
+import org.the_jk.cleversync.io.local.PathTree
+import java.nio.file.Path
+
+object LocalTreeFactory {
+ fun tree(path: Path): Tree {
+ return PathTree(path)
+ }
+
+ fun modifiableTree(path: Path): ModifiableTree {
+ return PathTree(path)
+ }
+}
diff --git a/libs/local/src/main/res/values/strings.xml b/libs/local/src/main/res/values/strings.xml
new file mode 100644
index 0000000..9fef6ef
--- /dev/null
+++ b/libs/local/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+<resources>
+ <string name="local_directory">Local directory</string>
+</resources>
diff --git a/libs/local/src/test/java/org/the_jk/cleversync/local/LocalTreeTest.kt b/libs/local/src/test/java/org/the_jk/cleversync/local/LocalTreeTest.kt
new file mode 100644
index 0000000..21002e3
--- /dev/null
+++ b/libs/local/src/test/java/org/the_jk/cleversync/local/LocalTreeTest.kt
@@ -0,0 +1,327 @@
+package org.the_jk.cleversync.local
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLooper
+import org.the_jk.cleversync.io.Directory
+import org.the_jk.cleversync.io.Link
+import org.the_jk.cleversync.io.ModifiableLink
+import org.the_jk.cleversync.io.ModifiableTree
+import org.the_jk.cleversync.safeValue
+
+@Config(manifest=Config.NONE)
+@RunWith(RobolectricTestRunner::class)
+class LocalTreeTest {
+ @get:Rule
+ val folder = TemporaryFolder()
+
+ private lateinit var tree: ModifiableTree
+
+ @Before
+ fun setUp() {
+ tree = LocalTreeFactory.modifiableTree(folder.root.toPath())
+ }
+
+ @Test
+ fun empty() {
+ val content = tree.list()
+ assertThat(content.directories).isEmpty()
+ assertThat(content.files).isEmpty()
+ assertThat(content.links).isEmpty()
+ }
+
+ @Test
+ fun emptyLive() {
+ val content = tree.liveList().safeValue()
+ assertThat(content?.directories).isEmpty()
+ assertThat(content?.files).isEmpty()
+ assertThat(content?.links).isEmpty()
+ }
+
+ @Test
+ fun createDirectory() {
+ val foo = tree.createDirectory("foo")
+ assertThat(foo.name).isEqualTo("foo")
+ val fooContent = foo.list()
+ assertThat(fooContent.directories).isEmpty()
+ assertThat(fooContent.files).isEmpty()
+ assertThat(fooContent.links).isEmpty()
+ val content = tree.list()
+ assertThat(content.directories).contains(foo)
+ assertThat(content.files).isEmpty()
+ assertThat(content.links).isEmpty()
+ }
+
+ @Test
+ fun observeCreateDirectory() {
+ val content = tree.liveList()
+ var dir: Directory? = null
+ content.observeForever {
+ if (it.directories.size == 1) dir = it.directories[0]
+ }
+ tree.createDirectory("foo")
+ while (dir == null) {
+ ShadowLooper.idleMainLooper()
+ }
+ assertThat(dir?.name).isEqualTo("foo")
+ }
+
+ @Test
+ fun createFile() {
+ val foo = tree.createFile("foo")
+ // Files are not created until you write to them.
+ assertThat(tree.list().files).isEmpty()
+ foo.write().use { os ->
+ os.write(byteArrayOf(1, 2, 3, 4))
+ }
+ assertThat(tree.list().files).contains(foo)
+ assertThat(foo.size).isEqualTo(4.toULong())
+ foo.read().use {
+ assertThat(it.readBytes()).isEqualTo(byteArrayOf(1, 2, 3, 4))
+ }
+ }
+
+ @Test
+ fun overwriteFile() {
+ val foo = tree.createFile("foo")
+ foo.write().use { os ->
+ os.write(byteArrayOf(1, 2, 3, 4))
+ }
+ foo.write().use { os ->
+ os.write(127)
+ os.write(byteArrayOf(1))
+ os.write(byteArrayOf(2), 0, 0)
+ assertThat(foo.size).isEqualTo(4.toULong())
+ }
+ assertThat(foo.size).isEqualTo(2.toULong())
+ assertThat(tree.list().files).hasSize(1)
+ foo.read().use {
+ assertThat(it.readBytes()).isEqualTo(byteArrayOf(127, 1))
+ }
+ }
+
+ @Test
+ fun removeDir() {
+ tree.createDirectory("foo")
+ tree.removeDirectory("foo")
+ assertThat(tree.list().directories).isEmpty()
+ }
+
+ @Test
+ fun removeDirLive() {
+ tree.createDirectory("foo")
+ val content = tree.liveList()
+ var done = false
+ content.observeForever {
+ if (it.directories.isEmpty()) done = true
+ }
+ tree.removeDirectory("foo")
+ while (!done) {
+ ShadowLooper.idleMainLooper()
+ }
+ }
+
+ @Test
+ fun createLink() {
+ val dir = tree.createDirectory("dir")
+ val file = tree.createFile("file")
+ val link = tree.createLink("link", dir.name)
+ var target = link.resolve()
+ when (target) {
+ is Link.DirectoryTarget -> assertThat(target.directory).isEqualTo(
+ dir
+ )
+ is Link.FileTarget -> Assert.fail()
+ is Link.NoTarget -> Assert.fail()
+ }
+ assertThat(tree.openDir("link")).isEqualTo(dir)
+
+ link.target(file)
+ target = link.resolve()
+ when (target) {
+ is Link.DirectoryTarget -> Assert.fail()
+ is Link.FileTarget -> Assert.fail()
+ is Link.NoTarget -> Unit
+ }
+ file.write().use { it.write(1) }
+ target = link.resolve()
+ when (target) {
+ is Link.DirectoryTarget -> Assert.fail()
+ is Link.FileTarget -> assertThat(target.file).isEqualTo(file)
+ is Link.NoTarget -> Assert.fail()
+ }
+
+ assertThat(tree.openFile("link")).isEqualTo(file)
+ }
+
+ @Test
+ fun createLinkSubdir() {
+ val foo = tree.createDirectory("foo")
+ val bar = foo.createDirectory("bar")
+ val link1 = tree.createLink("link1", "foo/bar")
+ val link2 = tree.createLink("link2", bar)
+ assertThat(link1.resolve()).isEqualTo(link2.resolve())
+ assertThat((link1.resolve() as Link.DirectoryTarget).directory).isEqualTo(bar)
+ val link3 = foo.createLink("link3", "../link1")
+ assertThat((link3.resolve() as Link.DirectoryTarget).directory).isEqualTo(bar)
+ }
+
+ @Test
+ fun createLiveLink() {
+ val content = tree.liveList()
+ var link: Link? = null
+ content.observeForever {
+ if (it.links.size == 1) link = it.links[0]
+ }
+ val dir = tree.createDirectory("dir")
+ tree.createLink("link", "dir")
+ while (link == null) {
+ ShadowLooper.idleMainLooper()
+ }
+ assertThat((link?.resolve() as Link.DirectoryTarget).directory).isEqualTo(dir)
+ }
+
+ @Test
+ fun sameDir() {
+ val dir1 = tree.createDirectory("dir")
+ val dir2 = tree.openDir("dir")
+ assertThat(dir1).isEqualTo(dir2)
+ assertThat(dir1.hashCode()).isEqualTo(dir2.hashCode())
+ assertThat(dir1.toString()).isEqualTo(dir2.toString())
+ }
+
+ @Test
+ fun sameFile() {
+ val file1 = tree.createFile("file")
+ file1.write().use { it.write(127) }
+ val file2 = tree.openFile("file")
+ assertThat(file1).isEqualTo(file2)
+ assertThat(file1.hashCode()).isEqualTo(file2.hashCode())
+ assertThat(file1.toString()).isEqualTo(file2.toString())
+ }
+
+ @Test
+ fun sameLink() {
+ val link1 = tree.createLink("link", "foo")
+ val link2 = tree.openLink("link")
+ assertThat(link1).isEqualTo(link2)
+ assertThat(link1.hashCode()).isEqualTo(link2.hashCode())
+ assertThat(link1.toString()).isEqualTo(link2.toString())
+ }
+
+ @Test
+ fun removeDirWithContent() {
+ val foo = tree.createDirectory("foo")
+ foo.createDirectory("dir")
+ foo.createFile("file").write().use { it.write(byteArrayOf(1, 2, 3, 4)) }
+ foo.createLink("link", "file")
+ assertThat(tree.list().directories).hasSize(1)
+ assertThat(tree.removeDirectory("foo")).isTrue()
+ assertThat(tree.list().directories).isEmpty()
+ }
+
+ @Test
+ fun removeWrongType() {
+ tree.createDirectory("dir")
+ assertThat(tree.removeFile("dir")).isFalse()
+ assertThat(tree.removeLink("dir")).isFalse()
+ tree.createFile("file").write().use { it.write(byteArrayOf(1, 2, 3, 4)) }
+ assertThat(tree.removeDirectory("file")).isFalse()
+ assertThat(tree.removeLink("file")).isFalse()
+ tree.createLink("link", "doesn't exist")
+ assertThat(tree.removeDirectory("link")).isFalse()
+ assertThat(tree.removeFile("link")).isFalse()
+ val content = tree.list()
+ assertThat(content.directories).hasSize(1)
+ assertThat(content.files).hasSize(1)
+ assertThat(content.links).hasSize(1)
+ }
+
+ @Test
+ fun removeFile() {
+ tree.createFile("file").write().use { it.write(byteArrayOf(1, 2, 3, 4)) }
+ assertThat(tree.list().files).hasSize(1)
+ tree.removeFile("file")
+ assertThat(tree.list().files).isEmpty()
+ }
+
+ @Test
+ fun removeLink() {
+ val dir = tree.createDirectory("dir")
+ val file = tree.createFile("file")
+ file.write().use { it.write(127) }
+ tree.createLink("link1", dir)
+ tree.createLink("link2", file)
+ assertThat(tree.list().links).hasSize(2)
+ tree.removeLink("link1")
+ tree.removeLink("link2")
+ assertThat(tree.list().links).isEmpty()
+ }
+
+ @Test
+ fun changeLink() {
+ val dir = tree.createDirectory("dir")
+ val file = tree.createFile("file")
+ file.write().use { it.write(127) }
+ val link = tree.createLink("link", "doesn't exist")
+ assertThat(link.resolve() is Link.NoTarget).isTrue()
+ link.target(file)
+ assertThat((link.resolve() as Link.FileTarget).file).isEqualTo(file)
+ link.target(dir)
+ assertThat((link.resolve() as Link.DirectoryTarget).directory).isEqualTo(dir)
+ link.target("bad")
+ assertThat(link.resolve() is Link.NoTarget).isTrue()
+ }
+
+ @Test
+ fun changeModifiableLink() {
+ val dir = tree.createDirectory("dir")
+ val file = tree.createFile("file")
+ file.write().use { it.write(127) }
+ val link = tree.createLink("link", "doesn't exist")
+ assertThat(link.modifiableResolve() is ModifiableLink.NoTarget).isTrue()
+ link.target(file)
+ assertThat((link.modifiableResolve() as ModifiableLink.ModifiableFileTarget).file).isEqualTo(file)
+ link.target(dir)
+ assertThat((link.modifiableResolve() as ModifiableLink.ModifiableDirectoryTarget).directory).isEqualTo(dir)
+ link.target("bad")
+ assertThat(link.modifiableResolve() is ModifiableLink.NoTarget).isTrue()
+ }
+
+ @Test
+ fun recursiveLink() {
+ val link = tree.createLink("link", "link")
+ assertThat(link.resolve() is Link.NoTarget).isTrue()
+ }
+
+ @Test
+ fun names() {
+ assertThat(tree.createDirectory("dir").name).isEqualTo("dir")
+ assertThat(tree.createFile("file").name).isEqualTo("file")
+ assertThat(tree.createLink("link", "file").name).isEqualTo("link")
+ }
+
+ @Test
+ fun openNonExistent() {
+ assertThat(tree.openDir("dir")).isNull()
+ assertThat(tree.openFile("file")).isNull()
+ assertThat(tree.openLink("link")).isNull()
+ }
+
+ @Test
+ fun lastModified() {
+ val file = tree.createFile("foo")
+ file.write().use { it.write(1) }
+ val old = file.lastModified
+ file.write().use { it.write(2); it.flush() }
+ val new = file.lastModified
+ assertThat(old.isBefore(new) || old == new).isTrue()
+ }
+}
diff --git a/libs/utils/build.gradle.kts b/libs/utils/build.gradle.kts
new file mode 100644
index 0000000..b4f0ae5
--- /dev/null
+++ b/libs/utils/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "org.the_jk.cleversync.utils"
+}
+
+dependencies {
+ api(libs.androidx.livedata)
+ api(libs.androidx.livedata.ktx)
+}
diff --git a/libs/utils/src/main/java/org/the_jk/cleversync/LiveDataUtils.kt b/libs/utils/src/main/java/org/the_jk/cleversync/LiveDataUtils.kt
new file mode 100644
index 0000000..7f6ab1f
--- /dev/null
+++ b/libs/utils/src/main/java/org/the_jk/cleversync/LiveDataUtils.kt
@@ -0,0 +1,14 @@
+package org.the_jk.cleversync
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+
+fun <T> LiveData<T>.safeValue(): T? {
+ if (this.hasActiveObservers())
+ return value
+ var ret: T? = null
+ val observer = Observer<T> { value -> ret = value }
+ this.observeForever(observer)
+ this.removeObserver(observer)
+ return ret
+}
diff --git a/libs/utils/src/main/java/org/the_jk/cleversync/StringUtils.kt b/libs/utils/src/main/java/org/the_jk/cleversync/StringUtils.kt
new file mode 100644
index 0000000..6adea24
--- /dev/null
+++ b/libs/utils/src/main/java/org/the_jk/cleversync/StringUtils.kt
@@ -0,0 +1,32 @@
+package org.the_jk.cleversync
+
+object StringUtils {
+ fun split(input: String, delimiter: Char, keepEmpty: Boolean = true, limit: Int = 0): List<String> {
+ return buildList {
+ var offset = 0
+ var count = 0
+ while (true) {
+ val next = input.indexOf(delimiter, offset)
+ if (next == -1) {
+ if (keepEmpty || offset < input.length) {
+ if (limit > 0 && count == limit) {
+ add("${removeLast()}${delimiter}${input.substring(offset)}")
+ break
+ }
+ add(input.substring(offset))
+ }
+ break
+ }
+ if (keepEmpty || offset < next) {
+ if (limit > 0 && count == limit) {
+ add("${removeLast()}${delimiter}${input.substring(offset)}")
+ break
+ }
+ add(input.substring(offset, next))
+ count++
+ }
+ offset = next + 1
+ }
+ }
+ }
+}
diff --git a/libs/utils/src/test/java/org/the_jk/cleversync/StringUtilsTest.kt b/libs/utils/src/test/java/org/the_jk/cleversync/StringUtilsTest.kt
new file mode 100644
index 0000000..6a36156
--- /dev/null
+++ b/libs/utils/src/test/java/org/the_jk/cleversync/StringUtilsTest.kt
@@ -0,0 +1,40 @@
+package org.the_jk.cleversync
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class StringUtilsTest {
+ @Test
+ fun splitEmpty() {
+ assertThat(StringUtils.split("", '.', keepEmpty = true)).containsExactly("")
+ assertThat(StringUtils.split("", '.', keepEmpty = false)).isEmpty()
+ }
+
+ @Test
+ fun splitSanity() {
+ assertThat(StringUtils.split("a.bb.a", '.')).containsExactly("a", "bb", "a").inOrder()
+ assertThat(StringUtils.split(".a.bb.a", '.', keepEmpty = true)).containsExactly("", "a", "bb", "a").inOrder()
+ assertThat(StringUtils.split(".a.bb.a", '.', keepEmpty = false)).containsExactly("a", "bb", "a").inOrder()
+ assertThat(StringUtils.split(".a.bb.a.", '.', keepEmpty = true))
+ .containsExactly("", "a", "bb", "a", "").inOrder()
+ assertThat(StringUtils.split(".a.bb.a.", '.', keepEmpty = false)).containsExactly("a", "bb", "a").inOrder()
+ }
+
+ @Test
+ fun splitDouble() {
+ assertThat(StringUtils.split("foo..bar", '.', keepEmpty = true)).containsExactly("foo", "", "bar").inOrder()
+ assertThat(StringUtils.split("foo..bar", '.', keepEmpty = false)).containsExactly("foo", "bar").inOrder()
+ }
+
+ @Test
+ fun splitLimit() {
+ assertThat(StringUtils.split("a.bb.a", '.', limit = 1)).containsExactly("a.bb.a")
+ assertThat(StringUtils.split("a.bb.a", '.', limit = 2)).containsExactly("a", "bb.a").inOrder()
+ assertThat(StringUtils.split("a.bb.a", '.', limit = 3)).containsExactly("a", "bb", "a").inOrder()
+ assertThat(StringUtils.split("a.bb.a.", '.', limit = 3, keepEmpty = true))
+ .containsExactly("a", "bb", "a.").inOrder()
+ assertThat(StringUtils.split("a.bb.a.", '.', limit = 3, keepEmpty = false))
+ .containsExactly("a", "bb", "a").inOrder()
+ assertThat(StringUtils.split("a.bb.a", '.', limit = 1000)).containsExactly("a", "bb", "a").inOrder()
+ }
+}