diff options
Diffstat (limited to 'libs')
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() + } +} |
