From c971dcc922218bd2ce68086e50c340b30cd114f2 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 9 Jun 2024 00:53:00 +0200 Subject: [PATCH 01/73] Add class RepoIgnoreNode which compares file names against .orgzlyignore rules The class extends the JGit class IgnoreNode, which can do most of the work for us. The SyncRepo interface needed a new method to allow reading an arbitrary file from the repository, since the .orgzlyignore file is not an ORG file. --- .../android/git/GitFileSynchronizer.java | 6 ++ .../com/orgzly/android/repos/ContentRepo.java | 7 +++ .../orgzly/android/repos/DatabaseRepo.java | 6 ++ .../orgzly/android/repos/DirectoryRepo.java | 7 +++ .../orgzly/android/repos/DropboxClient.java | 25 ++++++++ .../com/orgzly/android/repos/DropboxRepo.java | 6 ++ .../com/orgzly/android/repos/GitRepo.java | 7 +++ .../com/orgzly/android/repos/MockRepo.java | 6 ++ .../orgzly/android/repos/RepoIgnoreNode.kt | 61 +++++++++++++++++++ .../com/orgzly/android/repos/SyncRepo.java | 9 +++ .../com/orgzly/android/repos/WebdavRepo.kt | 8 +++ app/src/main/res/values/strings.xml | 2 +- 12 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt diff --git a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java index e976c95f2..cfa0fa4fe 100644 --- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java +++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java @@ -29,8 +29,10 @@ import org.eclipse.jgit.treewalk.TreeWalk; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.List; @@ -61,6 +63,10 @@ public void retrieveLatestVersionOfFile( MiscUtils.copyFile(repoDirectoryFile(repositoryPath), destination); } + public InputStream openRepoFileInputStream(String repositoryPath) throws FileNotFoundException { + return new FileInputStream(repoDirectoryFile(repositoryPath)); + } + private void fetch() throws IOException { try { if (BuildConfig.LOG_DEBUG) { diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index 0b1148ebd..50e94eaa3 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -126,6 +126,13 @@ public VersionedRook retrieveBook(String fileName, File destinationFile) throws return new VersionedRook(repoId, RepoType.DOCUMENT, repoUri, sourceFile.getUri(), rev, mtime); } + @Override + public InputStream openRepoFileInputStream(String fileName) throws IOException { + DocumentFile sourceFile = repoDocumentFile.findFile(fileName); + if (sourceFile == null) throw new FileNotFoundException(); + return context.getContentResolver().openInputStream(sourceFile.getUri()); + } + @Override public VersionedRook storeBook(File file, String fileName) throws IOException { if (!file.exists()) { diff --git a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java index 9a2c5554f..7d371a12e 100644 --- a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java @@ -8,6 +8,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.List; /** @@ -52,6 +53,11 @@ public VersionedRook retrieveBook(String fileName, File file) { return dbRepo.retrieveBook(repoId, repoUri, uri, file); } + @Override + public InputStream openRepoFileInputStream(String fileName) throws IOException { + throw new UnsupportedOperationException("Not implemented"); + } + @Override public VersionedRook storeBook(File file, String fileName) throws IOException { String content = MiscUtils.readStringFromFile(file); diff --git a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java index bac17240c..c623b9fd6 100644 --- a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java @@ -12,8 +12,10 @@ import org.jetbrains.annotations.NotNull; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -130,6 +132,11 @@ public VersionedRook retrieveBook(String fileName, File destinationFile) throws return new VersionedRook(repoId, RepoType.DIRECTORY, repoUri, uri, rev, mtime); } + @Override + public InputStream openRepoFileInputStream(String fileName) throws IOException { + return new FileInputStream(repoUri.buildUpon().appendPath(fileName).build().getPath()); + } + @Override public VersionedRook storeBook(File file, String fileName) throws IOException { if (!file.exists()) { diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index e0fdbe082..12482fadf 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -4,6 +4,7 @@ import android.content.Context; import android.net.Uri; +import com.dropbox.core.DbxDownloader; import com.dropbox.core.DbxException; import com.dropbox.core.DbxRequestConfig; import com.dropbox.core.android.Auth; @@ -25,6 +26,7 @@ import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -233,6 +235,29 @@ public VersionedRook download(Uri repoUri, String fileName, File localFile) thro } } + public InputStream streamFile(Uri repoUri, String fileName) throws IOException { + linkedOrThrow(); + + Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + FileMetadata metadata; + String rev; + DbxDownloader downloader; + + try { + Metadata pathMetadata = dbxClient.files().getMetadata(uri.getPath()); + metadata = (FileMetadata) pathMetadata; + rev = metadata.getRev(); + downloader = dbxClient.files().download(metadata.getPathLower(), rev); + } catch (DbxException e) { + if (e instanceof GetMetadataErrorException) { + if (((GetMetadataErrorException) e).errorValue.getPathValue() == LookupError.NOT_FOUND) { + throw new FileNotFoundException(); + } + } + throw new RuntimeException(e); + } + return downloader.getInputStream(); + } /** Upload file to Dropbox. */ public VersionedRook upload(File file, Uri repoUri, String fileName) throws IOException { diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index 484da45a6..402ca1594 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -8,6 +8,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.List; public class DropboxRepo implements SyncRepo { @@ -46,6 +47,11 @@ public VersionedRook retrieveBook(String fileName, File file) throws IOException return client.download(repoUri, fileName, file); } + @Override + public InputStream openRepoFileInputStream(String fileName) throws IOException { + return client.streamFile(repoUri, fileName); + } + @Override public VersionedRook storeBook(File file, String fileName) throws IOException { return client.upload(file, repoUri, fileName); diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 3d60b5cf6..2e1f31225 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -38,6 +38,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.List; @@ -221,6 +222,12 @@ public VersionedRook retrieveBook(String fileName, File destination) throws IOEx return currentVersionedRook(sourceUri); } + @Override + public InputStream openRepoFileInputStream(String fileName) throws IOException { + Uri sourceUri = Uri.parse(fileName); + return synchronizer.openRepoFileInputStream(sourceUri.getPath()); + } + private VersionedRook currentVersionedRook(Uri uri) { RevCommit commit = null; uri = Uri.parse(Uri.decode(uri.toString())); diff --git a/app/src/main/java/com/orgzly/android/repos/MockRepo.java b/app/src/main/java/com/orgzly/android/repos/MockRepo.java index a6fca9e8d..63c6f93e5 100644 --- a/app/src/main/java/com/orgzly/android/repos/MockRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/MockRepo.java @@ -8,6 +8,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.List; /** @@ -56,6 +57,11 @@ public VersionedRook retrieveBook(String fileName, File file) throws IOException return databaseRepo.retrieveBook(fileName, file); } + @Override + public InputStream openRepoFileInputStream(String fileName) throws IOException { + throw new UnsupportedOperationException("Not implemented"); + } + @Override public VersionedRook storeBook(File file, String fileName) throws IOException { SystemClock.sleep(SLEEP_FOR_STORE_BOOK); diff --git a/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt b/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt new file mode 100644 index 000000000..d8fe16411 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt @@ -0,0 +1,61 @@ +package com.orgzly.android.repos + +import android.os.Build +import androidx.annotation.RequiresApi +import com.orgzly.R +import com.orgzly.android.App +import org.eclipse.jgit.ignore.IgnoreNode +import java.io.FileNotFoundException +import java.io.IOException +import kotlin.io.path.Path + +class RepoIgnoreNode(repo: SyncRepo) : IgnoreNode() { + + init { + try { + val inputStream = repo.openRepoFileInputStream(IGNORE_FILE) + inputStream.use { + parse(it) + } + inputStream.close() + } catch (ignored: FileNotFoundException) {} + } + + @RequiresApi(Build.VERSION_CODES.O) + fun isPathIgnored(pathString: String, isDirectory: Boolean): Boolean { + if (rules.isEmpty()) { + return false + } + val path = Path(pathString) + return when (isIgnored(pathString, isDirectory)) { + MatchResult.IGNORED -> + true + MatchResult.NOT_IGNORED -> + false + MatchResult.CHECK_PARENT -> + if (path.parent != null) { + // Recursive call + isPathIgnored(path.parent.toString(), true) + } else { + false + } + else -> false + } + } + + @RequiresApi(Build.VERSION_CODES.O) + fun ensureFileNameIsNotIgnored(filePath: String) { + if (isPathIgnored(filePath, false)) { + throw IOException( + App.getAppContext().getString( + R.string.error_file_matches_repo_ignore_rule, + IGNORE_FILE, + ) + ) + } + } + + companion object { + const val IGNORE_FILE = ".orgzlyignore" + } +} diff --git a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java index 6b2a7301b..ed22ef2d1 100644 --- a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java @@ -4,6 +4,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.List; /** @@ -32,6 +33,14 @@ public interface SyncRepo { */ VersionedRook retrieveBook(String fileName, File destination) throws IOException; + /** + * Open a file in the repository for reading. Originally added for parsing the .orgzlyignore + * file. + * @param fileName The file to open + * @throws IOException + */ + InputStream openRepoFileInputStream(String fileName) throws IOException; + /** * Uploads book storing it under given filename under repo's url. * @param file The contents of this file should be stored at the remote location/repo diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index e8bcd01e5..bc098a768 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -15,6 +15,7 @@ import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import okhttp3.OkHttpClient import okio.Buffer import java.io.File +import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.InputStream import java.security.KeyStore @@ -186,6 +187,13 @@ class WebdavRepo( return sardine.list(fileUrl).first().toVersionedRook() } + override fun openRepoFileInputStream(fileName: String): InputStream { + val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + if (!sardine.exists(fileUrl)) + throw FileNotFoundException() + return sardine.get(fileUrl) + } + override fun storeBook(file: File?, fileName: String?): VersionedRook { val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84a81d6fc..8e66dfab4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -768,7 +768,7 @@ Error while trying to generate the SSH key pair Message: \n Error: SSH key can only be unlocked from an activity - Repository filename matches a rule in %s + Error: Repository file name matches a rule in %s No change From ee9d943840e0c22df42ec9703371da65886ec1c2 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 9 Jun 2024 01:07:10 +0200 Subject: [PATCH 02/73] Apply the ignore rules in .orgzlyignore during SyncRepo.getBooks() I also speeded up the Git file tree walk. (We were performing similar checks two times -- first in the TreeFilter.include() method override, and then later in the actual processing of interesting nodes. I have concluded that we can filter out all uninteresting nodes in one place.) --- .../com/orgzly/android/repos/ContentRepo.java | 9 +++++++- .../orgzly/android/repos/DirectoryRepo.java | 16 ++++++++++++-- .../orgzly/android/repos/DropboxClient.java | 9 +++++++- .../com/orgzly/android/repos/DropboxRepo.java | 3 ++- .../com/orgzly/android/repos/GitRepo.java | 22 ++++++++----------- .../com/orgzly/android/repos/WebdavRepo.kt | 18 +++++++++++---- 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index 50e94eaa3..ef87d2ac2 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** * Using DocumentFile, for devices running Lollipop or later. @@ -69,12 +70,18 @@ public List getBooks() throws IOException { DocumentFile[] files = repoDocumentFile.listFiles(); + RepoIgnoreNode ignores = new RepoIgnoreNode(this); + if (files != null) { // Can't compare TreeDocumentFile // Arrays.sort(files); for (DocumentFile file : files) { - if (BookName.isSupportedFormatFileName(file.getName())) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ignores.isPathIgnored(Objects.requireNonNull(file.getName()), false)) { + continue; + } + } if (BookName.isSupportedFormatFileName(file.getName())) { if (BuildConfig.LOG_DEBUG) { LogUtils.d(TAG, diff --git a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java index c623b9fd6..db2b59064 100644 --- a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java @@ -1,6 +1,7 @@ package com.orgzly.android.repos; import android.net.Uri; +import android.os.Build; import android.util.Log; import com.orgzly.android.BookName; @@ -83,10 +84,21 @@ public Uri getUri() { @Override public List getBooks() { + RepoIgnoreNode ignores = new RepoIgnoreNode(this); + List result = new ArrayList<>(); - File[] files = mDirectory.listFiles((dir, filename) -> - BookName.isSupportedFormatFileName(filename)); + File[] files; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + files = mDirectory.listFiles( + (dir, filename) -> BookName.isSupportedFormatFileName(filename) + && !ignores.isPathIgnored(filename, false) + ); + } else { + files = mDirectory.listFiles( + (dir, filename) -> BookName.isSupportedFormatFileName(filename) + ); + } if (files != null) { Arrays.sort(files); diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index 12482fadf..940039415 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -3,6 +3,7 @@ import android.app.Activity; import android.content.Context; import android.net.Uri; +import android.os.Build; import com.dropbox.core.DbxDownloader; import com.dropbox.core.DbxException; @@ -129,7 +130,7 @@ private void deleteCredential() { AppPreferences.dropboxSerializedCredential(mContext, null); } - public List getBooks(Uri repoUri) throws IOException { + public List getBooks(Uri repoUri, RepoIgnoreNode ignores) throws IOException { linkedOrThrow(); List list = new ArrayList<>(); @@ -153,6 +154,12 @@ public List getBooks(Uri repoUri) throws IOException { if (metadata instanceof FileMetadata) { FileMetadata file = (FileMetadata) metadata; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ignores.isPathIgnored(file.getName(), false)) { + continue; + } + } + if (BookName.isSupportedFormatFileName(file.getName())) { Uri uri = repoUri.buildUpon().appendPath(file.getName()).build(); VersionedRook book = new VersionedRook( diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index 402ca1594..74dd25a65 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -39,7 +39,8 @@ public Uri getUri() { @Override public List getBooks() throws IOException { - return client.getBooks(repoUri); + RepoIgnoreNode ignores = new RepoIgnoreNode(this); + return client.getBooks(repoUri, ignores); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 2e1f31225..0b18bfa85 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -291,14 +291,18 @@ public List getBooks() throws IOException { walk.reset(); walk.setRecursive(true); walk.addTree(synchronizer.currentHead().getTree()); - final IgnoreNode ignores = getIgnores(); + final RepoIgnoreNode ignores = new RepoIgnoreNode(this); walk.setFilter(new TreeFilter() { @Override public boolean include(TreeWalk walker) { - final FileMode mode = walker.getFileMode(0); - final String filePath = walker.getPathString(); + final FileMode mode = walk.getFileMode(); final boolean isDirectory = mode == FileMode.TREE; - return !(ignores.isIgnored(filePath, isDirectory) == IgnoreNode.MatchResult.IGNORED); + final String filePath = walk.getPathString(); + if (ignores.isIgnored(filePath, isDirectory) == IgnoreNode.MatchResult.IGNORED) + return false; + if (isDirectory) + return true; + return BookName.isSupportedFormatFileName(filePath); } @Override @@ -312,15 +316,7 @@ public TreeFilter clone() { } }); while (walk.next()) { - final FileMode mode = walk.getFileMode(0); - final boolean isDirectory = mode == FileMode.TREE; - final String filePath = walk.getPathString(); - if (isDirectory) - continue; - if (BookName.isSupportedFormatFileName(filePath)) - result.add( - currentVersionedRook( - Uri.withAppendedPath(Uri.EMPTY, walk.getPathString()))); + result.add(currentVersionedRook(Uri.withAppendedPath(Uri.EMPTY, walk.getPathString()))); } return result; } diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index bc098a768..da62353a2 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -1,6 +1,7 @@ package com.orgzly.android.repos import android.net.Uri +import android.os.Build import com.burgstaller.okhttp.AuthenticationCacheInterceptor import com.burgstaller.okhttp.CachingAuthenticatorDecorator import com.burgstaller.okhttp.DispatchingAuthenticator @@ -26,7 +27,6 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager -import kotlin.time.Duration class WebdavRepo( @@ -163,13 +163,23 @@ class WebdavRepo( sardine.createDirectory(url) } + val ignores = RepoIgnoreNode(this) + return sardine .list(url) .mapNotNull { - if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name)) { - null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.name, false)) { + null + } else { + it.toVersionedRook() + } } else { - it.toVersionedRook() + if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name)) { + null + } else { + it.toVersionedRook() + } } } .toMutableList() From 8470b586e7df9ea8aa8ac9999634b2f48fc513c9 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 9 Jun 2024 01:12:57 +0200 Subject: [PATCH 03/73] Always check ignore rules when linking and renaming books --- .../com/orgzly/android/data/DataRepository.kt | 14 ++++++- .../com/orgzly/android/repos/GitRepo.java | 39 ++----------------- .../com/orgzly/android/repos/RepoUtils.java | 9 +++++ .../java/com/orgzly/android/sync/SyncUtils.kt | 2 + 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index f427efb05..1acac9155 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.content.res.Resources import android.media.MediaScannerConnection import android.net.Uri +import android.os.Build import android.os.Handler import android.text.TextUtils import androidx.lifecycle.LiveData @@ -52,7 +53,6 @@ import com.orgzly.org.parser.OrgParser import com.orgzly.org.parser.OrgParserWriter import com.orgzly.org.utils.StateChangeLogic import java.io.* -import java.lang.IllegalStateException import java.util.* import java.util.concurrent.Callable import javax.inject.Inject @@ -384,6 +384,10 @@ class DataRepository @Inject constructor( bookView.syncedTo?.let { vrook -> val repo = getRepoInstance(vrook.repoId, vrook.repoType, vrook.repoUri.toString()) + /* Do not rename if the new filename will be ignored */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + RepoUtils.ensureFileNameIsNotIgnored(repo, BookName.fileName(name, BookFormat.ORG)) + val movedVrook = repo.renameBook(vrook.uri, name) updateBookLinkAndSync(book.id, movedVrook) @@ -501,6 +505,14 @@ class DataRepository @Inject constructor( "Repo ${repo.url} not found" }.id + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Ensure that the resulting file name is not ignored in this repo + val syncRepo = getRepoInstance(repo.id, repo.type, repo.url) + val bookName = getBook(bookId)!!.name + val fileName = BookName.fileName(bookName, BookFormat.ORG) + RepoUtils.ensureFileNameIsNotIgnored(syncRepo, fileName) + } + db.bookLink().upsert(bookId, repoId) } diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 0b18bfa85..4bd759889 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -7,8 +7,7 @@ import android.util.Log; import com.orgzly.BuildConfig; -import com.orgzly.R; -import com.orgzly.android.App; +import com.orgzly.android.BookFormat; import com.orgzly.android.BookName; import com.orgzly.android.db.entity.Repo; import com.orgzly.android.git.GitFileSynchronizer; @@ -35,7 +34,6 @@ import org.eclipse.jgit.util.FileUtils; import java.io.File; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -45,7 +43,6 @@ public class GitRepo implements SyncRepo, TwoWaySyncRepo { private final static String TAG = GitRepo.class.getName(); private final long repoId; - private final Context context = App.getAppContext(); /** * Used as cause when we try to clone into a non-empty directory @@ -189,7 +186,6 @@ public boolean isAutoSyncSupported() { } public VersionedRook storeBook(File file, String fileName) throws IOException { - ensureFileNameIsNotIgnored(fileName); File destination = synchronizer.repoDirectoryFile(fileName); if (destination.exists()) { @@ -241,34 +237,6 @@ private VersionedRook currentVersionedRook(Uri uri) { return new VersionedRook(repoId, RepoType.GIT, getUri(), uri, commit.name(), mtime); } - private IgnoreNode getIgnores() throws IOException { - IgnoreNode ignores = new IgnoreNode(); - File ignoreFile = synchronizer.repoDirectoryFile(context.getString(R.string.repo_ignore_rules_file)); - if (ignoreFile.exists()) { - FileInputStream in = new FileInputStream(ignoreFile); - try { - ignores.parse(in); - } finally { - in.close(); - } - } - return ignores; - } - - /** - * Since subdirectories are currently not supported, we only check the file name against the - * ignore rules. - * @param fileName Name of the file which the user tries to write to - * @throws IOException - */ - private void ensureFileNameIsNotIgnored(String fileName) throws IOException { - IgnoreNode ignores = getIgnores(); - if (ignores.isIgnored(fileName, false) == IgnoreNode.MatchResult.IGNORED) { - throw new IOException(context.getString(R.string.error_file_matches_repo_ignore_rule, - context.getString(R.string.repo_ignore_rules_file))); - } - } - public boolean isUnchanged() throws IOException { // Check if the current head is unchanged. // If so, we can read all the VersionedRooks from the database. @@ -329,10 +297,9 @@ public void delete(Uri uri) throws IOException { if (synchronizer.deleteFileFromRepo(uri)) synchronizer.tryPush(); } - public VersionedRook renameBook(Uri oldUri, String newRookName) throws IOException { + public VersionedRook renameBook(Uri oldUri, String newBookName) throws IOException { String oldFileName = oldUri.toString().replaceFirst("^/", ""); - String newFileName = newRookName + ".org"; - ensureFileNameIsNotIgnored(newFileName); + String newFileName = BookName.fileName(newBookName, BookFormat.ORG); if (synchronizer.renameFileInRepo(oldFileName, newFileName)) { synchronizer.tryPush(); return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(newFileName).build()); diff --git a/app/src/main/java/com/orgzly/android/repos/RepoUtils.java b/app/src/main/java/com/orgzly/android/repos/RepoUtils.java index 1b1a73569..639e77cc2 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoUtils.java +++ b/app/src/main/java/com/orgzly/android/repos/RepoUtils.java @@ -1,5 +1,9 @@ package com.orgzly.android.repos; +import android.os.Build; + +import androidx.annotation.RequiresApi; + import java.util.Collection; public class RepoUtils { @@ -26,5 +30,10 @@ public static boolean isAutoSyncSupported(Collection repos) { } return true; } + + @RequiresApi(api = Build.VERSION_CODES.O) + public static void ensureFileNameIsNotIgnored(SyncRepo repo, String fileName) { + new RepoIgnoreNode(repo).ensureFileNameIsNotIgnored(fileName); + } } diff --git a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt index f0240cf5c..cb5b4af48 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt @@ -148,6 +148,8 @@ object SyncUtils { repoEntity = dataRepository.getRepos().iterator().next() repoUrl = repoEntity.url fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) + /* Set repo link before saving to ensure repo ignore rules are checked */ + dataRepository.setLink(namesake.book.book.id, repoEntity) dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } From 138ad4181729ec554572ba8b3a08203496761379 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 9 Jun 2024 01:26:17 +0200 Subject: [PATCH 04/73] Add missing permission in SyncingTest --- .../java/com/orgzly/android/espresso/SyncingTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index 4acb0d06e..d16e807c4 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -14,6 +14,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static com.orgzly.android.espresso.util.EspressoUtils.clickSetting; import static com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu; +import static com.orgzly.android.espresso.util.EspressoUtils.grantAlarmsAndRemindersPermission; import static com.orgzly.android.espresso.util.EspressoUtils.onActionItemClick; import static com.orgzly.android.espresso.util.EspressoUtils.onBook; import static com.orgzly.android.espresso.util.EspressoUtils.onListItem; @@ -73,6 +74,7 @@ public void tearDown() throws Exception { * Utility method for starting sync using drawer button. */ private void sync() { + grantAlarmsAndRemindersPermission(); onView(withId(R.id.drawer_layout)).perform(open()); onView(withId(R.id.sync_button_container)).perform(click()); onView(withId(R.id.drawer_layout)).perform(close()); From b4e9e663dba9a699b39d4e5485e58c9a5d194381 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 9 Jun 2024 01:14:20 +0200 Subject: [PATCH 05/73] Add/update tests for .orgzlyignore support --- .../java/com/orgzly/android/TestUtils.java | 31 ++++ .../orgzly/android/espresso/SyncingTest.java | 12 +- .../android/repos/DirectoryRepoTest.java | 23 +++ .../orgzly/android/repos/DropboxRepoTest.java | 80 +++++++-- .../com/orgzly/android/repos/GitRepoTest.kt | 152 ++++++++++++++++++ .../android/repos/RepoIgnoreNodeTest.kt | 47 ++++++ .../git/GitPreferencesFromRepoPrefs.java | 16 +- .../orgzly/android/prefs/AppPreferences.java | 4 + .../com/orgzly/android/repos/MockRepo.java | 3 +- 9 files changed, 338 insertions(+), 30 deletions(-) create mode 100644 app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt create mode 100644 app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt diff --git a/app/src/androidTest/java/com/orgzly/android/TestUtils.java b/app/src/androidTest/java/com/orgzly/android/TestUtils.java index 297f3edd9..07eb863a3 100644 --- a/app/src/androidTest/java/com/orgzly/android/TestUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/TestUtils.java @@ -5,11 +5,13 @@ import android.net.Uri; +import com.orgzly.BuildConfig; import com.orgzly.android.data.DataRepository; import com.orgzly.android.data.DbRepoBookRepository; import com.orgzly.android.db.entity.BookAction; import com.orgzly.android.db.entity.BookView; import com.orgzly.android.db.entity.Repo; +import com.orgzly.android.prefs.AppPreferences; import com.orgzly.android.repos.RepoType; import com.orgzly.android.repos.RepoWithProps; import com.orgzly.android.repos.SyncRepo; @@ -18,6 +20,10 @@ import com.orgzly.android.sync.SyncUtils; import com.orgzly.android.util.MiscUtils; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; + import java.io.File; import java.io.IOException; import java.util.Map; @@ -149,4 +155,29 @@ public Map sync() { return null; } + + public Map syncOrThrow() throws Exception { + Map nameGroups = SyncUtils.groupAllNotebooksByName(dataRepository); + + for (BookNamesake group : nameGroups.values()) { + BookAction action = SyncUtils.syncNamesake(dataRepository, group); + dataRepository.setBookLastActionAndSyncStatus( + group.getBook().getBook().getId(), action, group.getStatus().toString()); + } + + return nameGroups; + } + + public void dropboxTestPreflight() throws JSONException { + Assume.assumeTrue(BuildConfig.IS_DROPBOX_ENABLED); + Assume.assumeTrue(BuildConfig.DROPBOX_APP_KEY.length() > 0); + Assume.assumeTrue(BuildConfig.DROPBOX_REFRESH_TOKEN.length() > 0); + + JSONObject mockSerializedDbxCredential = new JSONObject(); + mockSerializedDbxCredential.put("access_token", "dummy"); + mockSerializedDbxCredential.put("expires_at", System.currentTimeMillis()); + mockSerializedDbxCredential.put("refresh_token", BuildConfig.DROPBOX_REFRESH_TOKEN); + mockSerializedDbxCredential.put("app_key", BuildConfig.DROPBOX_APP_KEY); + AppPreferences.dropboxSerializedCredential(App.getAppContext(), mockSerializedDbxCredential.toString()); + } } diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index d16e807c4..eecbd1d24 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -36,7 +36,6 @@ import androidx.test.core.app.ActivityScenario; -import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.OrgzlyTest; import com.orgzly.android.RetryTestRule; @@ -46,9 +45,9 @@ import com.orgzly.android.sync.SyncRunner; import com.orgzly.android.ui.main.MainActivity; +import org.json.JSONException; import org.junit.After; import org.junit.Assert; -import org.junit.Assume; import org.junit.Rule; import org.junit.Test; @@ -594,9 +593,8 @@ public void testEncodingAfterSyncSaving() { } @Test - public void testSettingLinkToRenamedRepo() { - Assume.assumeTrue(BuildConfig.IS_DROPBOX_ENABLED); - + public void testSettingLinkToRenamedRepo() throws JSONException { + testUtils.dropboxTestPreflight(); Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); testUtils.setupRook(repo, "mock://repo-a/booky.org", "Täht", "1abcde", 1400067156000L); scenario = ActivityScenario.launch(MainActivity.class); @@ -650,8 +648,8 @@ public void testSettingLinkToRenamedRepo() { } @Test - public void testRenamingReposRemovesLinksWhatUsedThem() { - Assume.assumeTrue(BuildConfig.IS_DROPBOX_ENABLED); + public void testRenamingReposRemovesLinksWhatUsedThem() throws JSONException { + testUtils.dropboxTestPreflight(); testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); testUtils.setupRepo(RepoType.MOCK, "mock://repo-b"); diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java index d2aaf23fd..60133d30c 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java @@ -1,5 +1,6 @@ package com.orgzly.android.repos; +import android.os.Build; import android.os.Environment; import com.orgzly.android.BookName; @@ -20,6 +21,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assume.assumeTrue; public class DirectoryRepoTest extends OrgzlyTest { private static final String TAG = DirectoryRepoTest.class.getName(); @@ -84,6 +86,27 @@ public void testExtension() throws IOException { assertEquals(repoUriString + "/03.org", books.get(0).getUri().toString()); } + @Test + public void testGetBooksRespectsIgnoreRules() throws IOException { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O); + RepoWithProps repoWithProps = new RepoWithProps(new Repo(13, RepoType.DIRECTORY, repoUriString)); + DirectoryRepo repo = new DirectoryRepo(repoWithProps, true); + + // Add .org files + MiscUtils.writeStringToFile("content", new File(dirFile, "file1.org")); + MiscUtils.writeStringToFile("content", new File(dirFile, "file2.org")); + MiscUtils.writeStringToFile("content", new File(dirFile, "file3.org")); + + // Add .orgzlyignore file + MiscUtils.writeStringToFile("*1.org\nfile3*", new File(dirFile, RepoIgnoreNode.IGNORE_FILE)); + + List books = repo.getBooks(); + + assertEquals(1, books.size()); + assertEquals("file2", BookName.getInstance(context, books.get(0)).getName()); + assertEquals(repoUriString + "/file2.org", books.get(0).getUri().toString()); + } + @Test public void testListDownloadsDirectory() throws IOException { File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java index 90f9bbe4b..38a9ba6e0 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java @@ -1,25 +1,26 @@ package com.orgzly.android.repos; -import com.orgzly.BuildConfig; +import com.orgzly.android.App; import com.orgzly.android.BookName; import com.orgzly.android.OrgzlyTest; import com.orgzly.android.db.entity.BookView; -import com.orgzly.android.prefs.AppPreferences; import com.orgzly.android.util.MiscUtils; -import org.json.JSONObject; -import org.junit.Assume; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import java.io.File; import java.io.IOException; +import java.util.List; import java.util.UUID; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; -import android.os.SystemClock; +import android.net.Uri; public class DropboxRepoTest extends OrgzlyTest { private static final String DROPBOX_TEST_DIR = "/orgzly-android-tests"; @@ -27,18 +28,12 @@ public class DropboxRepoTest extends OrgzlyTest { @Before public void setUp() throws Exception { super.setUp(); - Assume.assumeTrue(BuildConfig.IS_DROPBOX_ENABLED); - Assume.assumeTrue(BuildConfig.DROPBOX_APP_KEY.length() > 0); - Assume.assumeTrue(BuildConfig.DROPBOX_REFRESH_TOKEN.length() > 0); - - JSONObject mockSerializedDbxCredential = new JSONObject(); - mockSerializedDbxCredential.put("access_token", "dummy"); - mockSerializedDbxCredential.put("expires_at", System.currentTimeMillis()); - mockSerializedDbxCredential.put("refresh_token", BuildConfig.DROPBOX_REFRESH_TOKEN); - mockSerializedDbxCredential.put("app_key", BuildConfig.DROPBOX_APP_KEY); - AppPreferences.dropboxSerializedCredential(context, mockSerializedDbxCredential.toString()); + testUtils.dropboxTestPreflight(); } + @Rule + public ExpectedException exceptionRule = ExpectedException.none(); + @Test public void testUrl() { assertEquals( @@ -75,6 +70,53 @@ public void testRenameBook() throws IOException { assertEquals(repoUriString + "/booky-renamed.org", bookView.getSyncedTo().getUri().toString()); } + @Test + public void testIgnoreRulePreventsLinkingBook() throws Exception { + Uri repoUri = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri(); + testUtils.setupRepo(RepoType.DROPBOX, repoUri.toString()); + uploadFileToRepo(repoUri, RepoIgnoreNode.IGNORE_FILE, "*.org"); + testUtils.setupBook("booky", ""); + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage("matches a rule in .orgzlyignore"); + testUtils.syncOrThrow(); + } + + @Test + public void testIgnoreRulePreventsLoadingBook() throws Exception { + SyncRepo repo = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()); + testUtils.setupRepo(RepoType.DROPBOX, repo.getUri().toString()); + + // Create two .org files + uploadFileToRepo(repo.getUri(), "ignored.org", "1 2 3"); + uploadFileToRepo(repo.getUri(), "notignored.org", "1 2 3"); + // Create .orgzlyignore + uploadFileToRepo(repo.getUri(), RepoIgnoreNode.IGNORE_FILE, "ignored.org"); + testUtils.sync(); + + List bookViews = dataRepository.getBooks(); + assertEquals(1, bookViews.size()); + assertEquals("notignored", bookViews.get(0).getBook().getName()); + } + + @Test + public void testIgnoreRulePreventsRenamingBook() throws Exception { + BookView bookView; + Uri repoUri = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri(); + testUtils.setupRepo(RepoType.DROPBOX, repoUri.toString()); + uploadFileToRepo(repoUri, RepoIgnoreNode.IGNORE_FILE, "badname*"); + testUtils.setupBook("goodname", ""); + testUtils.sync(); + bookView = dataRepository.getBookView("goodname"); + dataRepository.renameBook(bookView, "badname"); + bookView = dataRepository.getBooks().get(0); + assertTrue( + bookView.getBook() + .getLastAction() + .toString() + .contains("matches a rule in .orgzlyignore") + ); + } + @Test public void testDropboxFileRename() throws IOException { SyncRepo repo = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()); @@ -98,6 +140,14 @@ public void testDropboxFileRename() throws IOException { assertEquals("notebook-renamed.org", BookName.getInstance(context, repo.getBooks().get(0)).getFileName()); } + private void uploadFileToRepo(Uri repoUri, String fileName, String fileContents) throws IOException { + DropboxClient client = new DropboxClient(App.getAppContext(), 0); + File tmpFile = File.createTempFile("abc", null); + MiscUtils.writeStringToFile(fileContents, tmpFile); + client.upload(tmpFile, repoUri, fileName); + tmpFile.delete(); + } + private String randomUrl() { return "dropbox:"+ DROPBOX_TEST_DIR + "/" + UUID.randomUUID().toString(); } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt new file mode 100644 index 000000000..3f4b1a260 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt @@ -0,0 +1,152 @@ +package com.orgzly.android.repos + +import android.net.Uri +import android.os.Build +import androidx.core.net.toUri +import com.orgzly.R +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.BookView +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.git.GitFileSynchronizer +import com.orgzly.android.git.GitPreferencesFromRepoPrefs +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.prefs.RepoPreferences +import com.orgzly.android.util.MiscUtils +import org.eclipse.jgit.api.Git +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import java.io.File +import java.io.IOException +import java.nio.file.Path +import kotlin.io.path.createTempDirectory + +class GitRepoTest : OrgzlyTest() { + + private lateinit var bareRepoPath: Path + private lateinit var repoUri: Uri + private lateinit var gitPreferences: GitPreferencesFromRepoPrefs + private lateinit var workingtree: File + private lateinit var repoPreferences: RepoPreferences + private lateinit var repo: Repo + private lateinit var syncRepo: GitRepo + private lateinit var git: Git + private lateinit var synchronizer: GitFileSynchronizer + + @Rule + @JvmField + var exceptionRule: ExpectedException = ExpectedException.none() + + override fun setUp() { + super.setUp() + bareRepoPath = createTempDirectory() + Git.init().setBare(true).setDirectory(bareRepoPath.toFile()).call() + AppPreferences.gitIsEnabled(context, true) + repoUri = bareRepoPath.toFile().toUri() + repo = testUtils.setupRepo(RepoType.GIT, repoUri.toString()) + repoPreferences = RepoPreferences(context, repo.id, repoUri) + gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) + workingtree = File(gitPreferences.repositoryFilepath()) + workingtree.mkdirs() + git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) + syncRepo = dataRepository.getRepoInstance(repo.id, RepoType.GIT, repo.url) as GitRepo + synchronizer = GitFileSynchronizer(git, gitPreferences) + } + + override fun tearDown() { + super.tearDown() + testUtils.deleteRepo(repo.url) + workingtree.deleteRecursively() + bareRepoPath.toFile()?.deleteRecursively() + } + + @Test + fun testSyncNewBookWithoutLinkAndOneRepo() { + testUtils.setupBook("book1", "book content") + testUtils.sync() + val bookView = dataRepository.getBooks()[0] + assertEquals(repoUri.toString(), bookView.linkRepo?.url) + assertEquals(1, syncRepo.books.size) + assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) + assertEquals(context.getString(R.string.sync_status_saved, repo.url), bookView.book.lastAction!!.message) + assertEquals("/book1.org", bookView.syncedTo!!.uri.toString()) + } + + @Test + fun testIgnoredFilesInRepoAreNotLoaded() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + // Create ignore file in working tree and commit + val ignoreFileContents = """ + ignoredbook.org + ignored-*.org + """.trimIndent() + addAndCommitIgnoreFile(ignoreFileContents) + // Add multiple files to repo + for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + synchronizer.addAndCommitNewFile(tmpFile, fileName) + tmpFile.delete() + } + testUtils.sync() + assertEquals(1, syncRepo.books.size) + assertEquals(1, dataRepository.getBooks().size) + assertEquals("notignored", dataRepository.getBooks()[0].book.name) + } + + @Test + fun testUnIgnoredFilesInRepoAreLoaded() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + // Create ignore file in working tree and commit + val ignoreFileContents = """ + *.org + !notignored.org + """.trimIndent() + addAndCommitIgnoreFile(ignoreFileContents) + // Add multiple files to repo + for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + synchronizer.addAndCommitNewFile(tmpFile, fileName) + tmpFile.delete() + } + testUtils.sync() + assertEquals(1, syncRepo.books.size) + assertEquals(1, dataRepository.getBooks().size) + assertEquals("notignored", dataRepository.getBooks()[0].book.name) + } + + @Test + fun testIgnoreRulePreventsLinkingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + addAndCommitIgnoreFile("*.org") + testUtils.setupBook("booky", "") + exceptionRule.expect(IOException::class.java) + exceptionRule.expectMessage("matches a rule in .orgzlyignore") + testUtils.syncOrThrow() + } + + @Test + fun testIgnoreRulePreventsRenamingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + addAndCommitIgnoreFile("badname*") + testUtils.setupBook("goodname", "") + testUtils.sync() + var bookView: BookView? = dataRepository.getBookView("goodname") + dataRepository.renameBook(bookView!!, "badname") + bookView = dataRepository.getBooks()[0] + assertTrue( + bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") + ) + } + + private fun addAndCommitIgnoreFile(contents: String) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile(contents, tmpFile) + synchronizer.addAndCommitNewFile(tmpFile, RepoIgnoreNode.IGNORE_FILE) + tmpFile.delete() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt new file mode 100644 index 000000000..7f983087f --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt @@ -0,0 +1,47 @@ +package com.orgzly.android.repos + +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.Repo +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.FileNotFoundException +import java.io.InputStream +import java.util.HashMap + +class RepoIgnoreNodeTest : OrgzlyTest() { + + class MockRepoWithMockIgnoreFile : MockRepo(repoWithProps, null) { + override fun openRepoFileInputStream(filePath: String): InputStream { + if (filePath == RepoIgnoreNode.IGNORE_FILE) { + val ignoreFileContents = """ + IgnoredAnywhere.org + /OnlyIgnoredInRoot.org + CompletelyExcludedFolder/ + PartiallyExcludedFolder/** + !PartiallyExcludedFolder/included-*.org + """.trimIndent() + return ByteArrayInputStream(ignoreFileContents.toByteArray()) + } else { + throw FileNotFoundException() + } + } + } + + @Test + fun testIsPathIgnoredSyntax() { + val repo = MockRepoWithMockIgnoreFile() + val ignores = RepoIgnoreNode(repo) + assertEquals(true, ignores.isPathIgnored("IgnoredAnywhere.org", false)) + assertEquals(true, ignores.isPathIgnored("SomeFolder/IgnoredAnywhere.org", false)) + assertEquals(true, ignores.isPathIgnored("OnlyIgnoredInRoot.org", false)) + assertEquals(false, ignores.isPathIgnored("SomeFolder/OnlyIgnoredInRoot.org", false)) + assertEquals(true, ignores.isPathIgnored("CompletelyExcludedFolder/file.org", false)) + assertEquals(true, ignores.isPathIgnored("PartiallyExcludedFolder/whatever.org", false)) + assertEquals(false, ignores.isPathIgnored("PartiallyExcludedFolder/included-file.org", false)) + } + + companion object { + private val repoWithProps = RepoWithProps(Repo(0, RepoType.MOCK, "mock://repo"), HashMap()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java b/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java index d1468add8..8d043356e 100644 --- a/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java +++ b/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java @@ -16,13 +16,15 @@ public GitPreferencesFromRepoPrefs(RepoPreferences prefs) { @Override public GitTransportSetter createTransportSetter() { String scheme = remoteUri().getScheme(); - if ("https".equals(scheme)) { - String username = repoPreferences.getStringValue(R.string.pref_key_git_https_username, ""); - String password = repoPreferences.getStringValue(R.string.pref_key_git_https_password, ""); - return new HTTPSTransportSetter(username, password); - } else { - // assume SSH, since ssh:// usually isn't specified as the scheme when cloning via SSH. - return new GitSshKeyTransportSetter(); + switch (scheme) { + case "https": + String username = repoPreferences.getStringValue(R.string.pref_key_git_https_username, ""); + String password = repoPreferences.getStringValue(R.string.pref_key_git_https_password, ""); + return new HTTPSTransportSetter(username, password); + case "file": + return tc -> tc; + default: + return new GitSshKeyTransportSetter(); } } diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index 632961742..c2b6b12de 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -926,6 +926,10 @@ public static boolean gitIsEnabled(Context context) { context.getResources().getString(R.string.pref_key_git_is_enabled), context.getResources().getBoolean(R.bool.pref_default_git_is_enabled)); } + + public static void gitIsEnabled(Context context, Boolean value) { + getDefaultSharedPreferences(context).edit().putBoolean(context.getResources().getString(R.string.pref_key_git_is_enabled), value).apply(); + } public static String defaultRepositoryStorageDirectory(Context context) { File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); diff --git a/app/src/main/java/com/orgzly/android/repos/MockRepo.java b/app/src/main/java/com/orgzly/android/repos/MockRepo.java index 63c6f93e5..9145c9a3d 100644 --- a/app/src/main/java/com/orgzly/android/repos/MockRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/MockRepo.java @@ -7,6 +7,7 @@ import com.orgzly.android.data.DbRepoBookRepository; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.List; @@ -59,7 +60,7 @@ public VersionedRook retrieveBook(String fileName, File file) throws IOException @Override public InputStream openRepoFileInputStream(String fileName) throws IOException { - throw new UnsupportedOperationException("Not implemented"); + throw new FileNotFoundException(); } @Override From d4054cd0b20f65def4480662183eaf03c62be52a Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 9 Jun 2024 19:19:08 +0200 Subject: [PATCH 06/73] Add a few more sleeps to flaky Espresso tests --- .../androidTest/java/com/orgzly/android/espresso/BookTest.java | 1 + .../java/com/orgzly/android/espresso/QueryFragmentTest.java | 2 ++ .../java/com/orgzly/android/espresso/util/EspressoUtils.java | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/BookTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/BookTest.java index e94588c37..532959c55 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/BookTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/BookTest.java @@ -444,6 +444,7 @@ public void testSetDeadlineTimeForNewNote() { onView(withId(R.id.date_picker_button)).perform(click()); onView(withClassName(equalTo(DatePicker.class.getName()))).perform(setDate(2014, 4, 1)); onView(withText(android.R.string.ok)).perform(click()); + SystemClock.sleep(100); onView(withText(R.string.set)).perform(click()); onView(withId(R.id.deadline_button)).check(matches(withText(userDateTime("<2014-04-01 Tue>")))); } diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index ccb0f7e98..b7b2c299a 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -246,6 +246,7 @@ public void testClickingNote() { defaultSetUp(); onView(allOf(withText("book-two"), isDisplayed())).perform(click()); + SystemClock.sleep(200); searchForTextCloseKeyboard("b.book-two Note"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(29))); @@ -615,6 +616,7 @@ public void testSearchForTagOrTag() { scenario = ActivityScenario.launch(MainActivity.class); onView(allOf(withText("notebook"), isDisplayed())).perform(click()); + SystemClock.sleep(200); searchForTextCloseKeyboard("tn.a or tn.b"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(2))); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index d2e8aa1f3..66141e453 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -339,7 +339,6 @@ public static ViewInteraction contextualToolbarOverflowMenu() { } public static void searchForTextCloseKeyboard(String str) { - SystemClock.sleep(100); onView(isRoot()).perform(waitId(R.id.search_view, 5000)); onView(allOf(withId(R.id.search_view), isDisplayed())).perform(click()); onView(isRoot()).perform(waitId(R.id.search_src_text, 5000)); From f93f1cb470745da7310126e7e4c5e3a00ccbf000 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 20 Jun 2024 00:52:47 +0200 Subject: [PATCH 07/73] Loading from subfolder in content repo works --- .../java/com/orgzly/android/BookName.java | 10 +++ .../com/orgzly/android/data/DataRepository.kt | 2 +- .../com/orgzly/android/repos/ContentRepo.java | 89 ++++++++++++------- .../com/orgzly/android/sync/BookNamesake.java | 2 +- 4 files changed, 68 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/BookName.java b/app/src/main/java/com/orgzly/android/BookName.java index 139ea2d7e..0c3244b27 100644 --- a/app/src/main/java/com/orgzly/android/BookName.java +++ b/app/src/main/java/com/orgzly/android/BookName.java @@ -62,6 +62,16 @@ public static String getFileName(Context context, Uri uri) { return fileName; } + public static String getFileName(Uri repoUri, Uri fileUri) { + if ("content".equals(repoUri.getScheme())) { + String repoUriLastSegment = repoUri.toString().replaceAll("^.*/", ""); + String repoRootUriSegment = repoUri + "/document/" + repoUriLastSegment + "%2F"; + return Uri.decode(fileUri.toString().replace(repoRootUriSegment, "")); + } else { + return fileUri.toString().replace(repoUri.toString(), ""); + } + } + public static BookName getInstance(Context context, Rook rook) { return fromFileName(getFileName(context, rook.getUri())); } diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index 1acac9155..876a9bf4c 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -1621,7 +1621,7 @@ class DataRepository @Inject constructor( @Throws(IOException::class) fun loadBookFromRepo(rook: Rook): BookView? { - val fileName = BookName.getFileName(context, rook.uri) + val fileName = BookName.getFileName(rook.repoUri, rook.uri) return loadBookFromRepo(rook.repoId, rook.repoType, rook.repoUri.toString(), fileName) } diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index ef87d2ac2..83c0b0fa4 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -21,7 +21,6 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.List; -import java.util.Objects; /** * Using DocumentFile, for devices running Lollipop or later. @@ -68,20 +67,11 @@ public Uri getUri() { public List getBooks() throws IOException { List result = new ArrayList<>(); - DocumentFile[] files = repoDocumentFile.listFiles(); - - RepoIgnoreNode ignores = new RepoIgnoreNode(this); - - if (files != null) { - // Can't compare TreeDocumentFile - // Arrays.sort(files); + List files = walkFileTree(); + if (files.size() > 0) { for (DocumentFile file : files) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (ignores.isPathIgnored(Objects.requireNonNull(file.getName()), false)) { - continue; - } - } if (BookName.isSupportedFormatFileName(file.getName())) { + if (BookName.isSupportedFormatFileName(file.getName())) { if (BuildConfig.LOG_DEBUG) { LogUtils.d(TAG, @@ -111,9 +101,50 @@ public List getBooks() throws IOException { return result; } + /** + * @return All file nodes in the repo tree which are not excluded by .orgzlyignore + */ + private List walkFileTree() { + List result = new ArrayList<>(); + List directoryNodes = new ArrayList<>(); + RepoIgnoreNode ignores = new RepoIgnoreNode(this); + directoryNodes.add(repoDocumentFile); + while (!directoryNodes.isEmpty()) { + DocumentFile currentDir = directoryNodes.remove(0); + for (DocumentFile node : currentDir.listFiles()) { + String relativeFileName = BookName.getFileName(repoUri, node.getUri()); + if (node.isDirectory()) { + if (Build.VERSION.SDK_INT >= 26) { + if (ignores.isPathIgnored(relativeFileName, true)) { + continue; + } + } + directoryNodes.add(node); + } else { + if (Build.VERSION.SDK_INT >= 26) { + if (ignores.isPathIgnored(relativeFileName, false)) { + continue; + } + } result.add(node); + } + } + } + return result; + } + + public static String getContentRepoUriRootSegment(String repoUri) { + String repoUriLastSegment = repoUri.replaceAll("^.*/", ""); + return repoUri + "/document/" + repoUriLastSegment + "%2F"; + } + + private DocumentFile getDocumentFileFromFileName(String fileName) { + String fullUri = repoDocumentFile.getUri() + Uri.encode("/" + fileName); + return DocumentFile.fromSingleUri(context, Uri.parse(fullUri)); + } + @Override public VersionedRook retrieveBook(String fileName, File destinationFile) throws IOException { - DocumentFile sourceFile = repoDocumentFile.findFile(fileName); + DocumentFile sourceFile = getDocumentFileFromFileName(fileName); if (sourceFile == null) { throw new FileNotFoundException("Book " + fileName + " not found in " + repoUri); } else { @@ -135,8 +166,8 @@ public VersionedRook retrieveBook(String fileName, File destinationFile) throws @Override public InputStream openRepoFileInputStream(String fileName) throws IOException { - DocumentFile sourceFile = repoDocumentFile.findFile(fileName); - if (sourceFile == null) throw new FileNotFoundException(); + DocumentFile sourceFile = getDocumentFileFromFileName(fileName); + if (!sourceFile.exists()) throw new FileNotFoundException(); return context.getContentResolver().openInputStream(sourceFile.getUri()); } @@ -145,24 +176,16 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { if (!file.exists()) { throw new FileNotFoundException("File " + file + " does not exist"); } - - /* Delete existing file. */ - DocumentFile existingFile = repoDocumentFile.findFile(fileName); - if (existingFile != null) { - existingFile.delete(); - } - - /* Create new file. */ - DocumentFile destinationFile = repoDocumentFile.createFile("text/*", fileName); - - if (destinationFile == null) { - throw new IOException("Failed creating " + fileName + " in " + repoUri); + DocumentFile destinationFile = getDocumentFileFromFileName(fileName); + if (!destinationFile.exists()) { + if (fileName.contains("/")) { + throw new UnsupportedOperationException("Invalid book name. (Creating files in " + + "folders is not supported.)"); + } + repoDocumentFile.createFile("text/*", fileName); } + OutputStream out = context.getContentResolver().openOutputStream(destinationFile.getUri()); - Uri uri = destinationFile.getUri(); - - /* Write file content to uri. */ - OutputStream out = context.getContentResolver().openOutputStream(uri); try { MiscUtils.writeFileToStream(file, out); } finally { @@ -174,7 +197,7 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { String rev = String.valueOf(destinationFile.lastModified()); long mtime = System.currentTimeMillis(); - return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), uri, rev, mtime); + return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), destinationFile.getUri(), rev, mtime); } @Override diff --git a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java index 14e320207..8520277b4 100644 --- a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java +++ b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java @@ -48,7 +48,7 @@ public static Map getAll(Context context, List b /* Set repo books. */ for (VersionedRook book: versionedRooks) { - String fileName = BookName.getFileName(context, book.getUri()); + String fileName = BookName.getFileName(book.getRepoUri(), book.getUri()); String name = BookName.fromFileName(fileName).getName(); BookNamesake pair = namesakes.get(name); From 4a29c1d5c8aaf4afab5a82c2ca99ab3878a80b5b Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Fri, 5 Jul 2024 22:54:28 +0200 Subject: [PATCH 08/73] Handle non-"content" repo file URLs better --- app/src/main/java/com/orgzly/android/BookName.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/orgzly/android/BookName.java b/app/src/main/java/com/orgzly/android/BookName.java index 0c3244b27..f358b9f75 100644 --- a/app/src/main/java/com/orgzly/android/BookName.java +++ b/app/src/main/java/com/orgzly/android/BookName.java @@ -63,12 +63,15 @@ public static String getFileName(Context context, Uri uri) { } public static String getFileName(Uri repoUri, Uri fileUri) { + /* The content:// repository type requires special handling */ if ("content".equals(repoUri.getScheme())) { String repoUriLastSegment = repoUri.toString().replaceAll("^.*/", ""); String repoRootUriSegment = repoUri + "/document/" + repoUriLastSegment + "%2F"; return Uri.decode(fileUri.toString().replace(repoRootUriSegment, "")); } else { - return fileUri.toString().replace(repoUri.toString(), ""); + // Just return the fileUri stripped of the repoUri (if present), and stripped of any + // leading / (if present). + return fileUri.toString().replace(repoUri.toString(), "").replaceFirst("^/", ""); } } From f3d7bb4623fbb3f1fb6b046e39ec68b43b3b2978 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 6 Jul 2024 01:32:42 +0200 Subject: [PATCH 09/73] Add a few tests for WebDAV repos This is mainly in preparation for adding subfolder support. --- app/build.gradle | 4 + .../java/com/orgzly/android/TestUtils.java | 15 ++ .../orgzly/android/repos/WebdavRepoTest.kt | 159 ++++++++++++++++++ .../com/orgzly/android/repos/WebdavRepo.kt | 5 + 4 files changed, 183 insertions(+) create mode 100644 app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt diff --git a/app/build.gradle b/app/build.gradle index 543ad2dcd..093feb55d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -57,7 +57,11 @@ android { debug { buildConfigField "boolean", "LOG_DEBUG", "true" + // Set BuildConfig fields used by tests buildConfigField "String", "DROPBOX_REFRESH_TOKEN", gradle.ext.appProperties.getProperty("dropbox.refresh_token", '""') + buildConfigField "String", "WEBDAV_REPO_URL", gradle.ext.appProperties.getProperty("webdav.repo_url", '""') + buildConfigField "String", "WEBDAV_USERNAME", gradle.ext.appProperties.getProperty("webdav.username", '""') + buildConfigField "String", "WEBDAV_PASSWORD", gradle.ext.appProperties.getProperty("webdav.password", '""') } } diff --git a/app/src/androidTest/java/com/orgzly/android/TestUtils.java b/app/src/androidTest/java/com/orgzly/android/TestUtils.java index 07eb863a3..5dac0138b 100644 --- a/app/src/androidTest/java/com/orgzly/android/TestUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/TestUtils.java @@ -46,12 +46,21 @@ public SyncRepo repoInstance(RepoType type, String url) { return dataRepository.getRepoInstance(13, type, url); } + public SyncRepo repoInstance(RepoType type, String url, Long id) { + return dataRepository.getRepoInstance(id, type, url); + } + public Repo setupRepo(RepoType type, String url) { long id = dataRepository.createRepo(new RepoWithProps(new Repo(0, type, url))); return dataRepository.getRepo(id); } + public Repo setupRepo(RepoType type, String url, Map props) { + long id = dataRepository.createRepo(new RepoWithProps(new Repo(0, type, url), props)); + return dataRepository.getRepo(id); + } + public void deleteRepo(String url) { Repo repo = dataRepository.getRepo(url); if (repo != null) { @@ -180,4 +189,10 @@ public void dropboxTestPreflight() throws JSONException { mockSerializedDbxCredential.put("app_key", BuildConfig.DROPBOX_APP_KEY); AppPreferences.dropboxSerializedCredential(App.getAppContext(), mockSerializedDbxCredential.toString()); } + + public void webdavTestPreflight() { + Assume.assumeTrue(BuildConfig.WEBDAV_REPO_URL.length() > 0); + Assume.assumeTrue(BuildConfig.WEBDAV_USERNAME.length() > 0); + Assume.assumeTrue(BuildConfig.WEBDAV_PASSWORD.length() > 0); + } } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt new file mode 100644 index 000000000..ee032fb16 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -0,0 +1,159 @@ +package com.orgzly.android.repos + +import com.orgzly.BuildConfig +import com.orgzly.android.BookName +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.BookView +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.util.MiscUtils +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import java.io.File +import java.io.IOException +import java.util.UUID + +class WebdavRepoTest : OrgzlyTest() { + + @Before + @Throws(Exception::class) + override fun setUp() { + super.setUp() + testUtils.webdavTestPreflight() + } + + @JvmField + @Rule + var exceptionRule: ExpectedException = ExpectedException.none() + + @Test + fun testUrl() { + val repo = setupRepo() + Assert.assertEquals( + "webdav:/dir", testUtils.repoInstance(RepoType.WEBDAV, "webdav:/dir", repo.id).uri.toString() + ) + } + + @Test + fun testSyncingUrlWithTrailingSlash() { + testUtils.setupRepo(RepoType.WEBDAV, randomUrl() + "/", repoProps) + Assert.assertNotNull(testUtils.sync()) + } + + @Test + fun testRenameBook() { + val repo = setupRepo() + val repoUriString = repo.url + testUtils.setupBook("booky", "") + testUtils.sync() + var bookView: BookView? = dataRepository.getBookView("booky") + Assert.assertEquals(repoUriString, bookView!!.linkRepo!!.url) + Assert.assertEquals(repoUriString, bookView.syncedTo!!.getRepoUri().toString()) + Assert.assertEquals("$repoUriString/booky.org", bookView.syncedTo!!.getUri().toString()) + dataRepository.renameBook(bookView, "booky-renamed") + bookView = dataRepository.getBookView("booky-renamed") + Assert.assertEquals(repoUriString, bookView!!.linkRepo!!.url) + Assert.assertEquals(repoUriString, bookView.syncedTo!!.getRepoUri().toString()) + Assert.assertEquals( + "$repoUriString/booky-renamed.org", + bookView.syncedTo!!.getUri().toString() + ) + } + + @Test + @Throws(Exception::class) + fun testIgnoreRulePreventsLinkingBook() { + val repo = setupRepo() + val webdavRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) as WebdavRepo + testUtils.sync() // To ensure the remote directory exists + uploadFileToRepo(webdavRepo, RepoIgnoreNode.IGNORE_FILE, "*.org") + testUtils.setupBook("booky", "") + exceptionRule.expect(IOException::class.java) + exceptionRule.expectMessage("matches a rule in .orgzlyignore") + testUtils.syncOrThrow() + } + + @Test + @Throws(Exception::class) + fun testIgnoreRulePreventsLoadingBook() { + val repo = setupRepo() + val webdavRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) as WebdavRepo + testUtils.sync() // To ensure the remote directory exists + + // Create two .org files + uploadFileToRepo(webdavRepo, "ignored.org", "1 2 3") + uploadFileToRepo(webdavRepo, "notignored.org", "1 2 3") + // Create .orgzlyignore + uploadFileToRepo(webdavRepo, RepoIgnoreNode.IGNORE_FILE, "ignored.org") + testUtils.sync() + val bookViews = dataRepository.getBooks() + Assert.assertEquals(1, bookViews.size.toLong()) + Assert.assertEquals("notignored", bookViews[0].book.name) + } + + @Test + @Throws(Exception::class) + fun testIgnoreRulePreventsRenamingBook() { + val repo = setupRepo() + val webdavRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) as WebdavRepo + testUtils.sync() // To ensure the remote directory exists + uploadFileToRepo(webdavRepo, RepoIgnoreNode.IGNORE_FILE, "badname*") + testUtils.setupBook("goodname", "") + testUtils.sync() + var bookView: BookView? = dataRepository.getBookView("goodname") + dataRepository.renameBook(bookView!!, "badname") + bookView = dataRepository.getBooks()[0] + Assert.assertTrue( + bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") + ) + } + + @Test + @Throws(IOException::class) + fun testFileRename() { + val repo = setupRepo() + val syncRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) + Assert.assertNotNull(syncRepo) + Assert.assertEquals(0, syncRepo.books.size.toLong()) + val file = File.createTempFile("notebook.", ".org") + MiscUtils.writeStringToFile("1 2 3", file) + val vrook = syncRepo.storeBook(file, file.name) + file.delete() + Assert.assertEquals(1, syncRepo.books.size.toLong()) + syncRepo.renameBook(vrook.getUri(), "notebook-renamed") + Assert.assertEquals(1, syncRepo.books.size.toLong()) + Assert.assertEquals( + syncRepo.uri.toString() + "/notebook-renamed.org", + syncRepo.books[0].getUri().toString() + ) + Assert.assertEquals( + "notebook-renamed.org", + BookName.getInstance(context, syncRepo.books[0]).fileName + ) + } + + @Throws(IOException::class) + private fun uploadFileToRepo(repo: WebdavRepo, fileName: String, fileContents: String) { + val tmpFile = File.createTempFile("abc", null) + MiscUtils.writeStringToFile(fileContents, tmpFile) + repo.uploadFile(tmpFile, fileName) + tmpFile.delete() + } + + private fun randomUrl(): String { + return BuildConfig.WEBDAV_REPO_URL + WEBDAV_TEST_DIR + "/" + UUID.randomUUID().toString() + } + + private fun setupRepo(): Repo { + return testUtils.setupRepo(RepoType.WEBDAV, randomUrl(), repoProps) + } + + companion object { + private const val WEBDAV_TEST_DIR = "/orgzly-android-tests" + private val repoProps: MutableMap = mutableMapOf( + WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, + WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index da62353a2..d5ff0e924 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -212,6 +212,11 @@ class WebdavRepo( return sardine.list(fileUrl).first().toVersionedRook() } + fun uploadFile(file: File, fileName: String) { + val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + sardine.put(fileUrl, file, null) + } + override fun renameBook(from: Uri, name: String?): VersionedRook { val destUrl = UriUtils.getUriForNewName(from, name).toUrl() sardine.move(from.toUrl(), destUrl) From 8654ff7a52a2134ccd6c55cabfe882ce6de7abae Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 11 Jul 2024 11:03:06 +0200 Subject: [PATCH 10/73] Remove unused argument from utility method signature --- .../android/espresso/BookPrefaceTest.kt | 4 +- .../android/espresso/BooksSortOrderTest.kt | 4 +- .../espresso/CreatedAtPropertyTest.java | 8 +-- .../com/orgzly/android/espresso/MiscTest.java | 4 +- .../android/espresso/NoteFragmentTest.kt | 8 +-- .../android/espresso/SettingsChangeTest.java | 8 +-- .../espresso/SettingsFragmentTest.java | 52 +++++++++---------- .../android/espresso/SshKeyCreationTest.kt | 10 ++-- .../orgzly/android/espresso/SyncingTest.java | 16 +++--- .../android/espresso/util/EspressoUtils.java | 6 +-- .../util/ScreenshotsTakingNotATest.kt | 4 +- 11 files changed, 62 insertions(+), 62 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt index 8ed959efd..c9e61188d 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt @@ -84,8 +84,8 @@ class BookPrefaceTest : OrgzlyTest() { private fun setPrefaceSetting(@StringRes id: Int) { onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_preface_in_book", R.string.preface_in_book) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.preface_in_book) onView(withText(id)).perform(click()) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt index f88468432..f3d546c08 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt @@ -60,8 +60,8 @@ class BooksSortOrderTest : OrgzlyTest() { private fun setBooksSortOrder(@StringRes id: Int) { onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_notebooks_sort_order", R.string.sort_order) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.sort_order) onData(hasToString(context.getString(id))).perform(click()) pressBack() pressBack() diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java index 93d88d82c..79791febb 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java @@ -136,8 +136,8 @@ public void testNewNote() { private void enableCreatedAt() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_is_created_at_added", R.string.use_created_at_property); + clickSetting(R.string.sync); + clickSetting(R.string.use_created_at_property); onView(withText(R.string.yes)).perform(click()); pressBack(); pressBack(); @@ -145,8 +145,8 @@ private void enableCreatedAt() { private void changeCreatedAtProperty(String propName) { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_created_at_property", R.string.created_at_property); + clickSetting(R.string.sync); + clickSetting(R.string.created_at_property); onView(instanceOf(EditText.class)).perform(replaceTextCloseKeyboard(propName)); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.yes)).perform(click()); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java index f3ab040de..1c7ba9bf6 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java @@ -108,8 +108,8 @@ public void testClearDatabaseWithFragmentsInBackStack() { onView(allOf(withText("book-two"), isDisplayed())).perform(click()); onView(withText("Note #2.")).perform(click()); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_app", R.string.app); - clickSetting("pref_key_clear_database", R.string.clear_database); + clickSetting(R.string.app); + clickSetting(R.string.clear_database); onView(withText(R.string.ok)).perform(click()); pressBack(); pressBack(); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt index 8a0d73b20..236b3b14d 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt @@ -419,8 +419,8 @@ class NoteFragmentTest : OrgzlyTest() { /* Change lowest priority to A. */ onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_min_priority", R.string.lowest_priority) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.lowest_priority) onData(hasToString(containsString("A"))).perform(click()) pressBack() pressBack() @@ -431,8 +431,8 @@ class NoteFragmentTest : OrgzlyTest() { /* Change lowest priority to C. */ onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_min_priority", R.string.lowest_priority) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.lowest_priority) onData(hasToString(containsString("C"))).perform(click()) pressBack() pressBack() diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java index 6ed707392..b2861cf18 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java @@ -86,8 +86,8 @@ public void testDisplayedContentInBook() { .check(matches(allOf(withText(containsString("Content for [a-1]")), isDisplayed()))); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_is_notes_content_displayed_in_list", R.string.display_content); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.display_content); pressBack(); pressBack(); @@ -96,8 +96,8 @@ public void testDisplayedContentInBook() { private void setDefaultPriority(String priority) { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_default_priority", R.string.default_priority); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.default_priority); onData(hasToString(containsString(priority))).perform(click()); pressBack(); pressBack(); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java index afffa075e..6241cc7a3 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java @@ -39,16 +39,16 @@ public void setUp() throws Exception { @Test public void testImportingGettingStartedFromGettingStartedNotebook() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_app", R.string.app); - clickSetting("pref_key_reload_getting_started", R.string.reload_getting_started); + clickSetting(R.string.app); + clickSetting(R.string.reload_getting_started); pressBack(); pressBack(); onView(withId(R.id.fragment_books_view_flipper)).check(matches(isDisplayed())); onView(allOf(withText(R.string.getting_started_notebook_name), isDisplayed())).perform(click()); onView(withId(R.id.fragment_book_view_flipper)).check(matches(isDisplayed())); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_app", R.string.app); - clickSetting("pref_key_reload_getting_started", R.string.reload_getting_started); + clickSetting(R.string.app); + clickSetting(R.string.reload_getting_started); pressBack(); pressBack(); onView(withId(R.id.fragment_book_view_flipper)).check(matches(isDisplayed())); @@ -60,15 +60,15 @@ public void testImportingGettingStartedFromGettingStartedNotebook() { public void testAddingNewTodoKeywordInSettingsAndChangingStateToItForNewNote() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO AAA BBB CCC")); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.not_now)).perform(click()); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.state); onData(hasToString(containsString("CCC"))).perform(click()); } @@ -77,14 +77,14 @@ public void testAddingNewTodoKeywordInSettingsAndChangingStateToItForNewNote() { public void testAddingNewTodoKeywordInSettingsNewNoteShouldHaveDefaultState() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO CCC")); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.not_now)).perform(click()); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.state); onData(hasToString(containsString("NOTE"))).perform(click()); } @@ -94,8 +94,8 @@ public void testStateSummaryAfterNoStates() { AppPreferences.states(context, "|"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO")); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.not_now)).perform(click()); @@ -106,8 +106,8 @@ public void testStateSummaryAfterNoStates() { public void testStatesDuplicateDetectedIgnoringCase() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO NEXT next")); @@ -121,8 +121,8 @@ public void testNewNoteDefaultStateIsInitiallyVisibleInSummary() { AppPreferences.newNoteState(context, "BBB"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.state); onView(withText("BBB")).check(matches(isDisplayed())); } @@ -133,8 +133,8 @@ public void testNewNoteDefaultStateIsSetInitially() { AppPreferences.newNoteState(context, "BBB"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.state); onData(hasToString(containsString("BBB"))).perform(click()); } @@ -145,11 +145,11 @@ public void testDefaultPriorityUpdateOnLowestPriorityChange() { AppPreferences.minPriority(context, "E"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_min_priority", R.string.lowest_priority); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.lowest_priority); onData(hasToString(containsString("B"))).perform(click()); - clickSetting("pref_key_default_priority", R.string.default_priority); + clickSetting(R.string.default_priority); onData(hasToString("B")).check(matches(isChecked())); } @@ -159,11 +159,11 @@ public void testLowestPriorityUpdateOnDefaultPriorityChange() { AppPreferences.minPriority(context, "E"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_default_priority", R.string.default_priority); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.default_priority); onData(hasToString(containsString("X"))).perform(click()); - clickSetting("pref_key_min_priority", R.string.lowest_priority); + clickSetting(R.string.lowest_priority); onData(hasToString("X")).check(matches(isChecked())); } @@ -171,8 +171,8 @@ public void testLowestPriorityUpdateOnDefaultPriorityChange() { public void testLowercaseStateConvertedToUppercase() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO NEXT wait")); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt index 62cda5c23..0d230f386 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt @@ -40,13 +40,13 @@ class SshKeyCreationTest(private val param: Parameter) : OrgzlyTest() { Assume.assumeFalse(BuildConfig.IS_GIT_REMOVED); ActivityScenario.launch(MainActivity::class.java).use { EspressoUtils.onActionItemClick(R.id.activity_action_settings, R.string.settings) - EspressoUtils.clickSetting(null, R.string.app) - EspressoUtils.clickSetting(null, R.string.developer_options) - EspressoUtils.clickSetting(null, R.string.git_repository_type) + EspressoUtils.clickSetting(R.string.app) + EspressoUtils.clickSetting(R.string.developer_options) + EspressoUtils.clickSetting(R.string.git_repository_type) pressBack() pressBack() - EspressoUtils.clickSetting(null, R.string.sync) - EspressoUtils.clickSetting(null, R.string.ssh_keygen_preference_title) + EspressoUtils.clickSetting(R.string.sync) + EspressoUtils.clickSetting(R.string.ssh_keygen_preference_title) onView(withText(param.keyType)).perform(click()) onView(withText(R.string.ssh_keygen_generate)).perform(click()) getInstrumentation().waitForIdleSync() diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index 26bd9dd90..e244ff9dc 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -107,10 +107,10 @@ public void testAutoSyncIsTriggeredAfterCreatingNote() { // Set preference onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("prefs_screen_auto_sync", R.string.auto_sync); - clickSetting("pref_key_auto_sync", R.string.auto_sync); - clickSetting("pref_key_auto_sync_on_note_create", R.string.pref_title_sync_after_note_create); + clickSetting(R.string.sync); + clickSetting(R.string.auto_sync); + clickSetting(R.string.auto_sync); + clickSetting(R.string.pref_title_sync_after_note_create); pressBack(); pressBack(); pressBack(); @@ -679,8 +679,8 @@ public void testSettingLinkToRenamedRepo() throws JSONException { /* Rename repository. */ onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_repos", R.string.repos_preference_title); + clickSetting(R.string.sync); + clickSetting(R.string.repos_preference_title); onListItem(0).perform(click()); onView(withId(R.id.activity_repo_dropbox_directory)).perform(replaceTextCloseKeyboard("repo-b")); onView(withId(R.id.fab)).perform(click()); // Repo done @@ -731,8 +731,8 @@ public void testRenamingReposRemovesLinksWhatUsedThem() throws JSONException { /* Rename all repositories. */ onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_repos", R.string.repos_preference_title); + clickSetting(R.string.sync); + clickSetting(R.string.repos_preference_title); onListItem(0).perform(click()); onView(withId(R.id.activity_repo_dropbox_directory)).perform(replaceTextCloseKeyboard("repo-1")); onView(withId(R.id.fab)).perform(click()); // Repo done diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index cc401eaba..e660e63f6 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -306,7 +306,7 @@ public static void onActionItemClick(int id, int resourceId) { } } - public static void clickSetting(String key, int title) { + public static void clickSetting(int title) { onView(withId(R.id.recycler_view)) .perform(RecyclerViewActions.actionOnItem( hasDescendant(withText(title)), click())); @@ -323,8 +323,8 @@ public static void settingsSetDoneKeywords(String keywords) { private static void settingsSetKeywords(int viewId, String keywords) { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(viewId)).perform(replaceTextCloseKeyboard(keywords)); onView(withText(android.R.string.ok)).perform(click()); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt index 7adc581f4..81ddf0f4e 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt @@ -169,8 +169,8 @@ class ScreenshotsTakingNotATest : OrgzlyTest() { fun settings() { startActivity(SettingsActivity::class.java) - clickSetting("", R.string.sync) - clickSetting("", R.string.repositories) + clickSetting(R.string.sync) + clickSetting(R.string.repositories) takeScreenshot("repos.png") } From 6997755c0b799063ad591fa0d782fb98cfd74439 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 7 Jul 2024 11:52:41 +0200 Subject: [PATCH 11/73] Add tests for ContentRepo --- .../android/espresso/ContentRepoTest.kt | 238 ++++++++++++++++++ .../orgzly/android/espresso/SyncingTest.java | 1 - .../com/orgzly/android/util/MiscUtils.java | 21 +- 3 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt new file mode 100644 index 000000000..5693204ef --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -0,0 +1,238 @@ +package com.orgzly.android.espresso + +import android.net.Uri +import android.os.Build +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObjectNotFoundException +import androidx.test.uiautomator.UiSelector +import com.orgzly.R +import com.orgzly.android.BookName +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.BookView +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu +import com.orgzly.android.espresso.util.EspressoUtils.onBook +import com.orgzly.android.espresso.util.EspressoUtils.waitId +import com.orgzly.android.repos.ContentRepo +import com.orgzly.android.repos.RepoIgnoreNode +import com.orgzly.android.repos.RepoType +import com.orgzly.android.ui.main.MainActivity +import com.orgzly.android.ui.repos.ReposActivity +import com.orgzly.android.util.MiscUtils +import org.hamcrest.CoreMatchers.endsWith +import org.hamcrest.core.AllOf.allOf +import org.junit.After +import org.junit.Assert +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import java.io.FileNotFoundException +import java.io.IOException + + +class ContentRepoTest : OrgzlyTest() { + + private var repoDirName = "orgzly-local-dir-repo-test" + private lateinit var encodedRepoDirName: String + private lateinit var documentTreeSegment: String + private lateinit var treeDocumentFileUrl: String + private lateinit var repo: Repo + private lateinit var syncRepo: ContentRepo + private lateinit var repoUri: Uri + + @Before + @Throws(Exception::class) + override fun setUp() { + super.setUp() + } + + @After + @Throws(Exception::class) + override fun tearDown() { + super.tearDown() + DocumentFile.fromTreeUri(context, Uri.parse(treeDocumentFileUrl))?.delete() + } + + @Rule + @JvmField + var exceptionRule: ExpectedException = ExpectedException.none() + + @Test + @Throws(IOException::class) + fun testStoringFile() { + setupContentRepo() + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "booky.org") + } finally { + tmpFile.delete() + } + val books = syncRepo.books + Assert.assertEquals(1, books.size.toLong()) + Assert.assertEquals("booky", BookName.getInstance(context, books[0]).name) + Assert.assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) + Assert.assertEquals(repo.url, books[0].repoUri.toString()) + Assert.assertEquals(repo.url + documentTreeSegment + "booky.org", books[0].uri.toString()) + } + + @Test + @Throws(IOException::class) + fun testExtension() { + setupContentRepo() + MiscUtils.writeStringToDocumentFile("Notebook content 1", "01.txt", repoUri) + MiscUtils.writeStringToDocumentFile("Notebook content 2", "02.o", repoUri) + MiscUtils.writeStringToDocumentFile("Notebook content 3", "03.org", repoUri) + val books = syncRepo.books + Assert.assertEquals(1, books.size.toLong()) + Assert.assertEquals("03", BookName.getInstance(context, books[0]).name) + Assert.assertEquals("03.org", BookName.getInstance(context, books[0]).fileName) + Assert.assertEquals(repo.id, books[0].repoId) + Assert.assertEquals(repo.url, books[0].repoUri.toString()) + Assert.assertEquals(repo.url + documentTreeSegment + "03.org", books[0].uri.toString()) + } + + @Test + fun testRenameBook() { + setupContentRepo() + testUtils.setupBook("booky", "") + testUtils.sync() + var bookView: BookView? = dataRepository.getBookView("booky") + Assert.assertEquals(repo.url, bookView!!.linkRepo!!.url) + Assert.assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + Assert.assertEquals( + repo.url + documentTreeSegment + "booky.org", + bookView.syncedTo!!.uri.toString() + ) + dataRepository.renameBook(bookView, "booky-renamed") + bookView = dataRepository.getBookView("booky-renamed") + Assert.assertEquals(repo.url, bookView!!.linkRepo!!.url) + Assert.assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + Assert.assertEquals( + repo.url + documentTreeSegment + "booky-renamed.org", + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + @Throws(FileNotFoundException::class) + fun testSyncWithDirectoryContainingPercent() { + repoDirName = "space separated" + setupContentRepo() + MiscUtils.writeStringToDocumentFile("Notebook content 1", "notebook.org", repoUri) + testUtils.sync() + Assert.assertEquals(1, dataRepository.getBooks().size.toLong()) + Assert.assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) + } + + @Test + @Throws(IOException::class) + fun testIgnoreRulePreventsLoadingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupContentRepo() + + // Add .org files + MiscUtils.writeStringToDocumentFile("content", "file1.org", repoUri) + MiscUtils.writeStringToDocumentFile("content", "file2.org", repoUri) + MiscUtils.writeStringToDocumentFile("content", "file3.org", repoUri) + + // Add .orgzlyignore file + MiscUtils.writeStringToDocumentFile("*1.org\nfile3*", RepoIgnoreNode.IGNORE_FILE, repoUri) + + val books = syncRepo.books + Assert.assertEquals(1, books.size.toLong()) + Assert.assertEquals("file2", BookName.getInstance(context, books[0]).name) + Assert.assertEquals(repo.url + documentTreeSegment + "file2.org", books[0].uri.toString()) + } + + @Test + fun testIgnoreRulePreventsRenamingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupContentRepo() + + // Add .orgzlyignore file + MiscUtils.writeStringToDocumentFile("file3*", RepoIgnoreNode.IGNORE_FILE, repoUri) + // Create book and sync it + testUtils.setupBook("booky", "") + testUtils.sync() + ActivityScenario.launch(MainActivity::class.java).use { + // Rename to allowed name + onBook(0).perform(ViewActions.longClick()) + contextualToolbarOverflowMenu().perform(click()) + onView(withText(R.string.rename)).perform(click()) + onView(withId(R.id.name)).perform(*EspressoUtils.replaceTextCloseKeyboard("file1")) + onView(withText(R.string.rename)).perform(click()) + onBook(0, R.id.item_book_last_action).check( + matches(withText(endsWith("Renamed from “booky”"))) + ) + // Rename to ignored name + onBook(0).perform(ViewActions.longClick()) + contextualToolbarOverflowMenu().perform(click()) + onView(withText(R.string.rename)).perform(click()) + onView(withId(R.id.name)).perform(*EspressoUtils.replaceTextCloseKeyboard("file3")) + onView(withText(R.string.rename)).perform(click()) + onBook(0, R.id.item_book_last_action).check( + matches(withText(endsWith(context.getString( + R.string.error_file_matches_repo_ignore_rule, + RepoIgnoreNode.IGNORE_FILE))))) + } + } + + @Test + @Throws(java.lang.Exception::class) + fun testIgnoreRulePreventsLinkingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupContentRepo() + // Add .orgzlyignore file + MiscUtils.writeStringToDocumentFile("*.org", RepoIgnoreNode.IGNORE_FILE, repoUri) + testUtils.setupBook("booky", "") + exceptionRule.expect(IOException::class.java) + exceptionRule.expectMessage("matches a rule in .orgzlyignore") + testUtils.syncOrThrow() + } + + /** + * An activity is required when creating this type of repo, because of the way Android handles + * access permissions to content:// URLs. + * @throws UiObjectNotFoundException + */ + @Throws(UiObjectNotFoundException::class) + private fun setupContentRepo() { + ActivityScenario.launch(ReposActivity::class.java).use { + onView(withId(R.id.activity_repos_directory)).perform(click()) + onView(withId(R.id.activity_repo_directory_browse_button)) + .perform(click()) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + // Back in Orgzly: + onView(isRoot()).perform(waitId(R.id.fab, 5000)) + onView(allOf(withId(R.id.fab), isDisplayed())).perform(click()) + } + repo = dataRepository.getRepos()[0] + repoUri = Uri.parse(repo.url) + syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as ContentRepo + encodedRepoDirName = Uri.encode(repoDirName) + documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" + treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" + Assert.assertEquals(treeDocumentFileUrl, repo.url) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index e244ff9dc..8a6d65fe3 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -13,7 +13,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static com.orgzly.android.espresso.util.EspressoUtils.clickSetting; import static com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu; -import static com.orgzly.android.espresso.util.EspressoUtils.grantAlarmsAndRemindersPermission; import static com.orgzly.android.espresso.util.EspressoUtils.onActionItemClick; import static com.orgzly.android.espresso.util.EspressoUtils.onBook; import static com.orgzly.android.espresso.util.EspressoUtils.onListItem; diff --git a/app/src/main/java/com/orgzly/android/util/MiscUtils.java b/app/src/main/java/com/orgzly/android/util/MiscUtils.java index 4e8414016..8546cb7d8 100644 --- a/app/src/main/java/com/orgzly/android/util/MiscUtils.java +++ b/app/src/main/java/com/orgzly/android/util/MiscUtils.java @@ -1,15 +1,18 @@ package com.orgzly.android.util; +import android.content.ContentResolver; import android.net.Uri; -import com.google.android.material.textfield.TextInputLayout; import android.text.Editable; -import android.text.Html; import android.text.Spanned; import android.text.TextWatcher; import android.widget.TextView; import androidx.core.text.HtmlCompat; +import androidx.documentfile.provider.DocumentFile; + +import com.google.android.material.textfield.TextInputLayout; +import com.orgzly.android.App; import java.io.BufferedReader; import java.io.File; @@ -69,6 +72,20 @@ public static void writeStringToFile(String str, File file) throws FileNotFoundE } } + public static void writeStringToDocumentFile(String content, String displayName, Uri directory) throws IOException { + DocumentFile file = DocumentFile.fromTreeUri(App.getAppContext(), directory) + .createFile("", displayName); + ContentResolver contentResolver = App.getAppContext().getContentResolver(); + try (OutputStream out = contentResolver.openOutputStream(file.getUri())) { + if (out != null) { + out.write(content.getBytes()); + out.flush(); + } else { + throw new IOException("Failed to open output stream for writing to " + file.getUri()); + } + } + } + /** * Compares content of two text files. * @return null if files match, difference if they don't From b81c8046ea341daeaebb3c372d05972bb4a85126 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 13 Jul 2024 01:18:55 +0200 Subject: [PATCH 12/73] Add tests of loading and saving with subfolders in ContentRepo --- .../android/espresso/ContentRepoTest.kt | 138 +++++++++++++++--- .../java/com/orgzly/android/sync/SyncUtils.kt | 3 +- 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 5693204ef..9a6974e14 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -5,8 +5,10 @@ import android.os.Build import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -24,6 +26,8 @@ import com.orgzly.android.db.entity.Repo import com.orgzly.android.espresso.util.EspressoUtils import com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu import com.orgzly.android.espresso.util.EspressoUtils.onBook +import com.orgzly.android.espresso.util.EspressoUtils.onNoteInBook +import com.orgzly.android.espresso.util.EspressoUtils.sync import com.orgzly.android.espresso.util.EspressoUtils.waitId import com.orgzly.android.repos.ContentRepo import com.orgzly.android.repos.RepoIgnoreNode @@ -35,6 +39,7 @@ import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.core.AllOf.allOf import org.junit.After import org.junit.Assert +import org.junit.Assert.assertEquals import org.junit.Assume import org.junit.Before import org.junit.Rule @@ -83,11 +88,11 @@ class ContentRepoTest : OrgzlyTest() { tmpFile.delete() } val books = syncRepo.books - Assert.assertEquals(1, books.size.toLong()) - Assert.assertEquals("booky", BookName.getInstance(context, books[0]).name) - Assert.assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) - Assert.assertEquals(repo.url, books[0].repoUri.toString()) - Assert.assertEquals(repo.url + documentTreeSegment + "booky.org", books[0].uri.toString()) + assertEquals(1, books.size.toLong()) + assertEquals("booky", BookName.getInstance(context, books[0]).name) + assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) + assertEquals(repo.url, books[0].repoUri.toString()) + assertEquals(repo.url + documentTreeSegment + "booky.org", books[0].uri.toString()) } @Test @@ -98,12 +103,12 @@ class ContentRepoTest : OrgzlyTest() { MiscUtils.writeStringToDocumentFile("Notebook content 2", "02.o", repoUri) MiscUtils.writeStringToDocumentFile("Notebook content 3", "03.org", repoUri) val books = syncRepo.books - Assert.assertEquals(1, books.size.toLong()) - Assert.assertEquals("03", BookName.getInstance(context, books[0]).name) - Assert.assertEquals("03.org", BookName.getInstance(context, books[0]).fileName) - Assert.assertEquals(repo.id, books[0].repoId) - Assert.assertEquals(repo.url, books[0].repoUri.toString()) - Assert.assertEquals(repo.url + documentTreeSegment + "03.org", books[0].uri.toString()) + assertEquals(1, books.size.toLong()) + assertEquals("03", BookName.getInstance(context, books[0]).name) + assertEquals("03.org", BookName.getInstance(context, books[0]).fileName) + assertEquals(repo.id, books[0].repoId) + assertEquals(repo.url, books[0].repoUri.toString()) + assertEquals(repo.url + documentTreeSegment + "03.org", books[0].uri.toString()) } @Test @@ -112,17 +117,17 @@ class ContentRepoTest : OrgzlyTest() { testUtils.setupBook("booky", "") testUtils.sync() var bookView: BookView? = dataRepository.getBookView("booky") - Assert.assertEquals(repo.url, bookView!!.linkRepo!!.url) - Assert.assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - Assert.assertEquals( + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertEquals( repo.url + documentTreeSegment + "booky.org", bookView.syncedTo!!.uri.toString() ) dataRepository.renameBook(bookView, "booky-renamed") bookView = dataRepository.getBookView("booky-renamed") - Assert.assertEquals(repo.url, bookView!!.linkRepo!!.url) - Assert.assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - Assert.assertEquals( + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertEquals( repo.url + documentTreeSegment + "booky-renamed.org", bookView.syncedTo!!.uri.toString() ) @@ -135,8 +140,8 @@ class ContentRepoTest : OrgzlyTest() { setupContentRepo() MiscUtils.writeStringToDocumentFile("Notebook content 1", "notebook.org", repoUri) testUtils.sync() - Assert.assertEquals(1, dataRepository.getBooks().size.toLong()) - Assert.assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) + assertEquals(1, dataRepository.getBooks().size.toLong()) + assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) } @Test @@ -154,9 +159,9 @@ class ContentRepoTest : OrgzlyTest() { MiscUtils.writeStringToDocumentFile("*1.org\nfile3*", RepoIgnoreNode.IGNORE_FILE, repoUri) val books = syncRepo.books - Assert.assertEquals(1, books.size.toLong()) - Assert.assertEquals("file2", BookName.getInstance(context, books[0]).name) - Assert.assertEquals(repo.url + documentTreeSegment + "file2.org", books[0].uri.toString()) + assertEquals(1, books.size.toLong()) + assertEquals("file2", BookName.getInstance(context, books[0]).name) + assertEquals(repo.url + documentTreeSegment + "file2.org", books[0].uri.toString()) } @Test @@ -205,6 +210,93 @@ class ContentRepoTest : OrgzlyTest() { testUtils.syncOrThrow() } + @Test + fun testLoadNotebookFromSubfolder() { + setupContentRepo() + // Create subfolder + val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + // Write org file to subfolder + MiscUtils.writeStringToDocumentFile("content", "book1.org", subfolder?.uri) + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book1", books[0].book.name) + assertEquals(repo.url + documentTreeSegment + "subfolder1%2Fbook1.org", books[0].syncedTo?.uri.toString()) + } + + @Test + fun testIgnoreFileInSubfolder() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupContentRepo() + // Add .orgzlyignore file + MiscUtils.writeStringToDocumentFile("subfolder1/book1.org", RepoIgnoreNode.IGNORE_FILE, repoUri) + // Create subfolder + val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + // Write 2 org files to subfolder + MiscUtils.writeStringToDocumentFile("content", "book1.org", subfolder?.uri) + MiscUtils.writeStringToDocumentFile("content", "book2.org", subfolder?.uri) + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book2", books[0].book.name) + } + + @Test + fun testUnIgnoreSingleFileInSubfolder() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupContentRepo() + // Add .orgzlyignore file + MiscUtils.writeStringToDocumentFile("subfolder1/**\n!subfolder1/book2.org", RepoIgnoreNode.IGNORE_FILE, repoUri) + // Create subfolder + val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + // Write 2 org files to subfolder + MiscUtils.writeStringToDocumentFile("content", "book1.org", subfolder?.uri) + MiscUtils.writeStringToDocumentFile("content", "book2.org", subfolder?.uri) + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book2", books[0].book.name) + } + + @Test + fun testUpdateBookInSubfolder() { + setupContentRepo() + // Create subfolder + val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + // Create org file in subfolder + MiscUtils.writeStringToDocumentFile("* DONE Heading 1", "book1.org", subfolder?.uri) + + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + + ActivityScenario.launch(MainActivity::class.java).use { + // Modify book + onBook(0).perform(click()) + onNoteInBook(1).perform(longClick()) + onView(withId(R.id.toggle_state)).perform(click()) + pressBack() + pressBack() + sync() + onBook(0, R.id.item_book_last_action).check( + matches(withText(endsWith("Saved to content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test"))) + ) + // Delete notebook from Orgzly and reload it to verify that our change was successfully written + onBook(0).perform(longClick()) + contextualToolbarOverflowMenu().perform(click()) + onView(withText(R.string.delete)).perform(click()) + onView(withText(R.string.delete)).perform(click()) + } + + testUtils.sync() + testUtils.assertBook("subfolder1/book1", "* TODO Heading 1\n") + } + /** * An activity is required when creating this type of repo, because of the way Android handles * access permissions to content:// URLs. @@ -233,6 +325,6 @@ class ContentRepoTest : OrgzlyTest() { encodedRepoDirName = Uri.encode(repoDirName) documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" - Assert.assertEquals(treeDocumentFileUrl, repo.url) + assertEquals(treeDocumentFileUrl, repo.url) } } \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt index 714592a11..6031f13f2 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt @@ -1,5 +1,6 @@ package com.orgzly.android.sync +import androidx.core.net.toUri import com.orgzly.BuildConfig import com.orgzly.android.App import com.orgzly.android.BookFormat @@ -166,7 +167,7 @@ object SyncUtils { BookSyncStatus.BOOK_WITH_LINK_LOCAL_MODIFIED -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.getFileName(App.getAppContext(), namesake.book.syncedTo!!.uri) + fileName = BookName.getFileName(repoUrl.toUri(), namesake.book.syncedTo!!.uri) dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } From d6bd2e482e06523acb3152cb98642eb7edb72134 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 14 Jul 2024 16:21:56 +0200 Subject: [PATCH 13/73] ContentRepo: Allow creating new directories --- .../com/orgzly/android/repos/ContentRepo.java | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index 83c0b0fa4..cd4f7d97a 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -20,7 +20,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Objects; /** * Using DocumentFile, for devices running Lollipop or later. @@ -132,11 +134,6 @@ private List walkFileTree() { return result; } - public static String getContentRepoUriRootSegment(String repoUri) { - String repoUriLastSegment = repoUri.replaceAll("^.*/", ""); - return repoUri + "/document/" + repoUriLastSegment + "%2F"; - } - private DocumentFile getDocumentFileFromFileName(String fileName) { String fullUri = repoDocumentFile.getUri() + Uri.encode("/" + fileName); return DocumentFile.fromSingleUri(context, Uri.parse(fullUri)); @@ -179,10 +176,13 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { DocumentFile destinationFile = getDocumentFileFromFileName(fileName); if (!destinationFile.exists()) { if (fileName.contains("/")) { - throw new UnsupportedOperationException("Invalid book name. (Creating files in " + - "folders is not supported.)"); + DocumentFile destinationDir = ensureSubDirectoriesExist(fileName); + assert destinationDir != null; + destinationFile = destinationDir.createFile("text/*", + Objects.requireNonNull(Uri.parse(fileName).getLastPathSegment())); + } else { + repoDocumentFile.createFile("text/*", fileName); } - repoDocumentFile.createFile("text/*", fileName); } OutputStream out = context.getContentResolver().openOutputStream(destinationFile.getUri()); @@ -200,10 +200,23 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), destinationFile.getUri(), rev, mtime); } + private DocumentFile ensureSubDirectoriesExist(String fileName) { + List levels = new ArrayList<>(Arrays.asList(fileName.split("/"))); + DocumentFile parentDir = repoDocumentFile; + while (levels.size() > 1) { + String currentDirName = levels.remove(0); + assert parentDir != null; + if (parentDir.findFile(currentDirName) == null) { + parentDir = parentDir.createDirectory(currentDirName); + } + } + return parentDir; + } + @Override public VersionedRook renameBook(Uri from, String name) throws IOException { DocumentFile fromDocFile = DocumentFile.fromSingleUri(context, from); - BookName bookName = BookName.fromFileName(fromDocFile.getName()); + BookName bookName = BookName.fromFileName(BookName.getFileName(repoUri, from)); String newFileName = BookName.fileName(name, bookName.getFormat()); /* Check if document already exists. */ From a6ba07d2a827eaea53df90c222a625e2f5d0a11c Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 15 Jul 2024 20:35:13 +0200 Subject: [PATCH 14/73] ContentRepo: Renaming across subdirectories now works --- .../com/orgzly/android/repos/ContentRepo.java | 97 ++++++++++++++----- app/src/main/res/values/strings.xml | 2 + 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index cd4f7d97a..4cab0d8df 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -9,6 +9,7 @@ import androidx.documentfile.provider.DocumentFile; import com.orgzly.BuildConfig; +import com.orgzly.R; import com.orgzly.android.BookName; import com.orgzly.android.db.entity.Repo; import com.orgzly.android.util.LogUtils; @@ -176,10 +177,8 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { DocumentFile destinationFile = getDocumentFileFromFileName(fileName); if (!destinationFile.exists()) { if (fileName.contains("/")) { - DocumentFile destinationDir = ensureSubDirectoriesExist(fileName); - assert destinationDir != null; - destinationFile = destinationDir.createFile("text/*", - Objects.requireNonNull(Uri.parse(fileName).getLastPathSegment())); + DocumentFile destinationDir = ensureDirectoryHierarchy(fileName); + destinationFile = destinationDir.createFile("text/*", Uri.parse(fileName).getLastPathSegment()); } else { repoDocumentFile.createFile("text/*", fileName); } @@ -200,37 +199,91 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), destinationFile.getUri(), rev, mtime); } - private DocumentFile ensureSubDirectoriesExist(String fileName) { - List levels = new ArrayList<>(Arrays.asList(fileName.split("/"))); - DocumentFile parentDir = repoDocumentFile; + /** + * Given a relative path, ensures that all directory levels are created unless they already + * exist. + * @param relativePath Path relative to the repository root directory + * @return The DocumentFile object of the leaf directory where the file should be placed. + */ + private DocumentFile ensureDirectoryHierarchy(String relativePath) { + List levels = new ArrayList<>(Arrays.asList(relativePath.split("/"))); + DocumentFile currentDir = repoDocumentFile; while (levels.size() > 1) { - String currentDirName = levels.remove(0); - assert parentDir != null; - if (parentDir.findFile(currentDirName) == null) { - parentDir = parentDir.createDirectory(currentDirName); + String nextDirName = levels.remove(0); + DocumentFile nextDir = currentDir.findFile(nextDirName); + if (nextDir == null) { + currentDir = currentDir.createDirectory(nextDirName); + } else { + currentDir = nextDir; } } - return parentDir; + return currentDir; } + /** + * Allows renaming a notebook to any subdirectory (indicated with a "/"), ensuring that all + * required subdirectories are created, if they do not already exist. Note that the file is + * moved, but no "abandoned" directories are deleted. + * @param oldUri + * @param newName + * @return + * @throws IOException + */ @Override - public VersionedRook renameBook(Uri from, String name) throws IOException { - DocumentFile fromDocFile = DocumentFile.fromSingleUri(context, from); - BookName bookName = BookName.fromFileName(BookName.getFileName(repoUri, from)); - String newFileName = BookName.fileName(name, bookName.getFormat()); + public VersionedRook renameBook(Uri oldUri, String newName) throws IOException { + DocumentFile oldDocFile = DocumentFile.fromSingleUri(context, oldUri); + long mtime = oldDocFile.lastModified(); + String rev = String.valueOf(mtime); + String oldDocFileName = oldDocFile.getName(); + Uri oldDirUri = Uri.parse( + oldUri.toString().replace( + Uri.encode("/" + oldDocFile.getName()), + "" + ) + ); + BookName oldBookName = BookName.fromFileName(BookName.getFileName(repoUri, oldUri)); + String newRelativePath = BookName.fileName(newName, oldBookName.getFormat()); + String newDocFileName = Uri.parse(newRelativePath).getLastPathSegment(); + DocumentFile newDir; + Uri newUri = oldUri; + + if (newName.contains("/")) { + newDir = ensureDirectoryHierarchy(newName); + } else { + newDir = repoDocumentFile; + } - /* Check if document already exists. */ - DocumentFile existingFile = repoDocumentFile.findFile(newFileName); + /* Abort if destination file already exists. */ + DocumentFile existingFile = newDir.findFile(newDocFileName); if (existingFile != null) { throw new IOException("File at " + existingFile.getUri() + " already exists"); } - Uri newUri = DocumentsContract.renameDocument(context.getContentResolver(), from, newFileName); + if (!newDir.getUri().toString().equals(oldDirUri.toString())) { + // File should be moved to a different directory + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + newUri = DocumentsContract.moveDocument( + context.getContentResolver(), + oldUri, + oldDirUri, + newDir.getUri() + ); + } else { + throw new IllegalArgumentException( + context.getString(R.string.moving_between_subdirectories_requires_api_24)); + } + } - long mtime = fromDocFile.lastModified(); - String rev = String.valueOf(mtime); + if (!Objects.equals(newDocFileName, oldDocFileName)) { + // File should be renamed + newUri = DocumentsContract.renameDocument( + context.getContentResolver(), + newUri, + newDocFileName + ); + } - return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), newUri, rev, mtime); + return new VersionedRook(repoId, RepoType.DOCUMENT, repoUri, newUri, rev, mtime); } @Override diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e67fec24d..c26eeac1d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -782,4 +782,6 @@ Notebook has no repository link Loaded from %s Saved to %s + + Renaming notebook to a different subdirectory requires Android 7 or higher From 8711adfa8cd09ca3226e27b689ef5b1834711198 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 15 Jul 2024 21:26:56 +0200 Subject: [PATCH 15/73] Polish ContentRepoTest --- .../orgzly/android/espresso/ContentRepoTest.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 9a6974e14..d0058cb41 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -214,16 +214,16 @@ class ContentRepoTest : OrgzlyTest() { fun testLoadNotebookFromSubfolder() { setupContentRepo() // Create subfolder - val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("a folder") // Write org file to subfolder - MiscUtils.writeStringToDocumentFile("content", "book1.org", subfolder?.uri) + MiscUtils.writeStringToDocumentFile("content", "a book.org", subfolder?.uri) testUtils.sync() val books = dataRepository.getBooks() assertEquals(1, books.size.toLong()) - assertEquals("subfolder1/book1", books[0].book.name) - assertEquals(repo.url + documentTreeSegment + "subfolder1%2Fbook1.org", books[0].syncedTo?.uri.toString()) + assertEquals("a folder/a book", books[0].book.name) + assertEquals(repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org", books[0].syncedTo?.uri.toString()) } @Test @@ -268,9 +268,9 @@ class ContentRepoTest : OrgzlyTest() { fun testUpdateBookInSubfolder() { setupContentRepo() // Create subfolder - val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("folder one") // Create org file in subfolder - MiscUtils.writeStringToDocumentFile("* DONE Heading 1", "book1.org", subfolder?.uri) + MiscUtils.writeStringToDocumentFile("* DONE Heading 1", "book one.org", subfolder?.uri) testUtils.sync() assertEquals(1, dataRepository.getBooks().size.toLong()) @@ -284,7 +284,7 @@ class ContentRepoTest : OrgzlyTest() { pressBack() sync() onBook(0, R.id.item_book_last_action).check( - matches(withText(endsWith("Saved to content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test"))) + matches(withText(endsWith("Saved to content://com.android.externalstorage.documents/tree/primary%3A$repoDirName"))) ) // Delete notebook from Orgzly and reload it to verify that our change was successfully written onBook(0).perform(longClick()) @@ -294,7 +294,8 @@ class ContentRepoTest : OrgzlyTest() { } testUtils.sync() - testUtils.assertBook("subfolder1/book1", "* TODO Heading 1\n") + assertEquals(1, dataRepository.getBooks().size.toLong()) + testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") } /** From aa3b230e67f028191f205346fcb8fe5cf4238497 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 00:31:36 +0200 Subject: [PATCH 16/73] Add tests of renaming in ContentRepo subfolders --- .../android/espresso/ContentRepoTest.kt | 108 +++++++++++++++++- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index d0058cb41..01f01fe67 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -6,7 +6,6 @@ import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack -import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.assertion.ViewAssertions.matches @@ -32,14 +31,15 @@ import com.orgzly.android.espresso.util.EspressoUtils.waitId import com.orgzly.android.repos.ContentRepo import com.orgzly.android.repos.RepoIgnoreNode import com.orgzly.android.repos.RepoType +import com.orgzly.android.sync.BookSyncStatus import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.core.AllOf.allOf import org.junit.After -import org.junit.Assert import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Assume import org.junit.Before import org.junit.Rule @@ -176,7 +176,7 @@ class ContentRepoTest : OrgzlyTest() { testUtils.sync() ActivityScenario.launch(MainActivity::class.java).use { // Rename to allowed name - onBook(0).perform(ViewActions.longClick()) + onBook(0).perform(longClick()) contextualToolbarOverflowMenu().perform(click()) onView(withText(R.string.rename)).perform(click()) onView(withId(R.id.name)).perform(*EspressoUtils.replaceTextCloseKeyboard("file1")) @@ -185,7 +185,7 @@ class ContentRepoTest : OrgzlyTest() { matches(withText(endsWith("Renamed from “booky”"))) ) // Rename to ignored name - onBook(0).perform(ViewActions.longClick()) + onBook(0).perform(longClick()) contextualToolbarOverflowMenu().perform(click()) onView(withText(R.string.rename)).perform(click()) onView(withId(R.id.name)).perform(*EspressoUtils.replaceTextCloseKeyboard("file3")) @@ -298,6 +298,106 @@ class ContentRepoTest : OrgzlyTest() { testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") } + @Test + fun testRenameBookFromRootToSubfolder() { + setupContentRepo() + testUtils.setupBook("booky", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") + assertTrue(dataRepository.getBookView("a/b")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(dataRepository.getBook("a/b")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + assertEquals( + "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fa%2Fb.org", + syncRepo.books[0].uri.toString() + ) + assertEquals(1, dataRepository.getBooks().size.toLong()) + } + + @Test + fun testRenameBookFromSubfolderToRoot() { + setupContentRepo() + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") + assertTrue(dataRepository.getBookView("booky")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(dataRepository.getBook("booky")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + assertEquals( + "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fbooky.org", + syncRepo.books[0].uri.toString() + ) + assertEquals(1, dataRepository.getBooks().size.toLong()) + } + + @Test + fun testRenameBookNewSubfolderSameLeafName() { + setupContentRepo() + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") + assertTrue(dataRepository.getBookView("b/b")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(dataRepository.getBook("b/b")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + assertEquals( + "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fb%2Fb.org", + syncRepo.books[0].uri.toString() + ) + assertEquals(1, dataRepository.getBooks().size.toLong()) + } + + @Test + fun testRenameBookNewSubfolderAndLeafName() { + setupContentRepo() + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") + assertTrue(dataRepository.getBookView("b/c")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(dataRepository.getBook("b/c")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + assertEquals( + "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fb%2Fc.org", + syncRepo.books[0].uri.toString() + ) + assertEquals(1, dataRepository.getBooks().size.toLong()) + } + + @Test + fun testRenameBookSameSubfolderNewLeafName() { + setupContentRepo() + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") + assertTrue(dataRepository.getBookView("a/c")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(dataRepository.getBook("a/c")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + assertEquals( + "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fa%2Fc.org", + syncRepo.books[0].uri.toString() + ) + assertEquals(1, dataRepository.getBooks().size.toLong()) + } + + @Test + fun testRenameBookToExistingFileName() { + setupContentRepo() + testUtils.setupBook("a", "") + testUtils.sync() + // Create "unsynced" file in repo + MiscUtils.writeStringToDocumentFile("", "b.org", DocumentFile.fromTreeUri(context, repoUri)!!.uri) + dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: File at content://")) + } + + @Test + fun testRenameBookToExistingBookName() { + setupContentRepo() + testUtils.setupBook("a", "") + testUtils.setupBook("b", "") + dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) + } + /** * An activity is required when creating this type of repo, because of the way Android handles * access permissions to content:// URLs. From 70bbd1659ae6ebc2751bd67ff1f9d204493990ae Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 01:44:04 +0200 Subject: [PATCH 17/73] Loading from subfolder in GitRepo now works --- app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt | 2 ++ app/src/main/java/com/orgzly/android/sync/SyncUtils.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt b/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt index a63aab93e..d3d7dfa7e 100644 --- a/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt @@ -9,4 +9,6 @@ interface TwoWaySyncRepo { fun syncBook(uri: Uri, current: VersionedRook?, fromDB: File): TwoWaySyncResult fun tryPushIfHeadDiffersFromRemote() + + fun getUri(): Uri } \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt index 6031f13f2..1f47e02d9 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt @@ -204,7 +204,7 @@ object SyncUtils { newRook = newRook1 // We only need to write it if syncback is needed if (loadFile != null) { - val fileName = BookName.getFileName(App.getAppContext(), newRook.uri) + val fileName = BookName.getFileName(repo.getUri(), newRook.uri) val bookName = BookName.fromFileName(fileName) if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Loading from file '$loadFile'") dataRepository.loadBookFromFile( From 5fe48abe9d40872bb99cbe233b5d3da9c26ada3c Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 01:51:36 +0200 Subject: [PATCH 18/73] GitRepo: Updating existing book in subfolder now works --- .../com/orgzly/android/data/DataRepository.kt | 8 ++++---- .../orgzly/android/git/GitFileSynchronizer.java | 8 ++++---- .../java/com/orgzly/android/repos/GitRepo.java | 10 +++++----- .../java/com/orgzly/android/sync/SyncUtils.kt | 16 ++++++++-------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index 5127448cc..40027bcb9 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -103,7 +103,7 @@ class DataRepository @Inject constructor( val book = getBookView(bookId) ?: throw IOException(resources.getString(R.string.book_does_not_exist_anymore)) - val fileName: String = BookName.getFileName(context, book) + val repositoryPath: String = BookName.getFileName(context, book) try { /* Prefer link. */ @@ -113,7 +113,7 @@ class DataRepository @Inject constructor( BookAction.Type.PROGRESS, resources.getString(R.string.force_saving_to_uri, repoEntity))) - saveBookToRepo(repoEntity, fileName, book, BookFormat.ORG) + saveBookToRepo(repoEntity, repositoryPath, book, BookFormat.ORG) val savedBook = getBookView(bookId) @@ -144,7 +144,7 @@ class DataRepository @Inject constructor( @Throws(IOException::class) fun saveBookToRepo( repoEntity: Repo, - fileName: String, + repositoryPath: String, bookView: BookView, @Suppress("UNUSED_PARAMETER") format: BookFormat) { @@ -158,7 +158,7 @@ class DataRepository @Inject constructor( NotesOrgExporter(this).exportBook(bookView.book, tmpFile) /* Upload to repo. */ - uploadedBook = repo.storeBook(tmpFile, fileName) + uploadedBook = repo.storeBook(tmpFile, repositoryPath) } finally { /* Delete temporary file. */ diff --git a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java index cfa0fa4fe..ede1d01d8 100644 --- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java +++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java @@ -400,13 +400,13 @@ public void addAndCommitNewFile(File sourceFile, String repositoryPath) throws I } private void updateAndCommitFile( - File sourceFile, String repositoryPath) throws IOException { - File destinationFile = repoDirectoryFile(repositoryPath); + File sourceFile, String fileName) throws IOException { + File destinationFile = repoDirectoryFile(fileName); MiscUtils.copyFile(sourceFile, destinationFile); try { - git.add().addFilepattern(repositoryPath).call(); + git.add().addFilepattern(fileName).call(); if (!gitRepoIsClean()) - commit(String.format("Orgzly update: %s", repositoryPath)); + commit(String.format("Orgzly update: %s", fileName)); } catch (GitAPIException e) { throw new IOException("Failed to commit changes."); } diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 4bd759889..4fae7c6b5 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -185,16 +185,16 @@ public boolean isAutoSyncSupported() { return true; } - public VersionedRook storeBook(File file, String fileName) throws IOException { - File destination = synchronizer.repoDirectoryFile(fileName); + public VersionedRook storeBook(File file, String repositoryPath) throws IOException { + File destination = synchronizer.repoDirectoryFile(repositoryPath); if (destination.exists()) { - synchronizer.updateAndCommitExistingFile(file, fileName); + synchronizer.updateAndCommitExistingFile(file, repositoryPath); } else { - synchronizer.addAndCommitNewFile(file, fileName); + synchronizer.addAndCommitNewFile(file, repositoryPath); } synchronizer.tryPush(); - return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(fileName).build()); + return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(repositoryPath).build()); } private RevWalk walk() { diff --git a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt index 1f47e02d9..7ad61d2b1 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt @@ -89,7 +89,7 @@ object SyncUtils { fun syncNamesake(dataRepository: DataRepository, namesake: BookNamesake): BookAction { val repoEntity: Repo? val repoUrl: String - val fileName: String + val repositoryPath: String var bookAction: BookAction? = null // FIXME: This is a pretty nasty hack that completely circumvents the existing code path @@ -157,26 +157,26 @@ object SyncUtils { BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO -> { repoEntity = dataRepository.getRepos().iterator().next() repoUrl = repoEntity.url - fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) + repositoryPath = BookName.fileName(namesake.book.book.name, BookFormat.ORG) /* Set repo link before saving to ensure repo ignore rules are checked */ dataRepository.setLink(namesake.book.book.id, repoEntity) - dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + dataRepository.saveBookToRepo(repoEntity, repositoryPath, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } BookSyncStatus.BOOK_WITH_LINK_LOCAL_MODIFIED -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.getFileName(repoUrl.toUri(), namesake.book.syncedTo!!.uri) - dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + repositoryPath = BookName.getFileName(repoUrl.toUri(), namesake.book.syncedTo!!.uri) + dataRepository.saveBookToRepo(repoEntity, repositoryPath, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } BookSyncStatus.ONLY_BOOK_WITH_LINK -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) - dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + repositoryPath = BookName.fileName(namesake.book.book.name, BookFormat.ORG) + dataRepository.saveBookToRepo(repoEntity, repositoryPath, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } } @@ -192,7 +192,7 @@ object SyncUtils { var noNewMergeConflicts = true // If there are only local changes, the GitRepo.syncBook method is overly complicated. if (namesake.status == BookSyncStatus.BOOK_WITH_LINK_LOCAL_MODIFIED) { - val fileName = BookName.getFileName(App.getAppContext(), namesake.book.syncedTo!!.uri) + val fileName = BookName.getFileName(repo.getUri(), namesake.book.syncedTo!!.uri) dataRepository.saveBookToRepo(namesake.book.linkRepo!!, fileName, namesake.book, BookFormat.ORG) } else { val dbFile = dataRepository.getTempBookFile() From fff075d4725526fbbc765c779c483bc312818c3b Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 02:22:01 +0200 Subject: [PATCH 19/73] GitRepo: Renaming across subfolders now works --- .../android/git/GitFileSynchronizer.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java index ede1d01d8..64be79de4 100644 --- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java +++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java @@ -7,6 +7,8 @@ import android.net.Uri; import android.util.Log; +import androidx.documentfile.provider.DocumentFile; + import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.App; @@ -34,6 +36,8 @@ import java.io.IOException; import java.io.InputStream; import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.List; import java.util.TimeZone; @@ -396,9 +400,20 @@ public void addAndCommitNewFile(File sourceFile, String repositoryPath) throws I if (destinationFile.exists()) { throw new IOException("Can't add new file " + repositoryPath + " that already exists."); } + ensureDirectoryHierarchy(repositoryPath); updateAndCommitFile(sourceFile, repositoryPath); } + private void ensureDirectoryHierarchy(String repositoryPath) throws IOException { + if (repositoryPath.contains("/")) { + File targetDir = repoDirectoryFile(repositoryPath).getParentFile(); + if (!(targetDir.exists() || targetDir.mkdirs())) { + throw new IOException("The directory " + targetDir.getAbsolutePath() + " could " + + "not be created"); + } + } + } + private void updateAndCommitFile( File sourceFile, String fileName) throws IOException { File destinationFile = repoDirectoryFile(fileName); @@ -490,8 +505,9 @@ public boolean renameFileInRepo(String oldFileName, String newFileName) throws I File newFile = repoDirectoryFile(newFileName); // Abort if destination file exists if (newFile.exists()) { - throw new IOException("Can't add new file " + newFileName + " that already exists."); + throw new IOException("Repository file " + newFileName + " already exists."); } + ensureDirectoryHierarchy(newFileName); // Copy the file contents and add it to the index MiscUtils.copyFile(oldFile, newFile); try { From 619fde27c6eb574723a0df3664f9260d36e8724b Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 17:48:12 +0200 Subject: [PATCH 20/73] Fix incorrect mtime stored for VersionedRooks in ContentRepo It seems completely wrong to store the current system time as "mtime", instead of the actual "last modified" filesystem metadata. --- app/src/main/java/com/orgzly/android/repos/ContentRepo.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index 4cab0d8df..f56da3bf8 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -193,8 +193,8 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { } } - String rev = String.valueOf(destinationFile.lastModified()); - long mtime = System.currentTimeMillis(); + long mtime = destinationFile.lastModified(); + String rev = String.valueOf(mtime); return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), destinationFile.getUri(), rev, mtime); } From 84248d4ef3d3760bfbe344c44eb38737483d96fb Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 17:56:00 +0200 Subject: [PATCH 21/73] Started work on a generic test suite for SyncRepos --- .../android/espresso/ContentRepoTest.kt | 2 +- .../com/orgzly/android/repos/SyncRepoTest.kt | 174 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 01f01fe67..de5b9fdbe 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -404,7 +404,7 @@ class ContentRepoTest : OrgzlyTest() { * @throws UiObjectNotFoundException */ @Throws(UiObjectNotFoundException::class) - private fun setupContentRepo() { + fun setupContentRepo() { ActivityScenario.launch(ReposActivity::class.java).use { onView(withId(R.id.activity_repos_directory)).perform(click()) onView(withId(R.id.activity_repo_directory_browse_button)) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt new file mode 100644 index 000000000..6591920b7 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -0,0 +1,174 @@ +package com.orgzly.android.repos + +import android.net.Uri +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.git.GitPreferencesFromRepoPrefs +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.prefs.RepoPreferences +import com.orgzly.android.repos.RepoType.* +import com.orgzly.android.ui.repos.ReposActivity +import org.eclipse.jgit.api.Git +import org.hamcrest.core.AllOf +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.io.File +import java.nio.file.Path +import kotlin.io.path.createTempDirectory + +@RunWith(value = Parameterized::class) +class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { + + private val testDirectoryName = "orgzly-android-tests" + private lateinit var repo: Repo + private lateinit var syncRepo: SyncRepo + // Used by GitRepo + private lateinit var gitWorkingTree: File + private lateinit var gitBareRepoPath: Path + // used by ContentRepo + private lateinit var documentTreeSegment: String + private lateinit var treeDocumentFileUrl: String + + data class Parameter(val repoType: RepoType) + + companion object { + @JvmStatic + @Parameterized.Parameters + fun data(): Collection { + return listOf( + Parameter(repoType = GIT), + Parameter(repoType = WEBDAV), + Parameter(repoType = DOCUMENT), + ) + } + } + + override fun tearDown() { + super.tearDown() + when (repo.type) { + GIT -> tearDownGitRepo() + MOCK -> TODO() + DROPBOX -> TODO() + DIRECTORY -> TODO() + DOCUMENT -> tearDownContentRepo() + WEBDAV -> tearDownWebdavRepo() + } + } + + @JvmField + @Rule + var exceptionRule: ExpectedException = ExpectedException.none() + + @Test + fun testSyncNewBookWithoutLinkAndOneRepo() { + setupSyncRepo(param.repoType) + testUtils.setupBook("book 1", "content") + testUtils.sync() + val bookView = dataRepository.getBooks()[0] + Assert.assertEquals(repo.url, bookView.linkRepo?.url) + Assert.assertEquals(1, syncRepo.books.size) + Assert.assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) + Assert.assertEquals( + context.getString(R.string.sync_status_saved, repo.url), + bookView.book.lastAction!!.message + ) + val expectedUriString = when (param.repoType) { + GIT -> "/book 1.org" + MOCK -> TODO() + DROPBOX -> TODO() + DIRECTORY -> TODO() + DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$testDirectoryName/document/primary%3A$testDirectoryName%2Fbook%201.org" + WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived%40gmail.com/$testDirectoryName/book 1.org" + } + Assert.assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) + } + + private fun setupSyncRepo(repoType: RepoType) { + when (repoType) { + GIT -> setupGitRepo() + MOCK -> TODO() + DROPBOX -> TODO() + DIRECTORY -> TODO() + DOCUMENT -> setupContentRepo() + WEBDAV -> setupWebdavRepo() + } + } + + private fun setupContentRepo() { + ActivityScenario.launch(ReposActivity::class.java).use { + Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) + .perform(ViewActions.click()) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() + mDevice.findObject(UiSelector().text("Folder name")).text = testDirectoryName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + // Back in Orgzly: + Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) + Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } + repo = dataRepository.getRepos()[0] + syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as ContentRepo + val encodedRepoDirName = Uri.encode(testDirectoryName) + documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" + treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" + Assert.assertEquals(treeDocumentFileUrl, repo.url) + } + + private fun tearDownContentRepo() { + DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri())!!.delete() + } + + private fun setupWebdavRepo() { + testUtils.webdavTestPreflight() + val repoProps: MutableMap = mutableMapOf( + WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, + WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) + repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + testDirectoryName, repoProps) + syncRepo = dataRepository.getRepoInstance(repo.id, WEBDAV, repo.url) as WebdavRepo + } + + private fun tearDownWebdavRepo() { + syncRepo.delete(repo.url.toUri()) + } + + private fun setupGitRepo() { + gitBareRepoPath = createTempDirectory() + Git.init().setBare(true).setDirectory(gitBareRepoPath.toFile()).call() + AppPreferences.gitIsEnabled(context, true) + repo = testUtils.setupRepo(GIT, gitBareRepoPath.toFile().toUri().toString()) + val repoPreferences = RepoPreferences(context, repo.id, repo.url.toUri()) + val gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) + gitWorkingTree = File(gitPreferences.repositoryFilepath()) + gitWorkingTree.mkdirs() + GitRepo.ensureRepositoryExists(gitPreferences, true, null) + syncRepo = dataRepository.getRepoInstance(repo.id, GIT, repo.url) as GitRepo + } + + private fun tearDownGitRepo() { + testUtils.deleteRepo(repo.url) + gitWorkingTree.deleteRecursively() + gitBareRepoPath.toFile()!!.deleteRecursively() + } +} \ No newline at end of file From a9798d9c27c48f0b5d7ed647a92a2352a598bdc8 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 16 Jul 2024 23:29:18 +0200 Subject: [PATCH 22/73] SyncRepoTest: Support more repo types and .orgzlyignore rules --- .../orgzly/android/repos/DropboxRepoTest.java | 2 +- .../com/orgzly/android/repos/GitRepoTest.kt | 33 +++----- .../com/orgzly/android/repos/SyncRepoTest.kt | 84 ++++++++++++++----- .../orgzly/android/repos/WebdavRepoTest.kt | 17 ++-- .../orgzly/android/repos/DropboxClient.java | 19 +++++ .../com/orgzly/android/repos/DropboxRepo.java | 8 ++ 6 files changed, 109 insertions(+), 54 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java index 38a9ba6e0..f83fcaa5c 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java @@ -140,7 +140,7 @@ public void testDropboxFileRename() throws IOException { assertEquals("notebook-renamed.org", BookName.getInstance(context, repo.getBooks().get(0)).getFileName()); } - private void uploadFileToRepo(Uri repoUri, String fileName, String fileContents) throws IOException { + static public void uploadFileToRepo(Uri repoUri, String fileName, String fileContents) throws IOException { DropboxClient client = new DropboxClient(App.getAppContext(), 0); File tmpFile = File.createTempFile("abc", null); MiscUtils.writeStringToFile(fileContents, tmpFile); diff --git a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt index 3f4b1a260..cd2347cd5 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt @@ -3,7 +3,6 @@ package com.orgzly.android.repos import android.net.Uri import android.os.Build import androidx.core.net.toUri -import com.orgzly.R import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo @@ -63,18 +62,6 @@ class GitRepoTest : OrgzlyTest() { bareRepoPath.toFile()?.deleteRecursively() } - @Test - fun testSyncNewBookWithoutLinkAndOneRepo() { - testUtils.setupBook("book1", "book content") - testUtils.sync() - val bookView = dataRepository.getBooks()[0] - assertEquals(repoUri.toString(), bookView.linkRepo?.url) - assertEquals(1, syncRepo.books.size) - assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) - assertEquals(context.getString(R.string.sync_status_saved, repo.url), bookView.book.lastAction!!.message) - assertEquals("/book1.org", bookView.syncedTo!!.uri.toString()) - } - @Test fun testIgnoredFilesInRepoAreNotLoaded() { Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) @@ -83,7 +70,7 @@ class GitRepoTest : OrgzlyTest() { ignoredbook.org ignored-*.org """.trimIndent() - addAndCommitIgnoreFile(ignoreFileContents) + addAndCommitIgnoreFile(synchronizer, ignoreFileContents) // Add multiple files to repo for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -105,7 +92,7 @@ class GitRepoTest : OrgzlyTest() { *.org !notignored.org """.trimIndent() - addAndCommitIgnoreFile(ignoreFileContents) + addAndCommitIgnoreFile(synchronizer, ignoreFileContents) // Add multiple files to repo for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -122,7 +109,7 @@ class GitRepoTest : OrgzlyTest() { @Test fun testIgnoreRulePreventsLinkingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile("*.org") + addAndCommitIgnoreFile(synchronizer, "*.org") testUtils.setupBook("booky", "") exceptionRule.expect(IOException::class.java) exceptionRule.expectMessage("matches a rule in .orgzlyignore") @@ -132,7 +119,7 @@ class GitRepoTest : OrgzlyTest() { @Test fun testIgnoreRulePreventsRenamingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile("badname*") + addAndCommitIgnoreFile(synchronizer, "badname*") testUtils.setupBook("goodname", "") testUtils.sync() var bookView: BookView? = dataRepository.getBookView("goodname") @@ -143,10 +130,12 @@ class GitRepoTest : OrgzlyTest() { ) } - private fun addAndCommitIgnoreFile(contents: String) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile(contents, tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, RepoIgnoreNode.IGNORE_FILE) - tmpFile.delete() + companion object { + fun addAndCommitIgnoreFile(synchronizer: GitFileSynchronizer, contents: String) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile(contents, tmpFile) + synchronizer.addAndCommitNewFile(tmpFile, RepoIgnoreNode.IGNORE_FILE) + tmpFile.delete() + } } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 6591920b7..a3c9c7f92 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -15,11 +15,18 @@ import com.orgzly.R import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.Repo import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.git.GitFileSynchronizer import com.orgzly.android.git.GitPreferencesFromRepoPrefs import com.orgzly.android.prefs.AppPreferences import com.orgzly.android.prefs.RepoPreferences -import com.orgzly.android.repos.RepoType.* +import com.orgzly.android.repos.RepoType.DIRECTORY +import com.orgzly.android.repos.RepoType.DOCUMENT +import com.orgzly.android.repos.RepoType.DROPBOX +import com.orgzly.android.repos.RepoType.GIT +import com.orgzly.android.repos.RepoType.MOCK +import com.orgzly.android.repos.RepoType.WEBDAV import com.orgzly.android.ui.repos.ReposActivity +import com.orgzly.android.util.MiscUtils import org.eclipse.jgit.api.Git import org.hamcrest.core.AllOf import org.junit.Assert @@ -41,6 +48,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // Used by GitRepo private lateinit var gitWorkingTree: File private lateinit var gitBareRepoPath: Path + private lateinit var gitFileSynchronizer: GitFileSynchronizer // used by ContentRepo private lateinit var documentTreeSegment: String private lateinit var treeDocumentFileUrl: String @@ -49,9 +57,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { companion object { @JvmStatic - @Parameterized.Parameters + @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( + Parameter(repoType = DROPBOX), Parameter(repoType = GIT), Parameter(repoType = WEBDAV), Parameter(repoType = DOCUMENT), @@ -61,13 +70,15 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { override fun tearDown() { super.tearDown() - when (repo.type) { - GIT -> tearDownGitRepo() - MOCK -> TODO() - DROPBOX -> TODO() - DIRECTORY -> TODO() - DOCUMENT -> tearDownContentRepo() - WEBDAV -> tearDownWebdavRepo() + if (this::repo.isInitialized) { + when (repo.type) { + GIT -> tearDownGitRepo() + MOCK -> TODO() + DROPBOX -> tearDownDropboxRepo() + DIRECTORY -> TODO() + DOCUMENT -> tearDownContentRepo() + WEBDAV -> tearDownWebdavRepo() + } } } @@ -77,7 +88,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testSyncNewBookWithoutLinkAndOneRepo() { - setupSyncRepo(param.repoType) + setupSyncRepo(param.repoType, null) testUtils.setupBook("book 1", "content") testUtils.sync() val bookView = dataRepository.getBooks()[0] @@ -91,7 +102,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { val expectedUriString = when (param.repoType) { GIT -> "/book 1.org" MOCK -> TODO() - DROPBOX -> TODO() + DROPBOX -> "dropbox:/orgzly-android-tests/book%201.org" DIRECTORY -> TODO() DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$testDirectoryName/document/primary%3A$testDirectoryName%2Fbook%201.org" WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived%40gmail.com/$testDirectoryName/book 1.org" @@ -99,18 +110,32 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { Assert.assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) } - private fun setupSyncRepo(repoType: RepoType) { + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { when (repoType) { - GIT -> setupGitRepo() + GIT -> setupGitRepo(ignoreRules) MOCK -> TODO() - DROPBOX -> TODO() + DROPBOX -> setupDropboxRepo(ignoreRules) DIRECTORY -> TODO() - DOCUMENT -> setupContentRepo() - WEBDAV -> setupWebdavRepo() + DOCUMENT -> setupContentRepo(ignoreRules) + WEBDAV -> setupWebdavRepo(ignoreRules) + } + } + + private fun setupDropboxRepo(ignoreRules: String?) { + testUtils.dropboxTestPreflight() + syncRepo = testUtils.repoInstance(DROPBOX, "dropbox:/$testDirectoryName") + repo = testUtils.setupRepo(DROPBOX, syncRepo.uri.toString()) + if (ignoreRules != null) { + DropboxRepoTest.uploadFileToRepo(syncRepo.uri, RepoIgnoreNode.IGNORE_FILE, ignoreRules) } } - private fun setupContentRepo() { + private fun tearDownDropboxRepo() { + val dropboxRepo = syncRepo as DropboxRepo + dropboxRepo.deleteDirectory(syncRepo.uri) + } + + private fun setupContentRepo(ignoreRules: String?) { ActivityScenario.launch(ReposActivity::class.java).use { Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) .perform(ViewActions.click()) @@ -129,31 +154,40 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { .perform(ViewActions.click()) } repo = dataRepository.getRepos()[0] - syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as ContentRepo + syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) val encodedRepoDirName = Uri.encode(testDirectoryName) documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" Assert.assertEquals(treeDocumentFileUrl, repo.url) + if (ignoreRules != null) { + MiscUtils.writeStringToDocumentFile( + ignoreRules, + RepoIgnoreNode.IGNORE_FILE, syncRepo.uri + ) + } } private fun tearDownContentRepo() { DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri())!!.delete() } - private fun setupWebdavRepo() { + private fun setupWebdavRepo(ignoreRules: String?) { testUtils.webdavTestPreflight() val repoProps: MutableMap = mutableMapOf( WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + testDirectoryName, repoProps) - syncRepo = dataRepository.getRepoInstance(repo.id, WEBDAV, repo.url) as WebdavRepo + syncRepo = dataRepository.getRepoInstance(repo.id, WEBDAV, repo.url) + if (ignoreRules != null) { + WebdavRepoTest.uploadFileToRepo(syncRepo as WebdavRepo, RepoIgnoreNode.IGNORE_FILE, ignoreRules) + } } private fun tearDownWebdavRepo() { syncRepo.delete(repo.url.toUri()) } - private fun setupGitRepo() { + private fun setupGitRepo(ignoreRules: String?) { gitBareRepoPath = createTempDirectory() Git.init().setBare(true).setDirectory(gitBareRepoPath.toFile()).call() AppPreferences.gitIsEnabled(context, true) @@ -162,8 +196,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { val gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) gitWorkingTree = File(gitPreferences.repositoryFilepath()) gitWorkingTree.mkdirs() - GitRepo.ensureRepositoryExists(gitPreferences, true, null) - syncRepo = dataRepository.getRepoInstance(repo.id, GIT, repo.url) as GitRepo + val git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) + gitFileSynchronizer = GitFileSynchronizer(git, gitPreferences) + syncRepo = dataRepository.getRepoInstance(repo.id, GIT, repo.url) + if (ignoreRules != null) { + GitRepoTest.addAndCommitIgnoreFile(gitFileSynchronizer, ignoreRules) + } } private fun tearDownGitRepo() { diff --git a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt index ee032fb16..51f977c01 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -134,14 +134,6 @@ class WebdavRepoTest : OrgzlyTest() { ) } - @Throws(IOException::class) - private fun uploadFileToRepo(repo: WebdavRepo, fileName: String, fileContents: String) { - val tmpFile = File.createTempFile("abc", null) - MiscUtils.writeStringToFile(fileContents, tmpFile) - repo.uploadFile(tmpFile, fileName) - tmpFile.delete() - } - private fun randomUrl(): String { return BuildConfig.WEBDAV_REPO_URL + WEBDAV_TEST_DIR + "/" + UUID.randomUUID().toString() } @@ -152,8 +144,17 @@ class WebdavRepoTest : OrgzlyTest() { companion object { private const val WEBDAV_TEST_DIR = "/orgzly-android-tests" + private val repoProps: MutableMap = mutableMapOf( WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) + + @Throws(IOException::class) + fun uploadFileToRepo(repo: WebdavRepo, fileName: String, fileContents: String) { + val tmpFile = File.createTempFile("abc", null) + MiscUtils.writeStringToFile(fileContents, tmpFile) + repo.uploadFile(tmpFile, fileName) + tmpFile.delete() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index 940039415..e1729a829 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -348,4 +348,23 @@ public VersionedRook move(Uri repoUri, Uri from, Uri to) throws IOException { } } } + + public void deleteFolder(String path) throws IOException { + linkedOrThrow(); + + try { + if (dbxClient.files().getMetadata(path) instanceof FolderMetadata) { + dbxClient.files().deleteV2(path); + } else { + throw new IOException("Not a directory: " + path); + } + } catch (DbxException e) { + e.printStackTrace(); + if (e.getMessage() != null) { + throw new IOException("Failed deleting " + path + " on Dropbox: " + e.getMessage()); + } else { + throw new IOException("Failed deleting " + path + " on Dropbox: " + e); + } + } + } } diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index 74dd25a65..e25acec63 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -69,6 +69,14 @@ public void delete(Uri uri) throws IOException { client.delete(uri.getPath()); } + /** + * Intended for tests. The delete() method does not allow deleting directories. + * @param uri + */ + public void deleteDirectory(Uri uri) throws IOException { + client.deleteFolder(uri.getPath()); + } + @Override public String toString() { return repoUri.toString(); From 2fd191c657acc74577904ef5769c2a08278d3cc2 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 17 Jul 2024 15:15:53 +0200 Subject: [PATCH 23/73] SyncRepoTest: Add first ignore rules test --- .../android/espresso/ContentRepoTest.kt | 70 ++++++--------- .../orgzly/android/repos/DropboxRepoTest.java | 2 +- .../com/orgzly/android/repos/GitRepoTest.kt | 20 ++--- .../com/orgzly/android/repos/SyncRepoTest.kt | 87 ++++++++++++------- .../orgzly/android/repos/WebdavRepoTest.kt | 64 +++++++------- .../com/orgzly/android/repos/WebdavRepo.kt | 5 -- 6 files changed, 124 insertions(+), 124 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index de5b9fdbe..9f6bea3a5 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -45,6 +45,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.ExpectedException +import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -99,9 +100,9 @@ class ContentRepoTest : OrgzlyTest() { @Throws(IOException::class) fun testExtension() { setupContentRepo() - MiscUtils.writeStringToDocumentFile("Notebook content 1", "01.txt", repoUri) - MiscUtils.writeStringToDocumentFile("Notebook content 2", "02.o", repoUri) - MiscUtils.writeStringToDocumentFile("Notebook content 3", "03.org", repoUri) + writeStringToRepoFile("Notebook content 1", "01.txt") + writeStringToRepoFile("Notebook content 2", "02.o") + writeStringToRepoFile("Notebook content 3", "03.org") val books = syncRepo.books assertEquals(1, books.size.toLong()) assertEquals("03", BookName.getInstance(context, books[0]).name) @@ -138,39 +139,19 @@ class ContentRepoTest : OrgzlyTest() { fun testSyncWithDirectoryContainingPercent() { repoDirName = "space separated" setupContentRepo() - MiscUtils.writeStringToDocumentFile("Notebook content 1", "notebook.org", repoUri) + writeStringToRepoFile("Notebook content 1", "notebook.org") testUtils.sync() assertEquals(1, dataRepository.getBooks().size.toLong()) assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) } - - @Test - @Throws(IOException::class) - fun testIgnoreRulePreventsLoadingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupContentRepo() - - // Add .org files - MiscUtils.writeStringToDocumentFile("content", "file1.org", repoUri) - MiscUtils.writeStringToDocumentFile("content", "file2.org", repoUri) - MiscUtils.writeStringToDocumentFile("content", "file3.org", repoUri) - - // Add .orgzlyignore file - MiscUtils.writeStringToDocumentFile("*1.org\nfile3*", RepoIgnoreNode.IGNORE_FILE, repoUri) - - val books = syncRepo.books - assertEquals(1, books.size.toLong()) - assertEquals("file2", BookName.getInstance(context, books[0]).name) - assertEquals(repo.url + documentTreeSegment + "file2.org", books[0].uri.toString()) - } - + @Test fun testIgnoreRulePreventsRenamingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) setupContentRepo() // Add .orgzlyignore file - MiscUtils.writeStringToDocumentFile("file3*", RepoIgnoreNode.IGNORE_FILE, repoUri) + writeStringToRepoFile("file3*", RepoIgnoreNode.IGNORE_FILE) // Create book and sync it testUtils.setupBook("booky", "") testUtils.sync() @@ -203,7 +184,7 @@ class ContentRepoTest : OrgzlyTest() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) setupContentRepo() // Add .orgzlyignore file - MiscUtils.writeStringToDocumentFile("*.org", RepoIgnoreNode.IGNORE_FILE, repoUri) + writeStringToRepoFile("*.org", RepoIgnoreNode.IGNORE_FILE) testUtils.setupBook("booky", "") exceptionRule.expect(IOException::class.java) exceptionRule.expectMessage("matches a rule in .orgzlyignore") @@ -213,10 +194,8 @@ class ContentRepoTest : OrgzlyTest() { @Test fun testLoadNotebookFromSubfolder() { setupContentRepo() - // Create subfolder - val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("a folder") // Write org file to subfolder - MiscUtils.writeStringToDocumentFile("content", "a book.org", subfolder?.uri) + writeStringToRepoFile("content", "a folder/a book.org") testUtils.sync() @@ -231,12 +210,10 @@ class ContentRepoTest : OrgzlyTest() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) setupContentRepo() // Add .orgzlyignore file - MiscUtils.writeStringToDocumentFile("subfolder1/book1.org", RepoIgnoreNode.IGNORE_FILE, repoUri) - // Create subfolder - val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + writeStringToRepoFile("subfolder1/book1.org", RepoIgnoreNode.IGNORE_FILE) // Write 2 org files to subfolder - MiscUtils.writeStringToDocumentFile("content", "book1.org", subfolder?.uri) - MiscUtils.writeStringToDocumentFile("content", "book2.org", subfolder?.uri) + writeStringToRepoFile("content", "subfolder1/book1.org") + writeStringToRepoFile("content", "subfolder1/book2.org") testUtils.sync() @@ -250,12 +227,10 @@ class ContentRepoTest : OrgzlyTest() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) setupContentRepo() // Add .orgzlyignore file - MiscUtils.writeStringToDocumentFile("subfolder1/**\n!subfolder1/book2.org", RepoIgnoreNode.IGNORE_FILE, repoUri) - // Create subfolder - val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("subfolder1") + writeStringToRepoFile("subfolder1/**\n!subfolder1/book2.org", RepoIgnoreNode.IGNORE_FILE) // Write 2 org files to subfolder - MiscUtils.writeStringToDocumentFile("content", "book1.org", subfolder?.uri) - MiscUtils.writeStringToDocumentFile("content", "book2.org", subfolder?.uri) + writeStringToRepoFile("content", "subfolder1/book1.org") + writeStringToRepoFile("content", "subfolder1/book2.org") testUtils.sync() @@ -267,10 +242,8 @@ class ContentRepoTest : OrgzlyTest() { @Test fun testUpdateBookInSubfolder() { setupContentRepo() - // Create subfolder - val subfolder = DocumentFile.fromTreeUri(context, repoUri)?.createDirectory("folder one") // Create org file in subfolder - MiscUtils.writeStringToDocumentFile("* DONE Heading 1", "book one.org", subfolder?.uri) + writeStringToRepoFile("* DONE Heading 1", "folder one/book one.org") testUtils.sync() assertEquals(1, dataRepository.getBooks().size.toLong()) @@ -384,7 +357,7 @@ class ContentRepoTest : OrgzlyTest() { testUtils.setupBook("a", "") testUtils.sync() // Create "unsynced" file in repo - MiscUtils.writeStringToDocumentFile("", "b.org", DocumentFile.fromTreeUri(context, repoUri)!!.uri) + writeStringToRepoFile("", "b.org") dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: File at content://")) } @@ -398,13 +371,20 @@ class ContentRepoTest : OrgzlyTest() { assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) } + private fun writeStringToRepoFile(content: String, fileName: String) { + val tmpFile = File.createTempFile("abc", null) + MiscUtils.writeStringToFile(content, tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + /** * An activity is required when creating this type of repo, because of the way Android handles * access permissions to content:// URLs. * @throws UiObjectNotFoundException */ @Throws(UiObjectNotFoundException::class) - fun setupContentRepo() { + private fun setupContentRepo() { ActivityScenario.launch(ReposActivity::class.java).use { onView(withId(R.id.activity_repos_directory)).perform(click()) onView(withId(R.id.activity_repo_directory_browse_button)) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java index f83fcaa5c..94a0400d5 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java @@ -142,7 +142,7 @@ public void testDropboxFileRename() throws IOException { static public void uploadFileToRepo(Uri repoUri, String fileName, String fileContents) throws IOException { DropboxClient client = new DropboxClient(App.getAppContext(), 0); - File tmpFile = File.createTempFile("abc", null); + File tmpFile = File.createTempFile("orgzly-test", null); MiscUtils.writeStringToFile(fileContents, tmpFile); client.upload(tmpFile, repoUri, fileName); tmpFile.delete(); diff --git a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt index cd2347cd5..ef5b2b824 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt @@ -70,7 +70,7 @@ class GitRepoTest : OrgzlyTest() { ignoredbook.org ignored-*.org """.trimIndent() - addAndCommitIgnoreFile(synchronizer, ignoreFileContents) + addAndCommitIgnoreFile(ignoreFileContents) // Add multiple files to repo for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -92,7 +92,7 @@ class GitRepoTest : OrgzlyTest() { *.org !notignored.org """.trimIndent() - addAndCommitIgnoreFile(synchronizer, ignoreFileContents) + addAndCommitIgnoreFile(ignoreFileContents) // Add multiple files to repo for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -109,7 +109,7 @@ class GitRepoTest : OrgzlyTest() { @Test fun testIgnoreRulePreventsLinkingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile(synchronizer, "*.org") + addAndCommitIgnoreFile("*.org") testUtils.setupBook("booky", "") exceptionRule.expect(IOException::class.java) exceptionRule.expectMessage("matches a rule in .orgzlyignore") @@ -119,7 +119,7 @@ class GitRepoTest : OrgzlyTest() { @Test fun testIgnoreRulePreventsRenamingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile(synchronizer, "badname*") + addAndCommitIgnoreFile("badname*") testUtils.setupBook("goodname", "") testUtils.sync() var bookView: BookView? = dataRepository.getBookView("goodname") @@ -130,12 +130,10 @@ class GitRepoTest : OrgzlyTest() { ) } - companion object { - fun addAndCommitIgnoreFile(synchronizer: GitFileSynchronizer, contents: String) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile(contents, tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, RepoIgnoreNode.IGNORE_FILE) - tmpFile.delete() - } + private fun addAndCommitIgnoreFile(contents: String) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile(contents, tmpFile) + syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) + tmpFile.delete() } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index a3c9c7f92..b0ae536d2 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -1,6 +1,8 @@ package com.orgzly.android.repos import android.net.Uri +import android.os.Build +import android.os.SystemClock import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario @@ -27,9 +29,11 @@ import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils +import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git import org.hamcrest.core.AllOf import org.junit.Assert +import org.junit.Assume import org.junit.Rule import org.junit.Test import org.junit.rules.ExpectedException @@ -42,7 +46,7 @@ import kotlin.io.path.createTempDirectory @RunWith(value = Parameterized::class) class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { - private val testDirectoryName = "orgzly-android-tests" + private val repoDirectoryName = "orgzly-android-tests" private lateinit var repo: Repo private lateinit var syncRepo: SyncRepo // Used by GitRepo @@ -60,9 +64,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( + Parameter(repoType = WEBDAV), Parameter(repoType = DROPBOX), Parameter(repoType = GIT), - Parameter(repoType = WEBDAV), Parameter(repoType = DOCUMENT), ) } @@ -104,30 +108,54 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { MOCK -> TODO() DROPBOX -> "dropbox:/orgzly-android-tests/book%201.org" DIRECTORY -> TODO() - DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$testDirectoryName/document/primary%3A$testDirectoryName%2Fbook%201.org" - WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived%40gmail.com/$testDirectoryName/book 1.org" + DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$repoDirectoryName/document/primary%3A$repoDirectoryName%2Fbook%201.org" + WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived%40gmail.com/$repoDirectoryName/book 1.org" } Assert.assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) } + @Test + fun testIgnoreRulePreventsLoadingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + val ignoreRules = """ + ignoredbook.org + ignored-*.org + """.trimIndent() + setupSyncRepo(param.repoType, ignoreRules) + // Add multiple files to repo + for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + testUtils.sync() + Assert.assertEquals(1, syncRepo.books.size) + Assert.assertEquals(1, dataRepository.getBooks().size) + Assert.assertEquals("notignored", dataRepository.getBooks()[0].book.name) + } + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { when (repoType) { - GIT -> setupGitRepo(ignoreRules) + GIT -> setupGitRepo() MOCK -> TODO() - DROPBOX -> setupDropboxRepo(ignoreRules) + DROPBOX -> setupDropboxRepo() DIRECTORY -> TODO() - DOCUMENT -> setupContentRepo(ignoreRules) - WEBDAV -> setupWebdavRepo(ignoreRules) + DOCUMENT -> setupContentRepo() + WEBDAV -> setupWebdavRepo() + } + if (ignoreRules != null) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile(ignoreRules, tmpFile) + syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) + tmpFile.delete() } } - private fun setupDropboxRepo(ignoreRules: String?) { + private fun setupDropboxRepo() { testUtils.dropboxTestPreflight() - syncRepo = testUtils.repoInstance(DROPBOX, "dropbox:/$testDirectoryName") + syncRepo = testUtils.repoInstance(DROPBOX, "dropbox:/$repoDirectoryName") repo = testUtils.setupRepo(DROPBOX, syncRepo.uri.toString()) - if (ignoreRules != null) { - DropboxRepoTest.uploadFileToRepo(syncRepo.uri, RepoIgnoreNode.IGNORE_FILE, ignoreRules) - } } private fun tearDownDropboxRepo() { @@ -135,7 +163,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { dropboxRepo.deleteDirectory(syncRepo.uri) } - private fun setupContentRepo(ignoreRules: String?) { + private fun setupContentRepo() { ActivityScenario.launch(ReposActivity::class.java).use { Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) .perform(ViewActions.click()) @@ -144,7 +172,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // In Android file browser (Espresso cannot be used): val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - mDevice.findObject(UiSelector().text("Folder name")).text = testDirectoryName + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirectoryName mDevice.findObject(UiSelector().text("OK")).click() mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() mDevice.findObject(UiSelector().text("ALLOW")).click() @@ -155,39 +183,37 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } repo = dataRepository.getRepos()[0] syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) - val encodedRepoDirName = Uri.encode(testDirectoryName) + val encodedRepoDirName = Uri.encode(repoDirectoryName) documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" Assert.assertEquals(treeDocumentFileUrl, repo.url) - if (ignoreRules != null) { - MiscUtils.writeStringToDocumentFile( - ignoreRules, - RepoIgnoreNode.IGNORE_FILE, syncRepo.uri - ) - } } private fun tearDownContentRepo() { DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri())!!.delete() } - private fun setupWebdavRepo(ignoreRules: String?) { + private fun setupWebdavRepo() { testUtils.webdavTestPreflight() val repoProps: MutableMap = mutableMapOf( WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) - repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + testDirectoryName, repoProps) + repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + repoDirectoryName, repoProps) syncRepo = dataRepository.getRepoInstance(repo.id, WEBDAV, repo.url) - if (ignoreRules != null) { - WebdavRepoTest.uploadFileToRepo(syncRepo as WebdavRepo, RepoIgnoreNode.IGNORE_FILE, ignoreRules) - } + testUtils.sync() // Required to create the remote directory } private fun tearDownWebdavRepo() { - syncRepo.delete(repo.url.toUri()) + try { + syncRepo.delete(repo.url.toUri()) + } catch (e: SardineException) { + if (e.statusCode != 404) { + throw e + } + } } - private fun setupGitRepo(ignoreRules: String?) { + private fun setupGitRepo() { gitBareRepoPath = createTempDirectory() Git.init().setBare(true).setDirectory(gitBareRepoPath.toFile()).call() AppPreferences.gitIsEnabled(context, true) @@ -199,9 +225,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { val git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) gitFileSynchronizer = GitFileSynchronizer(git, gitPreferences) syncRepo = dataRepository.getRepoInstance(repo.id, GIT, repo.url) - if (ignoreRules != null) { - GitRepoTest.addAndCommitIgnoreFile(gitFileSynchronizer, ignoreRules) - } } private fun tearDownGitRepo() { diff --git a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt index 51f977c01..8f0bf165c 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -1,22 +1,28 @@ package com.orgzly.android.repos +import androidx.core.net.toUri import com.orgzly.BuildConfig import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo import com.orgzly.android.util.MiscUtils +import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.ExpectedException import java.io.File +import java.io.FileNotFoundException import java.io.IOException import java.util.UUID class WebdavRepoTest : OrgzlyTest() { + private val repoUriString = BuildConfig.WEBDAV_REPO_URL + "/orgzly-android-tests" + private lateinit var syncRepo: SyncRepo + @Before @Throws(Exception::class) override fun setUp() { @@ -24,13 +30,21 @@ class WebdavRepoTest : OrgzlyTest() { testUtils.webdavTestPreflight() } + @After + override fun tearDown() { + super.tearDown() + if (this::syncRepo.isInitialized) { + syncRepo.delete(syncRepo.uri) + } + } + @JvmField @Rule var exceptionRule: ExpectedException = ExpectedException.none() @Test fun testUrl() { - val repo = setupRepo() + val repo = testUtils.setupRepo(RepoType.WEBDAV, repoUriString, repoProps) Assert.assertEquals( "webdav:/dir", testUtils.repoInstance(RepoType.WEBDAV, "webdav:/dir", repo.id).uri.toString() ) @@ -38,14 +52,14 @@ class WebdavRepoTest : OrgzlyTest() { @Test fun testSyncingUrlWithTrailingSlash() { - testUtils.setupRepo(RepoType.WEBDAV, randomUrl() + "/", repoProps) + val repo = testUtils.setupRepo(RepoType.WEBDAV, "$repoUriString/", repoProps) + syncRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) Assert.assertNotNull(testUtils.sync()) } @Test fun testRenameBook() { - val repo = setupRepo() - val repoUriString = repo.url + setupRepo() testUtils.setupBook("booky", "") testUtils.sync() var bookView: BookView? = dataRepository.getBookView("booky") @@ -65,10 +79,9 @@ class WebdavRepoTest : OrgzlyTest() { @Test @Throws(Exception::class) fun testIgnoreRulePreventsLinkingBook() { - val repo = setupRepo() - val webdavRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) as WebdavRepo + setupRepo() testUtils.sync() // To ensure the remote directory exists - uploadFileToRepo(webdavRepo, RepoIgnoreNode.IGNORE_FILE, "*.org") + writeStringToRepoFile(syncRepo, "*.org", RepoIgnoreNode.IGNORE_FILE) testUtils.setupBook("booky", "") exceptionRule.expect(IOException::class.java) exceptionRule.expectMessage("matches a rule in .orgzlyignore") @@ -78,15 +91,14 @@ class WebdavRepoTest : OrgzlyTest() { @Test @Throws(Exception::class) fun testIgnoreRulePreventsLoadingBook() { - val repo = setupRepo() - val webdavRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) as WebdavRepo + setupRepo() testUtils.sync() // To ensure the remote directory exists // Create two .org files - uploadFileToRepo(webdavRepo, "ignored.org", "1 2 3") - uploadFileToRepo(webdavRepo, "notignored.org", "1 2 3") + writeStringToRepoFile(syncRepo, "1 2 3", "ignored.org") + writeStringToRepoFile(syncRepo, "1 2 3", "notignored.org") // Create .orgzlyignore - uploadFileToRepo(webdavRepo, RepoIgnoreNode.IGNORE_FILE, "ignored.org") + writeStringToRepoFile(syncRepo, "ignored.org", RepoIgnoreNode.IGNORE_FILE) testUtils.sync() val bookViews = dataRepository.getBooks() Assert.assertEquals(1, bookViews.size.toLong()) @@ -96,10 +108,9 @@ class WebdavRepoTest : OrgzlyTest() { @Test @Throws(Exception::class) fun testIgnoreRulePreventsRenamingBook() { - val repo = setupRepo() - val webdavRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) as WebdavRepo + setupRepo() testUtils.sync() // To ensure the remote directory exists - uploadFileToRepo(webdavRepo, RepoIgnoreNode.IGNORE_FILE, "badname*") + writeStringToRepoFile(syncRepo, "badname*", RepoIgnoreNode.IGNORE_FILE) testUtils.setupBook("goodname", "") testUtils.sync() var bookView: BookView? = dataRepository.getBookView("goodname") @@ -113,8 +124,7 @@ class WebdavRepoTest : OrgzlyTest() { @Test @Throws(IOException::class) fun testFileRename() { - val repo = setupRepo() - val syncRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) + setupRepo() Assert.assertNotNull(syncRepo) Assert.assertEquals(0, syncRepo.books.size.toLong()) val file = File.createTempFile("notebook.", ".org") @@ -134,26 +144,20 @@ class WebdavRepoTest : OrgzlyTest() { ) } - private fun randomUrl(): String { - return BuildConfig.WEBDAV_REPO_URL + WEBDAV_TEST_DIR + "/" + UUID.randomUUID().toString() - } - - private fun setupRepo(): Repo { - return testUtils.setupRepo(RepoType.WEBDAV, randomUrl(), repoProps) + private fun setupRepo() { + val repo = testUtils.setupRepo(RepoType.WEBDAV, repoUriString, repoProps) + syncRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) } companion object { - private const val WEBDAV_TEST_DIR = "/orgzly-android-tests" - private val repoProps: MutableMap = mutableMapOf( WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) - @Throws(IOException::class) - fun uploadFileToRepo(repo: WebdavRepo, fileName: String, fileContents: String) { - val tmpFile = File.createTempFile("abc", null) - MiscUtils.writeStringToFile(fileContents, tmpFile) - repo.uploadFile(tmpFile, fileName) + fun writeStringToRepoFile(repo: SyncRepo, content: String, fileName: String) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile(content, tmpFile) + repo.storeBook(tmpFile, fileName) tmpFile.delete() } } diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index d5ff0e924..da62353a2 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -212,11 +212,6 @@ class WebdavRepo( return sardine.list(fileUrl).first().toVersionedRook() } - fun uploadFile(file: File, fileName: String) { - val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() - sardine.put(fileUrl, file, null) - } - override fun renameBook(from: Uri, name: String?): VersionedRook { val destUrl = UriUtils.getUriForNewName(from, name).toUrl() sardine.move(from.toUrl(), destUrl) From 63222c121d3c6a9d3662aa962b1768d03c27ff7d Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 17 Jul 2024 15:23:53 +0200 Subject: [PATCH 24/73] SyncRepoTest: Add more ignore rules tests --- .../android/espresso/ContentRepoTest.kt | 82 ----------- .../com/orgzly/android/repos/GitRepoTest.kt | 139 ------------------ .../com/orgzly/android/repos/SyncRepoTest.kt | 101 ++++++++++++- 3 files changed, 96 insertions(+), 226 deletions(-) delete mode 100644 app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 9f6bea3a5..d568cf06e 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -22,7 +22,6 @@ import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo -import com.orgzly.android.espresso.util.EspressoUtils import com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu import com.orgzly.android.espresso.util.EspressoUtils.onBook import com.orgzly.android.espresso.util.EspressoUtils.onNoteInBook @@ -77,41 +76,6 @@ class ContentRepoTest : OrgzlyTest() { @JvmField var exceptionRule: ExpectedException = ExpectedException.none() - @Test - @Throws(IOException::class) - fun testStoringFile() { - setupContentRepo() - val tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("...", tmpFile) - syncRepo.storeBook(tmpFile, "booky.org") - } finally { - tmpFile.delete() - } - val books = syncRepo.books - assertEquals(1, books.size.toLong()) - assertEquals("booky", BookName.getInstance(context, books[0]).name) - assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) - assertEquals(repo.url, books[0].repoUri.toString()) - assertEquals(repo.url + documentTreeSegment + "booky.org", books[0].uri.toString()) - } - - @Test - @Throws(IOException::class) - fun testExtension() { - setupContentRepo() - writeStringToRepoFile("Notebook content 1", "01.txt") - writeStringToRepoFile("Notebook content 2", "02.o") - writeStringToRepoFile("Notebook content 3", "03.org") - val books = syncRepo.books - assertEquals(1, books.size.toLong()) - assertEquals("03", BookName.getInstance(context, books[0]).name) - assertEquals("03.org", BookName.getInstance(context, books[0]).fileName) - assertEquals(repo.id, books[0].repoId) - assertEquals(repo.url, books[0].repoUri.toString()) - assertEquals(repo.url + documentTreeSegment + "03.org", books[0].uri.toString()) - } - @Test fun testRenameBook() { setupContentRepo() @@ -144,52 +108,6 @@ class ContentRepoTest : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) } - - @Test - fun testIgnoreRulePreventsRenamingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupContentRepo() - - // Add .orgzlyignore file - writeStringToRepoFile("file3*", RepoIgnoreNode.IGNORE_FILE) - // Create book and sync it - testUtils.setupBook("booky", "") - testUtils.sync() - ActivityScenario.launch(MainActivity::class.java).use { - // Rename to allowed name - onBook(0).perform(longClick()) - contextualToolbarOverflowMenu().perform(click()) - onView(withText(R.string.rename)).perform(click()) - onView(withId(R.id.name)).perform(*EspressoUtils.replaceTextCloseKeyboard("file1")) - onView(withText(R.string.rename)).perform(click()) - onBook(0, R.id.item_book_last_action).check( - matches(withText(endsWith("Renamed from “booky”"))) - ) - // Rename to ignored name - onBook(0).perform(longClick()) - contextualToolbarOverflowMenu().perform(click()) - onView(withText(R.string.rename)).perform(click()) - onView(withId(R.id.name)).perform(*EspressoUtils.replaceTextCloseKeyboard("file3")) - onView(withText(R.string.rename)).perform(click()) - onBook(0, R.id.item_book_last_action).check( - matches(withText(endsWith(context.getString( - R.string.error_file_matches_repo_ignore_rule, - RepoIgnoreNode.IGNORE_FILE))))) - } - } - - @Test - @Throws(java.lang.Exception::class) - fun testIgnoreRulePreventsLinkingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupContentRepo() - // Add .orgzlyignore file - writeStringToRepoFile("*.org", RepoIgnoreNode.IGNORE_FILE) - testUtils.setupBook("booky", "") - exceptionRule.expect(IOException::class.java) - exceptionRule.expectMessage("matches a rule in .orgzlyignore") - testUtils.syncOrThrow() - } @Test fun testLoadNotebookFromSubfolder() { diff --git a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt deleted file mode 100644 index ef5b2b824..000000000 --- a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.orgzly.android.repos - -import android.net.Uri -import android.os.Build -import androidx.core.net.toUri -import com.orgzly.android.OrgzlyTest -import com.orgzly.android.db.entity.BookView -import com.orgzly.android.db.entity.Repo -import com.orgzly.android.git.GitFileSynchronizer -import com.orgzly.android.git.GitPreferencesFromRepoPrefs -import com.orgzly.android.prefs.AppPreferences -import com.orgzly.android.prefs.RepoPreferences -import com.orgzly.android.util.MiscUtils -import org.eclipse.jgit.api.Git -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assume -import org.junit.Rule -import org.junit.Test -import org.junit.rules.ExpectedException -import java.io.File -import java.io.IOException -import java.nio.file.Path -import kotlin.io.path.createTempDirectory - -class GitRepoTest : OrgzlyTest() { - - private lateinit var bareRepoPath: Path - private lateinit var repoUri: Uri - private lateinit var gitPreferences: GitPreferencesFromRepoPrefs - private lateinit var workingtree: File - private lateinit var repoPreferences: RepoPreferences - private lateinit var repo: Repo - private lateinit var syncRepo: GitRepo - private lateinit var git: Git - private lateinit var synchronizer: GitFileSynchronizer - - @Rule - @JvmField - var exceptionRule: ExpectedException = ExpectedException.none() - - override fun setUp() { - super.setUp() - bareRepoPath = createTempDirectory() - Git.init().setBare(true).setDirectory(bareRepoPath.toFile()).call() - AppPreferences.gitIsEnabled(context, true) - repoUri = bareRepoPath.toFile().toUri() - repo = testUtils.setupRepo(RepoType.GIT, repoUri.toString()) - repoPreferences = RepoPreferences(context, repo.id, repoUri) - gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) - workingtree = File(gitPreferences.repositoryFilepath()) - workingtree.mkdirs() - git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) - syncRepo = dataRepository.getRepoInstance(repo.id, RepoType.GIT, repo.url) as GitRepo - synchronizer = GitFileSynchronizer(git, gitPreferences) - } - - override fun tearDown() { - super.tearDown() - testUtils.deleteRepo(repo.url) - workingtree.deleteRecursively() - bareRepoPath.toFile()?.deleteRecursively() - } - - @Test - fun testIgnoredFilesInRepoAreNotLoaded() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - // Create ignore file in working tree and commit - val ignoreFileContents = """ - ignoredbook.org - ignored-*.org - """.trimIndent() - addAndCommitIgnoreFile(ignoreFileContents) - // Add multiple files to repo - for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, fileName) - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertEquals(1, dataRepository.getBooks().size) - assertEquals("notignored", dataRepository.getBooks()[0].book.name) - } - - @Test - fun testUnIgnoredFilesInRepoAreLoaded() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - // Create ignore file in working tree and commit - val ignoreFileContents = """ - *.org - !notignored.org - """.trimIndent() - addAndCommitIgnoreFile(ignoreFileContents) - // Add multiple files to repo - for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, fileName) - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertEquals(1, dataRepository.getBooks().size) - assertEquals("notignored", dataRepository.getBooks()[0].book.name) - } - - @Test - fun testIgnoreRulePreventsLinkingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile("*.org") - testUtils.setupBook("booky", "") - exceptionRule.expect(IOException::class.java) - exceptionRule.expectMessage("matches a rule in .orgzlyignore") - testUtils.syncOrThrow() - } - - @Test - fun testIgnoreRulePreventsRenamingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile("badname*") - testUtils.setupBook("goodname", "") - testUtils.sync() - var bookView: BookView? = dataRepository.getBookView("goodname") - dataRepository.renameBook(bookView!!, "badname") - bookView = dataRepository.getBooks()[0] - assertTrue( - bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") - ) - } - - private fun addAndCommitIgnoreFile(contents: String) { - val tmpFile = File.createTempFile("orgzly-test", null) - MiscUtils.writeStringToFile(contents, tmpFile) - syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) - tmpFile.delete() - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index b0ae536d2..28de0283d 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -8,13 +8,16 @@ import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.orgzly.BuildConfig import com.orgzly.R +import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo import com.orgzly.android.espresso.util.EspressoUtils import com.orgzly.android.git.GitFileSynchronizer @@ -27,10 +30,12 @@ import com.orgzly.android.repos.RepoType.DROPBOX import com.orgzly.android.repos.RepoType.GIT import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV +import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git +import org.hamcrest.CoreMatchers import org.hamcrest.core.AllOf import org.junit.Assert import org.junit.Assume @@ -40,6 +45,7 @@ import org.junit.rules.ExpectedException import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.io.File +import java.io.IOException import java.nio.file.Path import kotlin.io.path.createTempDirectory @@ -64,10 +70,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( - Parameter(repoType = WEBDAV), - Parameter(repoType = DROPBOX), Parameter(repoType = GIT), + Parameter(repoType = DROPBOX), Parameter(repoType = DOCUMENT), + Parameter(repoType = WEBDAV), ) } } @@ -90,6 +96,43 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Rule var exceptionRule: ExpectedException = ExpectedException.none() + @Test + @Throws(IOException::class) + fun testStoringFile() { + setupSyncRepo(param.repoType, null) + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "booky.org") + } finally { + tmpFile.delete() + } + val books = syncRepo.books + Assert.assertEquals(1, books.size.toLong()) + Assert.assertEquals("booky", BookName.getInstance(context, books[0]).name) + Assert.assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) + Assert.assertEquals(repo.url, books[0].repoUri.toString()) + } + + @Test + @Throws(IOException::class) + fun testExtension() { + setupSyncRepo(param.repoType, null) + // Add multiple files to repo + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + val books = syncRepo.books + Assert.assertEquals(1, books.size.toLong()) + Assert.assertEquals("file three", BookName.getInstance(context, books[0]).name) + Assert.assertEquals("file three.org", BookName.getInstance(context, books[0]).fileName) + Assert.assertEquals(repo.id, books[0].repoId) + Assert.assertEquals(repo.url, books[0].repoUri.toString()) + } + @Test fun testSyncNewBookWithoutLinkAndOneRepo() { setupSyncRepo(param.repoType, null) @@ -116,7 +159,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testIgnoreRulePreventsLoadingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) // .orgzlyignore not supported below API 26 val ignoreRules = """ ignoredbook.org ignored-*.org @@ -135,6 +178,54 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { Assert.assertEquals("notignored", dataRepository.getBooks()[0].book.name) } + @Test + fun testUnIgnoredFilesInRepoAreLoaded() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + val ignoreFileContents = """ + *.org + !notignored.org + """.trimIndent() + setupSyncRepo(param.repoType, ignoreFileContents) + // Add multiple files to repo + for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + testUtils.sync() + Assert.assertEquals(1, syncRepo.books.size) + Assert.assertEquals(1, dataRepository.getBooks().size) + Assert.assertEquals("notignored", dataRepository.getBooks()[0].book.name) + } + + @Test + fun testIgnoreRulePreventsRenamingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(param.repoType,"bad name*") + + // Create book and sync it + testUtils.setupBook("good name", "") + testUtils.sync() + var bookView: BookView? = dataRepository.getBookView("good name") + dataRepository.renameBook(bookView!!, "bad name") + bookView = dataRepository.getBooks()[0] + Assert.assertTrue( + bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") + ) + } + + @Test + @Throws(java.lang.Exception::class) + fun testIgnoreRulePreventsLinkingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(param.repoType, "*.org") + testUtils.setupBook("booky", "") + exceptionRule.expect(IOException::class.java) + exceptionRule.expectMessage("matches a rule in .orgzlyignore") + testUtils.syncOrThrow() + } + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { when (repoType) { GIT -> setupGitRepo() @@ -154,8 +245,8 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private fun setupDropboxRepo() { testUtils.dropboxTestPreflight() - syncRepo = testUtils.repoInstance(DROPBOX, "dropbox:/$repoDirectoryName") - repo = testUtils.setupRepo(DROPBOX, syncRepo.uri.toString()) + repo = testUtils.setupRepo(DROPBOX, "dropbox:/$repoDirectoryName") + syncRepo = testUtils.repoInstance(DROPBOX, repo.url, repo.id) } private fun tearDownDropboxRepo() { From e118a1c458cf576f793479c131135f3488979596 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 00:54:16 +0200 Subject: [PATCH 25/73] Avoid using Espresso when it's not necessary --- .../orgzly/android/espresso/SyncingTest.java | 159 ------------------ .../com/orgzly/android/repos/SyncTest.java | 140 ++++++++++++++- 2 files changed, 133 insertions(+), 166 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index 8a6d65fe3..6fa4d738a 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -74,29 +74,6 @@ public void testRunSync() { sync(); } - @Test - public void testForceLoadingBookWithLink() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRook(repo, "mock://repo-a/booky.org", "New content", "abc", 1234567890000L); - testUtils.setupBook("booky", "First book used for testing\n* Note A"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - contextualToolbarOverflowMenu().perform(click()); - onView(withText(R.string.books_context_menu_item_set_link)).perform(click()); - onView(withText("mock://repo-a")).perform(click()); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onBook(0, R.id.item_book_last_action) - .check(matches((withText(containsString(context.getString(R.string.force_loaded_from_uri, "mock://repo-a/booky.org")))))); - - onView(allOf(withText("booky"), isDisplayed())).perform(click()); - onView(allOf(withId(R.id.item_preface_text_view), withText("New content"))) - .check(matches(isDisplayed())); - } - @Test public void testAutoSyncIsTriggeredAfterCreatingNote() { Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -169,64 +146,6 @@ public void nonLinkedBookCannotBeMadeOutOfSync() { onBook(0, R.id.item_book_sync_needed_icon).check(matches(not(isDisplayed()))); } - @Test - public void testForceLoadingBookWithNoLinkNoRepos() { - testUtils.setupBook("booky", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onSnackbar().check(matches(withText(endsWith(context.getString(R.string.message_book_has_no_link))))); - } - - @Test - public void testForceLoadingBookWithNoLinkSingleRepo() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupBook("booky", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onSnackbar().check(matches(withText(endsWith(context.getString(R.string.message_book_has_no_link))))); - } - - /* Books view was returning multiple entries for the same book, due to duplicates in encodings - * table. The last statement in this method will fail if there are multiple books matching. - */ - @Test - public void testForceLoadingMultipleTimes() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRook(repo, "mock://repo-a/book-one.org", "New content", "abc", 1234567890000L); - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - contextualToolbarOverflowMenu().perform(click()); - onView(withText(R.string.books_context_menu_item_set_link)).perform(click()); - onView(withText("mock://repo-a")).perform(click()); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"))))); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"))))); - } - /* * Book is left with out-of-sync icon when it's modified, then force-loaded. * This is because book's mtime was not being updated and was greater then remote book's mtime. @@ -296,69 +215,6 @@ public void testForceLoadingMultipleBooks() { onNoteInBook(1, R.id.item_head_title_view).check(matches(withText("Note 1"))); } - @Test - public void testForceSavingBookWithNoLinkAndMultipleRepos() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRepo(RepoType.MOCK, "mock://repo-b"); - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saving_failed, context.getString(R.string.multiple_repos)))))); - - } - - @Test - public void testForceSavingBookWithNoLinkNoRepos() { - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saving_failed, context.getString(R.string.no_repos)))))); - } - - @Test - public void testForceSavingBookWithNoLinkSingleRepo() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saved_to_uri, "mock://repo-a/book-one.org"))))); - } - - @Test - public void testForceSavingBookWithLink() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupBook("booky", "First book used for testing\n* Note A", repo); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saved_to_uri, "mock://repo-a/booky.org"))))); - } - @Test public void testForceSavingMultipleBooks() { Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -784,21 +640,6 @@ public void testSettingLinkForLoadedOrgTxtBook() { onBook(0, R.id.item_book_synced_url).check(matches(allOf(withText("mock://repo-a/booky.org.txt"), isDisplayed()))); } - @Test - public void testSpaceSeparatedBookName() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRook(repo, "mock://repo-a/Book%20Name.org", "", "1abcdef", 1400067155); - - scenario = ActivityScenario.launch(MainActivity.class); - - sync(); - - onBook(0, R.id.item_book_synced_url) - .check(matches(allOf(withText("mock://repo-a/Book%20Name.org"), isDisplayed()))); - onBook(0, R.id.item_book_last_action) - .check(matches(allOf(withText(endsWith("Loaded from mock://repo-a/Book%20Name.org")), isDisplayed()))); - } - @Test public void testRenameModifiedBook() { testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java index a07da5217..d9f41761d 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java @@ -1,7 +1,30 @@ package com.orgzly.android.repos; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu; +import static com.orgzly.android.espresso.util.EspressoUtils.onBook; +import static com.orgzly.android.espresso.util.EspressoUtils.onSnackbar; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.endsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + import android.net.Uri; +import androidx.test.core.app.ActivityScenario; + +import com.orgzly.R; +import com.orgzly.android.BookFormat; import com.orgzly.android.BookName; import com.orgzly.android.LocalStorage; import com.orgzly.android.OrgzlyTest; @@ -11,11 +34,14 @@ import com.orgzly.android.db.entity.Repo; import com.orgzly.android.sync.BookNamesake; import com.orgzly.android.sync.BookSyncStatus; +import com.orgzly.android.ui.main.MainActivity; import com.orgzly.android.util.EncodingDetect; import com.orgzly.android.util.MiscUtils; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import java.io.File; import java.io.IOException; @@ -23,13 +49,6 @@ import java.util.Map; import java.util.UUID; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - public class SyncTest extends OrgzlyTest { private static final String TAG = SyncTest.class.getName(); @@ -38,6 +57,9 @@ public void setUp() throws Exception { super.setUp(); } + @Rule + public ExpectedException exceptionRule = ExpectedException.none(); + @Test public void testOrgRange() { Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -533,4 +555,108 @@ public void testBookStatusAfterMultipleSyncsFollowingRemoteFileDeletion() throws assertNull(book.getLinkRepo()); assertEquals(BookSyncStatus.BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK.toString(), book.getBook().getSyncStatus()); } + + @Test + public void testSpaceSeparatedBookName() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/Book%20Name.org", "", "1abcdef", 1400067155); + + testUtils.sync(); + + BookView bookView = dataRepository.getBooks().get(0); + assertNotNull(bookView.getSyncedTo()); + assertEquals("mock://repo-a/Book%20Name.org", bookView.getSyncedTo().getUri().toString()); + assertEquals("Loaded from mock://repo-a/Book%20Name.org", + bookView.getBook().getLastAction().getMessage()); + } + + @Test + public void testForceLoadingBookWithLink() throws IOException { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/booky.org", "New content", "abc", 1234567890000L); + Book book = testUtils.setupBook("booky", "First book used for testing\n* Note A").getBook(); + dataRepository.setLink(book.getId(), repo); + dataRepository.forceLoadBook(book.getId()); + + assertEquals(context.getString(R.string.force_loaded_from_uri, "mock://repo-a/booky.org") + , dataRepository.getBook(book.getName()).getLastAction().getMessage()); + assertEquals("New content\n\n", dataRepository.getBookContent("booky", BookFormat.ORG)); + } + + @Test + public void testForceLoadingBookWithNoLinkNoRepos() { + BookView book = testUtils.setupBook("booky", "First book used for testing\n* Note A"); + + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.message_book_has_no_link)); + dataRepository.forceLoadBook(book.getBook().getId()); + } + + @Test + public void testForceLoadingBookWithNoLinkSingleRepo() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + BookView book = testUtils.setupBook("booky", "First book used for testing\n* Note A"); + + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.message_book_has_no_link)); + dataRepository.forceLoadBook(book.getBook().getId()); + } + + /* Books view was returning multiple entries for the same book, due to duplicates in encodings + * table. The last statement in this method will fail if there are multiple books matching. + */ + @Test + public void testForceLoadingMultipleTimes() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/book-one.org", "New content", "abc", 1234567890000L); + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + dataRepository.setLink(book.getId(), repo); + dataRepository.forceLoadBook(book.getId()); + assertEquals( + context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"), + dataRepository.getBook(book.getId()).getLastAction().getMessage() + ); + dataRepository.forceLoadBook(book.getId()); + assertEquals( + context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"), + dataRepository.getBook(book.getId()).getLastAction().getMessage() + ); + } + + @Test + public void testForceSavingBookWithNoLinkAndMultipleRepos() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRepo(RepoType.MOCK, "mock://repo-b"); + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.force_saving_failed, context.getString(R.string.multiple_repos))); + dataRepository.forceSaveBook(book.getId()); + } + + @Test + public void testForceSavingBookWithNoLinkNoRepos() { + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.force_saving_failed, context.getString(R.string.no_repos))); + dataRepository.forceSaveBook(book.getId()); + } + + @Test + public void testForceSavingBookWithNoLinkSingleRepo() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + dataRepository.forceSaveBook(book.getId()); + assertEquals(context.getString(R.string.force_saved_to_uri, "mock://repo-a/book-one.org") + , dataRepository.getBook(book.getId()).getLastAction().getMessage()); + } + + @Test + public void testForceSavingBookWithLink() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + Book book = testUtils.setupBook("booky", "First book used for testing\n* Note A", repo).getBook(); + dataRepository.setLink(book.getId(), repo); + dataRepository.forceSaveBook(book.getId()); + assertEquals(context.getString(R.string.force_saved_to_uri, "mock://repo-a/booky.org") + , dataRepository.getBook(book.getId()).getLastAction().getMessage()); + } } From 6ffb94bbd81d53f1f30257d52e8a1c40680c51b8 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 01:14:16 +0200 Subject: [PATCH 26/73] fixup! Handle non-"content" repo file URLs better --- app/src/main/java/com/orgzly/android/BookName.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/BookName.java b/app/src/main/java/com/orgzly/android/BookName.java index f358b9f75..aeee48304 100644 --- a/app/src/main/java/com/orgzly/android/BookName.java +++ b/app/src/main/java/com/orgzly/android/BookName.java @@ -69,9 +69,11 @@ public static String getFileName(Uri repoUri, Uri fileUri) { String repoRootUriSegment = repoUri + "/document/" + repoUriLastSegment + "%2F"; return Uri.decode(fileUri.toString().replace(repoRootUriSegment, "")); } else { - // Just return the fileUri stripped of the repoUri (if present), and stripped of any - // leading / (if present). - return fileUri.toString().replace(repoUri.toString(), "").replaceFirst("^/", ""); + // Just return the decoded fileUri stripped of the repoUri (if present), and stripped + // of any leading / (if present). + return Uri.decode( + fileUri.toString().replace(repoUri.toString(), "") + ).replaceFirst("^/", ""); } } From 587314f5c38a23ceba27131e8d19c70051d192f7 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 01:17:11 +0200 Subject: [PATCH 27/73] Add an assertion to clarify the meaning of a test --- app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java index d9f41761d..75a660e83 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java @@ -565,6 +565,7 @@ public void testSpaceSeparatedBookName() { BookView bookView = dataRepository.getBooks().get(0); assertNotNull(bookView.getSyncedTo()); + assertEquals("Book Name", bookView.getBook().getName()); assertEquals("mock://repo-a/Book%20Name.org", bookView.getSyncedTo().getUri().toString()); assertEquals("Loaded from mock://repo-a/Book%20Name.org", bookView.getBook().getLastAction().getMessage()); From f56214eb6f823f54004cf55916c599b80245e244 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 02:05:25 +0200 Subject: [PATCH 28/73] Variable is not always initialized, for some reason --- .../java/com/orgzly/android/espresso/ContentRepoTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index d568cf06e..4694bbdc0 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -69,7 +69,9 @@ class ContentRepoTest : OrgzlyTest() { @Throws(Exception::class) override fun tearDown() { super.tearDown() - DocumentFile.fromTreeUri(context, Uri.parse(treeDocumentFileUrl))?.delete() + if (this::treeDocumentFileUrl.isInitialized) { + DocumentFile.fromTreeUri(context, Uri.parse(treeDocumentFileUrl))!!.delete() + } } @Rule From 4de9bfb0147340596bd64bb170ee6c7e810bdc35 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 02:07:55 +0200 Subject: [PATCH 29/73] SyncRepoTest: Sleep between some UI actions --- .../androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 28de0283d..6e8fce99b 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -260,9 +260,11 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { .perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) .perform(ViewActions.click()) + SystemClock.sleep(100) // In Android file browser (Espresso cannot be used): val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() + SystemClock.sleep(100) mDevice.findObject(UiSelector().text("Folder name")).text = repoDirectoryName mDevice.findObject(UiSelector().text("OK")).click() mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() From 1079366d3daa9158734f89cc07228112e28c8488 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 02:51:44 +0200 Subject: [PATCH 30/73] SyncRepoTest: Consolidate some more tests --- .../android/espresso/ContentRepoTest.kt | 42 ------- .../android/repos/DirectoryRepoTest.java | 1 - .../orgzly/android/repos/DropboxRepoTest.java | 103 +----------------- .../com/orgzly/android/repos/SyncRepoTest.kt | 103 +++++++++++++----- .../orgzly/android/repos/WebdavRepoTest.kt | 99 ----------------- 5 files changed, 78 insertions(+), 270 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 4694bbdc0..04512983b 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -78,28 +78,6 @@ class ContentRepoTest : OrgzlyTest() { @JvmField var exceptionRule: ExpectedException = ExpectedException.none() - @Test - fun testRenameBook() { - setupContentRepo() - testUtils.setupBook("booky", "") - testUtils.sync() - var bookView: BookView? = dataRepository.getBookView("booky") - assertEquals(repo.url, bookView!!.linkRepo!!.url) - assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - assertEquals( - repo.url + documentTreeSegment + "booky.org", - bookView.syncedTo!!.uri.toString() - ) - dataRepository.renameBook(bookView, "booky-renamed") - bookView = dataRepository.getBookView("booky-renamed") - assertEquals(repo.url, bookView!!.linkRepo!!.url) - assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - assertEquals( - repo.url + documentTreeSegment + "booky-renamed.org", - bookView.syncedTo!!.uri.toString() - ) - } - @Test @Throws(FileNotFoundException::class) fun testSyncWithDirectoryContainingPercent() { @@ -271,26 +249,6 @@ class ContentRepoTest : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) } - @Test - fun testRenameBookToExistingFileName() { - setupContentRepo() - testUtils.setupBook("a", "") - testUtils.sync() - // Create "unsynced" file in repo - writeStringToRepoFile("", "b.org") - dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") - assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: File at content://")) - } - - @Test - fun testRenameBookToExistingBookName() { - setupContentRepo() - testUtils.setupBook("a", "") - testUtils.setupBook("b", "") - dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") - assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) - } - private fun writeStringToRepoFile(content: String, fileName: String) { val tmpFile = File.createTempFile("abc", null) MiscUtils.writeStringToFile(content, tmpFile) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java index 60133d30c..c790d473c 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java @@ -118,7 +118,6 @@ public void testListDownloadsDirectory() throws IOException { assertNotNull(repo.getBooks()); } - // TODO: Do the same for dropbox repo @Test public void testRenameBook() { BookView bookView; diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java index 94a0400d5..35d5c7c3a 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java @@ -42,112 +42,11 @@ public void testUrl() { } @Test - public void testSyncingUrlWithTrailingSlash() throws IOException { + public void testSyncingUrlWithTrailingSlash() { testUtils.setupRepo(RepoType.DROPBOX, randomUrl() + "/"); assertNotNull(testUtils.sync()); } - @Test - public void testRenameBook() throws IOException { - BookView bookView; - String repoUriString = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri().toString(); - - testUtils.setupRepo(RepoType.DROPBOX, repoUriString); - testUtils.setupBook("booky", ""); - - testUtils.sync(); - bookView = dataRepository.getBookView("booky"); - - assertEquals(repoUriString, bookView.getLinkRepo().getUrl()); - assertEquals(repoUriString, bookView.getSyncedTo().getRepoUri().toString()); - assertEquals(repoUriString + "/booky.org", bookView.getSyncedTo().getUri().toString()); - - dataRepository.renameBook(bookView, "booky-renamed"); - bookView = dataRepository.getBookView("booky-renamed"); - - assertEquals(repoUriString, bookView.getLinkRepo().getUrl()); - assertEquals(repoUriString, bookView.getSyncedTo().getRepoUri().toString()); - assertEquals(repoUriString + "/booky-renamed.org", bookView.getSyncedTo().getUri().toString()); - } - - @Test - public void testIgnoreRulePreventsLinkingBook() throws Exception { - Uri repoUri = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri(); - testUtils.setupRepo(RepoType.DROPBOX, repoUri.toString()); - uploadFileToRepo(repoUri, RepoIgnoreNode.IGNORE_FILE, "*.org"); - testUtils.setupBook("booky", ""); - exceptionRule.expect(IOException.class); - exceptionRule.expectMessage("matches a rule in .orgzlyignore"); - testUtils.syncOrThrow(); - } - - @Test - public void testIgnoreRulePreventsLoadingBook() throws Exception { - SyncRepo repo = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()); - testUtils.setupRepo(RepoType.DROPBOX, repo.getUri().toString()); - - // Create two .org files - uploadFileToRepo(repo.getUri(), "ignored.org", "1 2 3"); - uploadFileToRepo(repo.getUri(), "notignored.org", "1 2 3"); - // Create .orgzlyignore - uploadFileToRepo(repo.getUri(), RepoIgnoreNode.IGNORE_FILE, "ignored.org"); - testUtils.sync(); - - List bookViews = dataRepository.getBooks(); - assertEquals(1, bookViews.size()); - assertEquals("notignored", bookViews.get(0).getBook().getName()); - } - - @Test - public void testIgnoreRulePreventsRenamingBook() throws Exception { - BookView bookView; - Uri repoUri = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri(); - testUtils.setupRepo(RepoType.DROPBOX, repoUri.toString()); - uploadFileToRepo(repoUri, RepoIgnoreNode.IGNORE_FILE, "badname*"); - testUtils.setupBook("goodname", ""); - testUtils.sync(); - bookView = dataRepository.getBookView("goodname"); - dataRepository.renameBook(bookView, "badname"); - bookView = dataRepository.getBooks().get(0); - assertTrue( - bookView.getBook() - .getLastAction() - .toString() - .contains("matches a rule in .orgzlyignore") - ); - } - - @Test - public void testDropboxFileRename() throws IOException { - SyncRepo repo = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()); - - assertNotNull(repo); - assertEquals(0, repo.getBooks().size()); - - File file = File.createTempFile("notebook.", ".org"); - MiscUtils.writeStringToFile("1 2 3", file); - - VersionedRook vrook = repo.storeBook(file, file.getName()); - - file.delete(); - - assertEquals(1, repo.getBooks().size()); - - repo.renameBook(vrook.getUri(), "notebook-renamed"); - - assertEquals(1, repo.getBooks().size()); - assertEquals(repo.getUri() + "/notebook-renamed.org", repo.getBooks().get(0).getUri().toString()); - assertEquals("notebook-renamed.org", BookName.getInstance(context, repo.getBooks().get(0)).getFileName()); - } - - static public void uploadFileToRepo(Uri repoUri, String fileName, String fileContents) throws IOException { - DropboxClient client = new DropboxClient(App.getAppContext(), 0); - File tmpFile = File.createTempFile("orgzly-test", null); - MiscUtils.writeStringToFile(fileContents, tmpFile); - client.upload(tmpFile, repoUri, fileName); - tmpFile.delete(); - } - private String randomUrl() { return "dropbox:"+ DROPBOX_TEST_DIR + "/" + UUID.randomUUID().toString(); } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 6e8fce99b..a186ebd22 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -8,7 +8,6 @@ import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -30,14 +29,14 @@ import com.orgzly.android.repos.RepoType.DROPBOX import com.orgzly.android.repos.RepoType.GIT import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV -import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git -import org.hamcrest.CoreMatchers import org.hamcrest.core.AllOf import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Assume import org.junit.Rule import org.junit.Test @@ -70,10 +69,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( + Parameter(repoType = WEBDAV), Parameter(repoType = GIT), Parameter(repoType = DROPBOX), Parameter(repoType = DOCUMENT), - Parameter(repoType = WEBDAV), ) } } @@ -108,10 +107,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { tmpFile.delete() } val books = syncRepo.books - Assert.assertEquals(1, books.size.toLong()) - Assert.assertEquals("booky", BookName.getInstance(context, books[0]).name) - Assert.assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) - Assert.assertEquals(repo.url, books[0].repoUri.toString()) + assertEquals(1, books.size.toLong()) + assertEquals("booky", BookName.getInstance(context, books[0]).name) + assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) + assertEquals(repo.url, books[0].repoUri.toString()) } @Test @@ -126,11 +125,11 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { tmpFile.delete() } val books = syncRepo.books - Assert.assertEquals(1, books.size.toLong()) - Assert.assertEquals("file three", BookName.getInstance(context, books[0]).name) - Assert.assertEquals("file three.org", BookName.getInstance(context, books[0]).fileName) - Assert.assertEquals(repo.id, books[0].repoId) - Assert.assertEquals(repo.url, books[0].repoUri.toString()) + assertEquals(1, books.size.toLong()) + assertEquals("file three", BookName.getInstance(context, books[0]).name) + assertEquals("file three.org", BookName.getInstance(context, books[0]).fileName) + assertEquals(repo.id, books[0].repoId) + assertEquals(repo.url, books[0].repoUri.toString()) } @Test @@ -139,10 +138,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { testUtils.setupBook("book 1", "content") testUtils.sync() val bookView = dataRepository.getBooks()[0] - Assert.assertEquals(repo.url, bookView.linkRepo?.url) - Assert.assertEquals(1, syncRepo.books.size) - Assert.assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) - Assert.assertEquals( + assertEquals(repo.url, bookView.linkRepo?.url) + assertEquals(1, syncRepo.books.size) + assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) + assertEquals( context.getString(R.string.sync_status_saved, repo.url), bookView.book.lastAction!!.message ) @@ -154,9 +153,59 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$repoDirectoryName/document/primary%3A$repoDirectoryName%2Fbook%201.org" WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived%40gmail.com/$repoDirectoryName/book 1.org" } - Assert.assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) + assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) + } + + @Test + fun testRenameBook() { + setupSyncRepo(param.repoType, null) + testUtils.setupBook("oldname", "") + testUtils.sync() + var bookView = dataRepository.getBookView("oldname") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertTrue(bookView.syncedTo!!.uri.toString().contains("oldname.org")) + + dataRepository.renameBook(bookView, "newname") + + assertEquals(1, syncRepo.books.size.toLong()) + assertEquals( + "newname.org", + BookName.getInstance(context, syncRepo.books[0]).fileName + ) + bookView = dataRepository.getBookView("newname") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertTrue(bookView.syncedTo!!.uri.toString().contains("newname.org")) } + @Test + fun testRenameBookToExistingFileName() { + setupSyncRepo(param.repoType, null) + testUtils.setupBook("a", "") + testUtils.sync() + + // Create "unsynced" file in repo + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile("bla bla", tmpFile) + syncRepo.storeBook(tmpFile, "b.org") + tmpFile.delete() + + dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed:")) + } + + @Test + fun testRenameBookToExistingBookName() { + setupSyncRepo(param.repoType, null) + testUtils.setupBook("a", "") + testUtils.setupBook("b", "") + assertEquals(2, dataRepository.getBooks().size) + dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) + } + + @Test fun testIgnoreRulePreventsLoadingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) // .orgzlyignore not supported below API 26 @@ -173,9 +222,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { tmpFile.delete() } testUtils.sync() - Assert.assertEquals(1, syncRepo.books.size) - Assert.assertEquals(1, dataRepository.getBooks().size) - Assert.assertEquals("notignored", dataRepository.getBooks()[0].book.name) + assertEquals(1, syncRepo.books.size) + assertEquals(1, dataRepository.getBooks().size) + assertEquals("notignored", dataRepository.getBooks()[0].book.name) } @Test @@ -194,9 +243,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { tmpFile.delete() } testUtils.sync() - Assert.assertEquals(1, syncRepo.books.size) - Assert.assertEquals(1, dataRepository.getBooks().size) - Assert.assertEquals("notignored", dataRepository.getBooks()[0].book.name) + assertEquals(1, syncRepo.books.size) + assertEquals(1, dataRepository.getBooks().size) + assertEquals("notignored", dataRepository.getBooks()[0].book.name) } @Test @@ -251,7 +300,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private fun tearDownDropboxRepo() { val dropboxRepo = syncRepo as DropboxRepo - dropboxRepo.deleteDirectory(syncRepo.uri) + try { + dropboxRepo.deleteDirectory(syncRepo.uri) + } catch (_: IOException) {} } private fun setupContentRepo() { @@ -279,7 +330,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { val encodedRepoDirName = Uri.encode(repoDirectoryName) documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" - Assert.assertEquals(treeDocumentFileUrl, repo.url) + assertEquals(treeDocumentFileUrl, repo.url) } private fun tearDownContentRepo() { diff --git a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt index 8f0bf165c..1586170d8 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -57,108 +57,9 @@ class WebdavRepoTest : OrgzlyTest() { Assert.assertNotNull(testUtils.sync()) } - @Test - fun testRenameBook() { - setupRepo() - testUtils.setupBook("booky", "") - testUtils.sync() - var bookView: BookView? = dataRepository.getBookView("booky") - Assert.assertEquals(repoUriString, bookView!!.linkRepo!!.url) - Assert.assertEquals(repoUriString, bookView.syncedTo!!.getRepoUri().toString()) - Assert.assertEquals("$repoUriString/booky.org", bookView.syncedTo!!.getUri().toString()) - dataRepository.renameBook(bookView, "booky-renamed") - bookView = dataRepository.getBookView("booky-renamed") - Assert.assertEquals(repoUriString, bookView!!.linkRepo!!.url) - Assert.assertEquals(repoUriString, bookView.syncedTo!!.getRepoUri().toString()) - Assert.assertEquals( - "$repoUriString/booky-renamed.org", - bookView.syncedTo!!.getUri().toString() - ) - } - - @Test - @Throws(Exception::class) - fun testIgnoreRulePreventsLinkingBook() { - setupRepo() - testUtils.sync() // To ensure the remote directory exists - writeStringToRepoFile(syncRepo, "*.org", RepoIgnoreNode.IGNORE_FILE) - testUtils.setupBook("booky", "") - exceptionRule.expect(IOException::class.java) - exceptionRule.expectMessage("matches a rule in .orgzlyignore") - testUtils.syncOrThrow() - } - - @Test - @Throws(Exception::class) - fun testIgnoreRulePreventsLoadingBook() { - setupRepo() - testUtils.sync() // To ensure the remote directory exists - - // Create two .org files - writeStringToRepoFile(syncRepo, "1 2 3", "ignored.org") - writeStringToRepoFile(syncRepo, "1 2 3", "notignored.org") - // Create .orgzlyignore - writeStringToRepoFile(syncRepo, "ignored.org", RepoIgnoreNode.IGNORE_FILE) - testUtils.sync() - val bookViews = dataRepository.getBooks() - Assert.assertEquals(1, bookViews.size.toLong()) - Assert.assertEquals("notignored", bookViews[0].book.name) - } - - @Test - @Throws(Exception::class) - fun testIgnoreRulePreventsRenamingBook() { - setupRepo() - testUtils.sync() // To ensure the remote directory exists - writeStringToRepoFile(syncRepo, "badname*", RepoIgnoreNode.IGNORE_FILE) - testUtils.setupBook("goodname", "") - testUtils.sync() - var bookView: BookView? = dataRepository.getBookView("goodname") - dataRepository.renameBook(bookView!!, "badname") - bookView = dataRepository.getBooks()[0] - Assert.assertTrue( - bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") - ) - } - - @Test - @Throws(IOException::class) - fun testFileRename() { - setupRepo() - Assert.assertNotNull(syncRepo) - Assert.assertEquals(0, syncRepo.books.size.toLong()) - val file = File.createTempFile("notebook.", ".org") - MiscUtils.writeStringToFile("1 2 3", file) - val vrook = syncRepo.storeBook(file, file.name) - file.delete() - Assert.assertEquals(1, syncRepo.books.size.toLong()) - syncRepo.renameBook(vrook.getUri(), "notebook-renamed") - Assert.assertEquals(1, syncRepo.books.size.toLong()) - Assert.assertEquals( - syncRepo.uri.toString() + "/notebook-renamed.org", - syncRepo.books[0].getUri().toString() - ) - Assert.assertEquals( - "notebook-renamed.org", - BookName.getInstance(context, syncRepo.books[0]).fileName - ) - } - - private fun setupRepo() { - val repo = testUtils.setupRepo(RepoType.WEBDAV, repoUriString, repoProps) - syncRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) - } - companion object { private val repoProps: MutableMap = mutableMapOf( WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) - - fun writeStringToRepoFile(repo: SyncRepo, content: String, fileName: String) { - val tmpFile = File.createTempFile("orgzly-test", null) - MiscUtils.writeStringToFile(content, tmpFile) - repo.storeBook(tmpFile, fileName) - tmpFile.delete() - } } } \ No newline at end of file From 5d02eeb4baa2be8b5251892ad84bbc8c6a36256a Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 13:07:50 +0200 Subject: [PATCH 31/73] Avoid flaky UI behavior --- .../java/com/orgzly/android/repos/SyncRepoTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index a186ebd22..0846ccd32 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -34,7 +34,6 @@ import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git import org.hamcrest.core.AllOf -import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assume @@ -259,7 +258,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { var bookView: BookView? = dataRepository.getBookView("good name") dataRepository.renameBook(bookView!!, "bad name") bookView = dataRepository.getBooks()[0] - Assert.assertTrue( + assertTrue( bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") ) } @@ -311,7 +310,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { .perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) .perform(ViewActions.click()) - SystemClock.sleep(100) + SystemClock.sleep(200) // In Android file browser (Espresso cannot be used): val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() @@ -321,6 +320,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() mDevice.findObject(UiSelector().text("ALLOW")).click() // Back in Orgzly: + SystemClock.sleep(200) Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) .perform(ViewActions.click()) From c7f0da6d4e8f0744fe3ab71d2490e2278a27038e Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 14:25:06 +0200 Subject: [PATCH 32/73] New tests are paying off -- found a bug in WebdavRepo.renameBook() --- .../com/orgzly/android/repos/SyncRepoTest.kt | 63 +++++++++++-------- .../com/orgzly/android/repos/WebdavRepo.kt | 8 +++ 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 0846ccd32..4a976544d 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -69,9 +69,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { fun data(): Collection { return listOf( Parameter(repoType = WEBDAV), + Parameter(repoType = DOCUMENT), Parameter(repoType = GIT), Parameter(repoType = DROPBOX), - Parameter(repoType = DOCUMENT), ) } } @@ -179,7 +179,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } @Test - fun testRenameBookToExistingFileName() { + fun testRenameBookToExistingRepoFileName() { setupSyncRepo(param.repoType, null) testUtils.setupBook("a", "") testUtils.sync() @@ -189,8 +189,13 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { MiscUtils.writeStringToFile("bla bla", tmpFile) syncRepo.storeBook(tmpFile, "b.org") tmpFile.delete() + assertEquals(2, syncRepo.books.size) // The remote repo should now contain 2 books dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + + // The remote repo should still contain 2 books - otherwise the existing b.org has been + // overwritten. + assertEquals(2, syncRepo.books.size) assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed:")) } @@ -305,36 +310,44 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } private fun setupContentRepo() { - ActivityScenario.launch(ReposActivity::class.java).use { - Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) - .perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) - .perform(ViewActions.click()) - SystemClock.sleep(200) - // In Android file browser (Espresso cannot be used): - val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - SystemClock.sleep(100) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirectoryName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - // Back in Orgzly: - SystemClock.sleep(200) - Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) - Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) - } - repo = dataRepository.getRepos()[0] - syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) val encodedRepoDirName = Uri.encode(repoDirectoryName) documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" + val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) + if (repoDirDocumentFile?.exists() == false) { + ActivityScenario.launch(ReposActivity::class.java).use { + Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) + .perform(ViewActions.click()) + SystemClock.sleep(300) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() + SystemClock.sleep(100) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirectoryName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + // Back in Orgzly: + SystemClock.sleep(200) + Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) + Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } + repo = dataRepository.getRepos()[0] + } else { + repo = testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) + } + syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) assertEquals(treeDocumentFileUrl, repo.url) } private fun tearDownContentRepo() { - DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri())!!.delete() + val repoDirectory = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) + for (file in repoDirectory!!.listFiles()) { + file.delete() + } } private fun setupWebdavRepo() { diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index da62353a2..2e4de8286 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -2,6 +2,7 @@ package com.orgzly.android.repos import android.net.Uri import android.os.Build +import androidx.documentfile.provider.DocumentFile import com.burgstaller.okhttp.AuthenticationCacheInterceptor import com.burgstaller.okhttp.CachingAuthenticatorDecorator import com.burgstaller.okhttp.DispatchingAuthenticator @@ -18,6 +19,7 @@ import okio.Buffer import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream import java.security.KeyStore import java.security.cert.CertificateFactory @@ -214,6 +216,12 @@ class WebdavRepo( override fun renameBook(from: Uri, name: String?): VersionedRook { val destUrl = UriUtils.getUriForNewName(from, name).toUrl() + + /* Abort if destination file already exists. */ + if (sardine.exists(destUrl)) { + throw IOException("File at $destUrl already exists") + } + sardine.move(from.toUrl(), destUrl) return sardine.list(destUrl).first().toVersionedRook() } From c22b2094a28c2654d17eb13d01878a2def583737 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 18 Jul 2024 14:47:16 +0200 Subject: [PATCH 33/73] DRY up content repo test setup --- .../android/espresso/ContentRepoTest.kt | 49 +++++++++++++------ .../com/orgzly/android/repos/SyncRepoTest.kt | 35 ++----------- 2 files changed, 37 insertions(+), 47 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 04512983b..1bd4b6078 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -2,6 +2,7 @@ package com.orgzly.android.espresso import android.net.Uri import android.os.Build +import android.os.SystemClock import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView @@ -89,6 +90,15 @@ class ContentRepoTest : OrgzlyTest() { assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) } + @Test + fun testCreateBookInSubfolder() { + setupContentRepo() + testUtils.setupBook("a folder/a book", "") + testUtils.sync() + assertEquals(1, syncRepo.books.size) + assertTrue(dataRepository.getBooks()[0].syncedTo!!.uri.toString().contains("a%20folder%2Fa%20book.org")) + } + @Test fun testLoadNotebookFromSubfolder() { setupContentRepo() @@ -263,21 +273,7 @@ class ContentRepoTest : OrgzlyTest() { */ @Throws(UiObjectNotFoundException::class) private fun setupContentRepo() { - ActivityScenario.launch(ReposActivity::class.java).use { - onView(withId(R.id.activity_repos_directory)).perform(click()) - onView(withId(R.id.activity_repo_directory_browse_button)) - .perform(click()) - // In Android file browser (Espresso cannot be used): - val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - // Back in Orgzly: - onView(isRoot()).perform(waitId(R.id.fab, 5000)) - onView(allOf(withId(R.id.fab), isDisplayed())).perform(click()) - } + addContentRepoInUi(repoDirName) repo = dataRepository.getRepos()[0] repoUri = Uri.parse(repo.url) syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as ContentRepo @@ -286,4 +282,27 @@ class ContentRepoTest : OrgzlyTest() { treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" assertEquals(treeDocumentFileUrl, repo.url) } + + companion object { + fun addContentRepoInUi(repoDirName: String) { + ActivityScenario.launch(ReposActivity::class.java).use { + onView(withId(R.id.activity_repos_directory)).perform(click()) + onView(withId(R.id.activity_repo_directory_browse_button)) + .perform(click()) + SystemClock.sleep(200) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() + SystemClock.sleep(100) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + // Back in Orgzly: + SystemClock.sleep(200) + onView(isRoot()).perform(waitId(R.id.fab, 5000)) + onView(allOf(withId(R.id.fab), isDisplayed())).perform(click()) + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 4a976544d..29ef4b76c 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -2,23 +2,15 @@ package com.orgzly.android.repos import android.net.Uri import android.os.Build -import android.os.SystemClock import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiSelector import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo -import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.espresso.ContentRepoTest import com.orgzly.android.git.GitFileSynchronizer import com.orgzly.android.git.GitPreferencesFromRepoPrefs import com.orgzly.android.prefs.AppPreferences @@ -29,11 +21,9 @@ import com.orgzly.android.repos.RepoType.DROPBOX import com.orgzly.android.repos.RepoType.GIT import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV -import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git -import org.hamcrest.core.AllOf import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assume @@ -68,8 +58,8 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( - Parameter(repoType = WEBDAV), Parameter(repoType = DOCUMENT), + Parameter(repoType = WEBDAV), Parameter(repoType = GIT), Parameter(repoType = DROPBOX), ) @@ -315,26 +305,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) if (repoDirDocumentFile?.exists() == false) { - ActivityScenario.launch(ReposActivity::class.java).use { - Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) - .perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) - .perform(ViewActions.click()) - SystemClock.sleep(300) - // In Android file browser (Espresso cannot be used): - val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - SystemClock.sleep(100) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirectoryName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - // Back in Orgzly: - SystemClock.sleep(200) - Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) - Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) - } + ContentRepoTest.addContentRepoInUi(repoDirectoryName) repo = dataRepository.getRepos()[0] } else { repo = testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) From 8c8fbdd235ab63e86fd0e91f3549a6e8437ccd43 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 24 Jul 2024 01:08:10 +0200 Subject: [PATCH 34/73] Support subfolders during force load and force save --- app/src/main/java/com/orgzly/android/BookName.java | 5 +++-- app/src/main/java/com/orgzly/android/data/DataRepository.kt | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/BookName.java b/app/src/main/java/com/orgzly/android/BookName.java index aeee48304..87c29c3eb 100644 --- a/app/src/main/java/com/orgzly/android/BookName.java +++ b/app/src/main/java/com/orgzly/android/BookName.java @@ -5,6 +5,7 @@ import androidx.documentfile.provider.DocumentFile; import com.orgzly.BuildConfig; +import com.orgzly.android.db.entity.BookView; import com.orgzly.android.repos.Rook; import com.orgzly.android.util.LogUtils; @@ -31,9 +32,9 @@ private BookName(String fileName, String name, BookFormat format) { mFormat = format; } - public static String getFileName(Context context, com.orgzly.android.db.entity.BookView bookView) { + public static String getFileName(BookView bookView) { if (bookView.getSyncedTo() != null) { - return getFileName(context, bookView.getSyncedTo().getUri()); + return getFileName(bookView.getSyncedTo().getRepoUri(), bookView.getSyncedTo().getUri()); } else { return fileName(bookView.getBook().getName(), BookFormat.ORG); diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index 40027bcb9..0861b2007 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -80,7 +80,7 @@ class DataRepository @Inject constructor( BookAction.Type.PROGRESS, resources.getString(R.string.force_loading_from_uri, book.linkRepo.url))) - val fileName = BookName.getFileName(context, book) + val fileName = BookName.getFileName(book) val loadedBook = loadBookFromRepo(book.linkRepo.id, book.linkRepo.type, book.linkRepo.url, fileName) @@ -103,7 +103,7 @@ class DataRepository @Inject constructor( val book = getBookView(bookId) ?: throw IOException(resources.getString(R.string.book_does_not_exist_anymore)) - val repositoryPath: String = BookName.getFileName(context, book) + val repositoryPath: String = BookName.getFileName(book) try { /* Prefer link. */ From 0b8be7ae9364354af4bba6afc93cc1f6880b81d4 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 24 Jul 2024 01:09:50 +0200 Subject: [PATCH 35/73] More support for subfolders in Webdav, Dropbox repos --- .../com/orgzly/android/repos/SyncRepoTest.kt | 154 ++++++++++++++++-- .../orgzly/android/repos/DropboxClient.java | 70 +++++--- .../com/orgzly/android/repos/WebdavRepo.kt | 33 +++- 3 files changed, 211 insertions(+), 46 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 29ef4b76c..b9fd3401a 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -59,8 +59,8 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { fun data(): Collection { return listOf( Parameter(repoType = DOCUMENT), - Parameter(repoType = WEBDAV), Parameter(repoType = GIT), + Parameter(repoType = WEBDAV), Parameter(repoType = DROPBOX), ) } @@ -86,7 +86,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) - fun testStoringFile() { + fun testLoadBook() { setupSyncRepo(param.repoType, null) val tmpFile = dataRepository.getTempBookFile() try { @@ -95,11 +95,54 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } finally { tmpFile.delete() } - val books = syncRepo.books - assertEquals(1, books.size.toLong()) - assertEquals("booky", BookName.getInstance(context, books[0]).name) - assertEquals("booky.org", BookName.getInstance(context, books[0]).fileName) - assertEquals(repo.url, books[0].repoUri.toString()) + val repoBooks = syncRepo.books + assertEquals(1, repoBooks.size.toLong()) + assertEquals(repo.url, repoBooks[0].repoUri.toString()) + testUtils.sync() + val books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the resulting notebook gets the right name + assertEquals("booky", books[0].book.name) + } + + @Test + @Throws(IOException::class) + fun testForceLoadBook() { + setupSyncRepo(param.repoType, null) + val bookView = testUtils.setupBook("booky", "content") + testUtils.sync() + var books = dataRepository.getBooks() + assertEquals(1, books.size) + assertEquals("booky", books[0].book.name) + dataRepository.forceLoadBook(bookView.book.id) + books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the name has not changed + assertEquals("booky", books[0].book.name) + } + + @Test + fun testLoadBookWithSpaceInName() { + setupSyncRepo(param.repoType, null) + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "book one.org") + } finally { + tmpFile.delete() + } + val repoBooks = syncRepo.books + assertEquals(1, repoBooks.size.toLong()) + assertEquals(repo.url, repoBooks[0].repoUri.toString()) + // Check that the notebook gets the right name based on the repository file's name + assertEquals("book one", BookName.getInstance(context, repoBooks[0]).name) + // Check that the remote filename is parsed and stored correctly + assertEquals("book one.org", BookName.getInstance(context, repoBooks[0]).fileName) + // Check that the resulting local book gets the right name + testUtils.sync() + val books = dataRepository.getBooks() + assertEquals(1, books.size) + assertEquals("book one", books[0].book.name) } @Test @@ -124,7 +167,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testSyncNewBookWithoutLinkAndOneRepo() { setupSyncRepo(param.repoType, null) - testUtils.setupBook("book 1", "content") + testUtils.setupBook("Book 1", "content") testUtils.sync() val bookView = dataRepository.getBooks()[0] assertEquals(repo.url, bookView.linkRepo?.url) @@ -135,12 +178,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { bookView.book.lastAction!!.message ) val expectedUriString = when (param.repoType) { - GIT -> "/book 1.org" + GIT -> "/Book 1.org" MOCK -> TODO() - DROPBOX -> "dropbox:/orgzly-android-tests/book%201.org" + DROPBOX -> "dropbox:/orgzly-android-tests/Book%201.org" DIRECTORY -> TODO() - DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$repoDirectoryName/document/primary%3A$repoDirectoryName%2Fbook%201.org" - WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived%40gmail.com/$repoDirectoryName/book 1.org" + DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$repoDirectoryName/document/primary%3A$repoDirectoryName%2FBook%201.org" + WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/$repoDirectoryName/Book%201.org" } assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) } @@ -168,6 +211,33 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertTrue(bookView.syncedTo!!.uri.toString().contains("newname.org")) } + @Test + fun testRenameBookToNameWithSpace() { + setupSyncRepo(param.repoType, null) + testUtils.setupBook("oldname", "") + testUtils.sync() + var bookView = dataRepository.getBookView("oldname") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertTrue(bookView.syncedTo!!.uri.toString().contains("oldname.org")) + + dataRepository.renameBook(bookView, "new name") + + assertEquals(1, syncRepo.books.size.toLong()) + assertEquals( + "new name.org", + BookName.getInstance(context, syncRepo.books[0]).fileName + ) + bookView = dataRepository.getBookView("new name") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + val expectedRookUriName = when (param.repoType) { + GIT -> "new name.org" + else -> { "new%20name.org" } + } + assertTrue(bookView.syncedTo!!.uri.toString().endsWith(expectedRookUriName)) + } + @Test fun testRenameBookToExistingRepoFileName() { setupSyncRepo(param.repoType, null) @@ -269,6 +339,66 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { testUtils.syncOrThrow() } + @Test + // @Ignore("Not yet implemented for all repo types") + fun testStoreBookInSubfolder() { + setupSyncRepo(param.repoType, null) + testUtils.setupBook("a folder/a book", "") + testUtils.sync() + assertEquals(1, syncRepo.books.size) + val expectedRookUri = when (param.repoType) { + WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/orgzly-android-tests/a%20folder/a%20book.org" + DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-android-tests/document/primary%3Aorgzly-android-tests%2Fa%20folder%2Fa%20book.org" + MOCK -> TODO() + DROPBOX -> "dropbox:/orgzly-android-tests/a%20folder/a%20book.org" + DIRECTORY -> TODO() + GIT -> "/a folder/a book.org" + } + assertEquals(expectedRookUri, dataRepository.getBooks()[0].syncedTo!!.uri.toString()) + assertEquals("a folder/a book", dataRepository.getBooks()[0].book.name) + } + + @Test + @Throws(IOException::class) + fun testLoadBookFromSubfolder() { + setupSyncRepo(param.repoType, null) + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "a folder/a book.org") + } finally { + tmpFile.delete() + } + val repoBooks = syncRepo.books + assertEquals(1, repoBooks.size.toLong()) + assertEquals(repo.url, repoBooks[0].repoUri.toString()) + testUtils.sync() + val books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the resulting notebook gets the right name + assertEquals("a folder/a book", books[0].book.name) + } + + *//** + * Ensures that file names and book names are not parsed/created differently during + * force-loading. + *//* + @Test + @Throws(IOException::class) + fun testForceLoadBookInSubfolder() { + setupSyncRepo(param.repoType, null) + val bookView = testUtils.setupBook("a folder/a book", "content") + testUtils.sync() + var books = dataRepository.getBooks() + assertEquals(1, books.size) + assertEquals("a folder/a book", books[0].book.name) + dataRepository.forceLoadBook(bookView.book.id) + books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the name has not changed + assertEquals("a folder/a book", books[0].book.name) + } + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { when (repoType) { GIT -> setupGitRepo() diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index e1729a829..6779006b2 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -145,41 +145,56 @@ public List getBooks(Uri repoUri, RepoIgnoreNode ignores) throws /* Strip trailing slashes. */ path = path.replaceAll("/+$", ""); + List folderPaths = new ArrayList<>(List.of(path)); + try { if (ROOT_PATH.equals(path) || dbxClient.files().getMetadata(path) instanceof FolderMetadata) { /* Get folder content. */ - ListFolderResult result = dbxClient.files().listFolder(path); - while (true) { - for (Metadata metadata : result.getEntries()) { - if (metadata instanceof FileMetadata) { - FileMetadata file = (FileMetadata) metadata; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (ignores.isPathIgnored(file.getName(), false)) { - continue; + while (folderPaths.size() > 0) { + ListFolderResult result = dbxClient.files().listFolder(folderPaths.remove(0)); + while (true) { + for (Metadata metadata : result.getEntries()) { + String pathRelativeToRepoRoot = + metadata.getPathDisplay().replaceAll("^" + path + "/", ""); + if (metadata instanceof FileMetadata) { + FileMetadata file = (FileMetadata) metadata; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ignores.isPathIgnored(pathRelativeToRepoRoot, false)) { + continue; + } } - } - if (BookName.isSupportedFormatFileName(file.getName())) { - Uri uri = repoUri.buildUpon().appendPath(file.getName()).build(); - VersionedRook book = new VersionedRook( - repoId, - RepoType.DROPBOX, - repoUri, - uri, - file.getRev(), - file.getServerModified().getTime()); - - list.add(book); + if (BookName.isSupportedFormatFileName(file.getName())) { + String encodedRelativePath = Uri.encode(pathRelativeToRepoRoot, "/"); + Uri uri = repoUri.buildUpon().appendEncodedPath(encodedRelativePath).build(); + VersionedRook book = new VersionedRook( + repoId, + RepoType.DROPBOX, + repoUri, + uri, + file.getRev(), + file.getServerModified().getTime()); + + list.add(book); + } + } + if (metadata instanceof FolderMetadata) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ignores.isPathIgnored(pathRelativeToRepoRoot, true)) { + continue; + } + } + folderPaths.add(metadata.getPathDisplay()); } } - } - if (!result.getHasMore()) { - break; - } + if (!result.getHasMore()) { + break; + } - result = dbxClient.files().listFolderContinue(result.getCursor()); + result = dbxClient.files().listFolderContinue(result.getCursor()); + } } } else { @@ -270,7 +285,8 @@ public InputStream streamFile(Uri repoUri, String fileName) throws IOException { public VersionedRook upload(File file, Uri repoUri, String fileName) throws IOException { linkedOrThrow(); - Uri bookUri = repoUri.buildUpon().appendPath(fileName).build(); + String encodedFileName = Uri.encode(fileName).replace("%2F", "/"); + Uri bookUri = Uri.withAppendedPath(repoUri, encodedFileName); if (file.length() > UPLOAD_FILE_SIZE_LIMIT * 1024 * 1024) { throw new IOException(LARGE_FILE); diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index 2e4de8286..30e96ff06 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -2,7 +2,6 @@ package com.orgzly.android.repos import android.net.Uri import android.os.Build -import androidx.documentfile.provider.DocumentFile import com.burgstaller.okhttp.AuthenticationCacheInterceptor import com.burgstaller.okhttp.CachingAuthenticatorDecorator import com.burgstaller.okhttp.DispatchingAuthenticator @@ -29,6 +28,7 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import kotlin.collections.ArrayList class WebdavRepo( @@ -168,16 +168,16 @@ class WebdavRepo( val ignores = RepoIgnoreNode(this) return sardine - .list(url) + .list(url, -1) .mapNotNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.name, false)) { + if (!BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.name, false)) { null } else { it.toVersionedRook() } } else { - if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name)) { + if (!BookName.isSupportedFormatFileName(it.name)) { null } else { it.toVersionedRook() @@ -206,8 +206,26 @@ class WebdavRepo( return sardine.get(fileUrl) } + private fun ensureDirectoryHierarchy(relativePath: String) { + val levels: ArrayList = ArrayList(relativePath.split("/")) + var currentDir: String = uri.toString() + while (levels.size > 1) { + val nextDirName: String = levels.removeAt(0) + currentDir = "$currentDir/$nextDirName" + if (!sardine.exists(currentDir)) { + sardine.createDirectory(currentDir) + } + } + } + override fun storeBook(file: File?, fileName: String?): VersionedRook { - val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + val encodedFileName = Uri.encode(fileName, "/") + if (encodedFileName != null) { + if (encodedFileName.contains("/")) { + ensureDirectoryHierarchy(encodedFileName) + } + } + val fileUrl = uri.buildUpon().appendEncodedPath(encodedFileName).build().toUrl() sardine.put(fileUrl, file, null) @@ -231,12 +249,13 @@ class WebdavRepo( } private fun DavResource.toVersionedRook(): VersionedRook { + val fullUrl = Uri.parse(uri.scheme + "://" + uri.authority + this.href.toString()) return VersionedRook( repoId, RepoType.WEBDAV, uri, - Uri.withAppendedPath(uri, this.name), - this.name + this.modified.time.toString(), + fullUrl, + this.modified.time.toString(), this.modified.time ) } From e029516f5a23f5964a61280dd877ee981c5051a2 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 24 Jul 2024 13:46:41 +0200 Subject: [PATCH 36/73] wip --- .../android/espresso/ContentRepoTest.kt | 89 ------------------- .../com/orgzly/android/repos/SyncRepoTest.kt | 84 ++++++++++++++++- 2 files changed, 82 insertions(+), 91 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index 1bd4b6078..efd47fdd6 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -90,95 +90,6 @@ class ContentRepoTest : OrgzlyTest() { assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) } - @Test - fun testCreateBookInSubfolder() { - setupContentRepo() - testUtils.setupBook("a folder/a book", "") - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertTrue(dataRepository.getBooks()[0].syncedTo!!.uri.toString().contains("a%20folder%2Fa%20book.org")) - } - - @Test - fun testLoadNotebookFromSubfolder() { - setupContentRepo() - // Write org file to subfolder - writeStringToRepoFile("content", "a folder/a book.org") - - testUtils.sync() - - val books = dataRepository.getBooks() - assertEquals(1, books.size.toLong()) - assertEquals("a folder/a book", books[0].book.name) - assertEquals(repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org", books[0].syncedTo?.uri.toString()) - } - - @Test - fun testIgnoreFileInSubfolder() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupContentRepo() - // Add .orgzlyignore file - writeStringToRepoFile("subfolder1/book1.org", RepoIgnoreNode.IGNORE_FILE) - // Write 2 org files to subfolder - writeStringToRepoFile("content", "subfolder1/book1.org") - writeStringToRepoFile("content", "subfolder1/book2.org") - - testUtils.sync() - - val books = dataRepository.getBooks() - assertEquals(1, books.size.toLong()) - assertEquals("subfolder1/book2", books[0].book.name) - } - - @Test - fun testUnIgnoreSingleFileInSubfolder() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupContentRepo() - // Add .orgzlyignore file - writeStringToRepoFile("subfolder1/**\n!subfolder1/book2.org", RepoIgnoreNode.IGNORE_FILE) - // Write 2 org files to subfolder - writeStringToRepoFile("content", "subfolder1/book1.org") - writeStringToRepoFile("content", "subfolder1/book2.org") - - testUtils.sync() - - val books = dataRepository.getBooks() - assertEquals(1, books.size.toLong()) - assertEquals("subfolder1/book2", books[0].book.name) - } - - @Test - fun testUpdateBookInSubfolder() { - setupContentRepo() - // Create org file in subfolder - writeStringToRepoFile("* DONE Heading 1", "folder one/book one.org") - - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - - ActivityScenario.launch(MainActivity::class.java).use { - // Modify book - onBook(0).perform(click()) - onNoteInBook(1).perform(longClick()) - onView(withId(R.id.toggle_state)).perform(click()) - pressBack() - pressBack() - sync() - onBook(0, R.id.item_book_last_action).check( - matches(withText(endsWith("Saved to content://com.android.externalstorage.documents/tree/primary%3A$repoDirName"))) - ) - // Delete notebook from Orgzly and reload it to verify that our change was successfully written - onBook(0).perform(longClick()) - contextualToolbarOverflowMenu().perform(click()) - onView(withText(R.string.delete)).perform(click()) - onView(withText(R.string.delete)).perform(click()) - } - - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") - } - @Test fun testRenameBookFromRootToSubfolder() { setupContentRepo() diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index b9fd3401a..a533b28eb 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -4,6 +4,10 @@ import android.net.Uri import android.os.Build import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.BookName @@ -11,6 +15,7 @@ import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo import com.orgzly.android.espresso.ContentRepoTest +import com.orgzly.android.espresso.util.EspressoUtils import com.orgzly.android.git.GitFileSynchronizer import com.orgzly.android.git.GitPreferencesFromRepoPrefs import com.orgzly.android.prefs.AppPreferences @@ -21,6 +26,7 @@ import com.orgzly.android.repos.RepoType.DROPBOX import com.orgzly.android.repos.RepoType.GIT import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV +import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git @@ -379,10 +385,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals("a folder/a book", books[0].book.name) } - *//** + /** * Ensures that file names and book names are not parsed/created differently during * force-loading. - *//* + */ @Test @Throws(IOException::class) fun testForceLoadBookInSubfolder() { @@ -399,6 +405,80 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals("a folder/a book", books[0].book.name) } + @Test + fun testIgnoreFileInSubfolder() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(param.repoType, "subfolder1/book1.org") + // Write 2 org files to subfolder in repo + for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book2", books[0].book.name) + } + + @Test + fun testUnIgnoreSingleFileInSubfolder() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(param.repoType, "subfolder1/**\n!subfolder1/book2.org") + // Write 2 org files to subfolder in repo + for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book2", books[0].book.name) + } + + @Test + fun testUpdateBookInSubfolder() { + setupSyncRepo(param.repoType, null) + // Create org file in subfolder + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("* DONE Heading 1", tmpFile) + syncRepo.storeBook(tmpFile, "folder one/book one.org") + } finally { + tmpFile.delete() + } + + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + + ActivityScenario.launch(MainActivity::class.java).use { + // Modify book + EspressoUtils.onBook(0).perform(ViewActions.click()) + EspressoUtils.onNoteInBook(1).perform(ViewActions.longClick()) + Espresso.onView(ViewMatchers.withId(R.id.toggle_state)).perform(ViewActions.click()) + Espresso.pressBack() + Espresso.pressBack() + EspressoUtils.sync() + // EspressoUtils.onBook(0, R.id.item_book_last_action).check(ViewAssertions.matches(ViewMatchers.withText(CoreMatchers.endsWith("Saved to content://com.android.externalstorage.documents/tree/primary%3A$repoDirName"))) ) + // Delete notebook from Orgzly and reload it to verify that our change was successfully written + EspressoUtils.onBook(0).perform(ViewActions.longClick()) + EspressoUtils.contextualToolbarOverflowMenu().perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withText(R.string.delete)).perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withText(R.string.delete)).perform(ViewActions.click()) + } + + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") + } + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { when (repoType) { GIT -> setupGitRepo() From b47a432078b972db9daf4ff9e62e0fce477699ff Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 24 Jul 2024 14:44:14 +0200 Subject: [PATCH 37/73] Handle content repo differences on API < 33 --- .../android/espresso/ContentRepoTest.kt | 23 ++++++++++++++----- .../com/orgzly/android/repos/SyncRepoTest.kt | 12 ++++++---- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index efd47fdd6..baefb1e13 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -203,12 +203,23 @@ class ContentRepoTest : OrgzlyTest() { SystemClock.sleep(200) // In Android file browser (Espresso cannot be used): val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - SystemClock.sleep(100) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() + if (Build.VERSION.SDK_INT < 33) { + // Older system file picker UI + mDevice.findObject(UiSelector().description("More options")).click() + mDevice.findObject(UiSelector().text("New folder")).click() + SystemClock.sleep(100) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } else { + mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() + SystemClock.sleep(100) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } // Back in Orgzly: SystemClock.sleep(200) onView(isRoot()).perform(waitId(R.id.fab, 5000)) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index a533b28eb..4044481cf 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -66,8 +66,8 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { return listOf( Parameter(repoType = DOCUMENT), Parameter(repoType = GIT), - Parameter(repoType = WEBDAV), Parameter(repoType = DROPBOX), + Parameter(repoType = WEBDAV), ) } } @@ -188,7 +188,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { MOCK -> TODO() DROPBOX -> "dropbox:/orgzly-android-tests/Book%201.org" DIRECTORY -> TODO() - DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3A$repoDirectoryName/document/primary%3A$repoDirectoryName%2FBook%201.org" + DOCUMENT -> "$treeDocumentFileUrl/document/primary%3A$repoDirectoryName%2FBook%201.org" WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/$repoDirectoryName/Book%201.org" } assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) @@ -354,7 +354,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, syncRepo.books.size) val expectedRookUri = when (param.repoType) { WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/orgzly-android-tests/a%20folder/a%20book.org" - DOCUMENT -> "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-android-tests/document/primary%3Aorgzly-android-tests%2Fa%20folder%2Fa%20book.org" + DOCUMENT -> "$treeDocumentFileUrl/document/primary%3Aorgzly-android-tests%2Fa%20folder%2Fa%20book.org" MOCK -> TODO() DROPBOX -> "dropbox:/orgzly-android-tests/a%20folder/a%20book.org" DIRECTORY -> TODO() @@ -512,7 +512,11 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private fun setupContentRepo() { val encodedRepoDirName = Uri.encode(repoDirectoryName) documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" - treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" + treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 33) { + "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName" + } else { + "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" + } val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) if (repoDirDocumentFile?.exists() == false) { ContentRepoTest.addContentRepoInUi(repoDirectoryName) From 8e81fd716775b4b1f3695338814a8ec060c8af94 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 25 Jul 2024 14:12:31 +0200 Subject: [PATCH 38/73] Handle API 29 behavior in SyncRepoTest --- .../java/com/orgzly/android/repos/SyncRepoTest.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 4044481cf..5d7f32e01 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -55,7 +55,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private lateinit var gitFileSynchronizer: GitFileSynchronizer // used by ContentRepo private lateinit var documentTreeSegment: String - private lateinit var treeDocumentFileUrl: String data class Parameter(val repoType: RepoType) @@ -188,7 +187,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { MOCK -> TODO() DROPBOX -> "dropbox:/orgzly-android-tests/Book%201.org" DIRECTORY -> TODO() - DOCUMENT -> "$treeDocumentFileUrl/document/primary%3A$repoDirectoryName%2FBook%201.org" + DOCUMENT -> repo.url + documentTreeSegment + "Book%201.org" WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/$repoDirectoryName/Book%201.org" } assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) @@ -354,7 +353,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, syncRepo.books.size) val expectedRookUri = when (param.repoType) { WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/orgzly-android-tests/a%20folder/a%20book.org" - DOCUMENT -> "$treeDocumentFileUrl/document/primary%3Aorgzly-android-tests%2Fa%20folder%2Fa%20book.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org" MOCK -> TODO() DROPBOX -> "dropbox:/orgzly-android-tests/a%20folder/a%20book.org" DIRECTORY -> TODO() @@ -511,8 +510,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private fun setupContentRepo() { val encodedRepoDirName = Uri.encode(repoDirectoryName) - documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" - treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 33) { + documentTreeSegment = if (Build.VERSION.SDK_INT < 33) { + "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName%2F" + } else { + "/document/primary%3A$encodedRepoDirName%2F" + } + val treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 33) { "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName" } else { "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" @@ -529,7 +532,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } private fun tearDownContentRepo() { - val repoDirectory = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) + val repoDirectory = DocumentFile.fromTreeUri(context, repo.url.toUri()) for (file in repoDirectory!!.listFiles()) { file.delete() } From 5ed19681b9f58b491db14d9adaa0ff2f398435ea Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 25 Jul 2024 14:13:07 +0200 Subject: [PATCH 39/73] Improve ContentRepo.storeBook() after tests on API 29 The DocumentFile.exists() method always returned true for not-yet-created subdirectories (cf. https://stackoverflow.com/q/51661385). Also, rename the "fileName" argument back to "path", since it is now actually a path name, not a file name. (This should be done in more places, eventually.) --- .../com/orgzly/android/repos/ContentRepo.java | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index f56da3bf8..d49246bad 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -170,27 +170,25 @@ public InputStream openRepoFileInputStream(String fileName) throws IOException { } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String path) throws IOException { if (!file.exists()) { throw new FileNotFoundException("File " + file + " does not exist"); } - DocumentFile destinationFile = getDocumentFileFromFileName(fileName); - if (!destinationFile.exists()) { - if (fileName.contains("/")) { - DocumentFile destinationDir = ensureDirectoryHierarchy(fileName); - destinationFile = destinationDir.createFile("text/*", Uri.parse(fileName).getLastPathSegment()); - } else { - repoDocumentFile.createFile("text/*", fileName); + DocumentFile destinationFile = getDocumentFileFromFileName(path); + if (path.contains("/")) { + DocumentFile destinationDir = ensureDirectoryHierarchy(path); + String fileName = Uri.parse(path).getLastPathSegment(); + if (destinationDir.findFile(fileName) == null) { + destinationFile = destinationDir.createFile("text/*", fileName); + } + } else { + if (!destinationFile.exists()) { + repoDocumentFile.createFile("text/*", path); } } - OutputStream out = context.getContentResolver().openOutputStream(destinationFile.getUri()); - try { + try (OutputStream out = context.getContentResolver().openOutputStream(destinationFile.getUri())) { MiscUtils.writeFileToStream(file, out); - } finally { - if (out != null) { - out.close(); - } } long mtime = destinationFile.lastModified(); From 3ff5a4b638b7c0c06dada900fbbe5b3a73d864db Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Thu, 25 Jul 2024 14:31:25 +0200 Subject: [PATCH 40/73] Add sleep to a flaky Espresso test --- .../java/com/orgzly/android/espresso/QueryFragmentTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index 0afbd5ef3..fe2d85478 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -385,6 +385,7 @@ public void testInheritedAndOwnTag() { scenario = ActivityScenario.launch(MainActivity.class); onView(allOf(withText("notebook-1"), isDisplayed())).perform(click()); + SystemClock.sleep(200); searchForTextCloseKeyboard("t.tag1 t.tag2"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(3))); From 7658f09f5d870a9ceb39fda2a21f692cfce63a41 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Fri, 26 Jul 2024 07:58:32 +0200 Subject: [PATCH 41/73] Ensure that Git repo file paths start with a slash Because this is what they look like according to Git, and we don't want our remote book file "URL" to look different from the repo's view. --- app/src/main/java/com/orgzly/android/repos/GitRepo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 4fae7c6b5..1c8c10ddf 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -208,7 +208,7 @@ RevCommit getCommitFromRevisionString(String revisionString) throws IOException @Override public VersionedRook retrieveBook(String fileName, File destination) throws IOException { - Uri sourceUri = Uri.parse(fileName); + Uri sourceUri = Uri.parse("/" + fileName); // Ensure our repo copy is up-to-date. This is necessary when force-loading a book. synchronizer.mergeWithRemote(); From ca4cd77caf4ce87537c4a6a71a3a733f85fe6401 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Fri, 26 Jul 2024 08:13:51 +0200 Subject: [PATCH 42/73] Dropbox: Ensure storeBook and retrieveBook return the same rook URI --- .../com/orgzly/android/repos/SyncRepoTest.kt | 28 +++++++++++++++++++ .../orgzly/android/repos/DropboxClient.java | 14 ++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 5d7f32e01..39f24d2ce 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -442,6 +442,34 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals("subfolder1/book2", books[0].book.name) } + @Test + fun testStoreBookAndRetrieveBookProducesSameRookUri() { + setupSyncRepo(param.repoType, null) + + val repoFilePath = "folder one/book one.org" + + // Upload file to repo + val storedBook: VersionedRook? + var tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("content", tmpFile) + storedBook = syncRepo.storeBook(tmpFile, repoFilePath) + } finally { + tmpFile.delete() + } + + // Download file from repo + tmpFile = dataRepository.getTempBookFile() + val retrievedBook: VersionedRook? + try { + retrievedBook = syncRepo.retrieveBook(repoFilePath, tmpFile) + } finally { + tmpFile.delete() + } + + assertEquals(storedBook!!.uri, retrievedBook!!.uri!!) + } + @Test fun testUpdateBookInSubfolder() { setupSyncRepo(param.repoType, null) diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index 6779006b2..fb80c6ef6 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -219,13 +219,18 @@ public List getBooks(Uri repoUri, RepoIgnoreNode ignores) throws return list; } + private Uri getFullUriFromRelativePath(Uri repoUri, String relativePath) { + String encodedFileName = Uri.encode(relativePath, "/"); + return Uri.withAppendedPath(repoUri, encodedFileName); + } + /** * Download file from Dropbox and store it to a local file. */ - public VersionedRook download(Uri repoUri, String fileName, File localFile) throws IOException { + public VersionedRook download(Uri repoUri, String relativePath, File localFile) throws IOException { linkedOrThrow(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = getFullUriFromRelativePath(repoUri, relativePath); OutputStream out = new BufferedOutputStream(new FileOutputStream(localFile)); @@ -282,11 +287,10 @@ public InputStream streamFile(Uri repoUri, String fileName) throws IOException { } /** Upload file to Dropbox. */ - public VersionedRook upload(File file, Uri repoUri, String fileName) throws IOException { + public VersionedRook upload(File file, Uri repoUri, String relativePath) throws IOException { linkedOrThrow(); - String encodedFileName = Uri.encode(fileName).replace("%2F", "/"); - Uri bookUri = Uri.withAppendedPath(repoUri, encodedFileName); + Uri bookUri = getFullUriFromRelativePath(repoUri, relativePath); if (file.length() > UPLOAD_FILE_SIZE_LIMIT * 1024 * 1024) { throw new IOException(LARGE_FILE); From fd524bf8798920ff0197e0f4cfc2efdb0e451284 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 10:45:31 +0200 Subject: [PATCH 43/73] WebdavRepo seems complete We now have successful tests of - WebdavRepo.getBooks - WebdavRepo.storeBook - WebdavRepo.retrieveBook - WebdavRepo.renameBook --- .../android/espresso/ContentRepoTest.kt | 124 +------------ .../com/orgzly/android/repos/SyncRepoTest.kt | 174 ++++++++++++++++-- .../com/orgzly/android/repos/WebdavRepo.kt | 41 +++-- 3 files changed, 190 insertions(+), 149 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt index baefb1e13..4fec8e20d 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt @@ -1,64 +1,39 @@ package com.orgzly.android.espresso -import android.net.Uri import android.os.Build import android.os.SystemClock import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.longClick -import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObjectNotFoundException import androidx.test.uiautomator.UiSelector import com.orgzly.R -import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest -import com.orgzly.android.db.entity.BookView -import com.orgzly.android.db.entity.Repo -import com.orgzly.android.espresso.util.EspressoUtils.contextualToolbarOverflowMenu -import com.orgzly.android.espresso.util.EspressoUtils.onBook -import com.orgzly.android.espresso.util.EspressoUtils.onNoteInBook -import com.orgzly.android.espresso.util.EspressoUtils.sync import com.orgzly.android.espresso.util.EspressoUtils.waitId import com.orgzly.android.repos.ContentRepo -import com.orgzly.android.repos.RepoIgnoreNode import com.orgzly.android.repos.RepoType -import com.orgzly.android.sync.BookSyncStatus -import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils -import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.core.AllOf.allOf import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Assume import org.junit.Before -import org.junit.Rule import org.junit.Test -import org.junit.rules.ExpectedException import java.io.File import java.io.FileNotFoundException -import java.io.IOException class ContentRepoTest : OrgzlyTest() { - private var repoDirName = "orgzly-local-dir-repo-test" - private lateinit var encodedRepoDirName: String - private lateinit var documentTreeSegment: String - private lateinit var treeDocumentFileUrl: String - private lateinit var repo: Repo + private var repoDirName = "orgzly-android-tests" private lateinit var syncRepo: ContentRepo - private lateinit var repoUri: Uri @Before @Throws(Exception::class) @@ -70,15 +45,11 @@ class ContentRepoTest : OrgzlyTest() { @Throws(Exception::class) override fun tearDown() { super.tearDown() - if (this::treeDocumentFileUrl.isInitialized) { - DocumentFile.fromTreeUri(context, Uri.parse(treeDocumentFileUrl))!!.delete() + if (this::syncRepo.isInitialized) { + DocumentFile.fromTreeUri(context, syncRepo.uri)!!.delete() } } - @Rule - @JvmField - var exceptionRule: ExpectedException = ExpectedException.none() - @Test @Throws(FileNotFoundException::class) fun testSyncWithDirectoryContainingPercent() { @@ -87,87 +58,7 @@ class ContentRepoTest : OrgzlyTest() { writeStringToRepoFile("Notebook content 1", "notebook.org") testUtils.sync() assertEquals(1, dataRepository.getBooks().size.toLong()) - assertEquals("content://com.android.externalstorage.documents/tree/primary%3Aspace%20separated", syncRepo.uri.toString()) - } - - @Test - fun testRenameBookFromRootToSubfolder() { - setupContentRepo() - testUtils.setupBook("booky", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") - assertTrue(dataRepository.getBookView("a/b")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(dataRepository.getBook("a/b")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - assertEquals( - "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fa%2Fb.org", - syncRepo.books[0].uri.toString() - ) - assertEquals(1, dataRepository.getBooks().size.toLong()) - } - - @Test - fun testRenameBookFromSubfolderToRoot() { - setupContentRepo() - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") - assertTrue(dataRepository.getBookView("booky")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(dataRepository.getBook("booky")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - assertEquals( - "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fbooky.org", - syncRepo.books[0].uri.toString() - ) - assertEquals(1, dataRepository.getBooks().size.toLong()) - } - - @Test - fun testRenameBookNewSubfolderSameLeafName() { - setupContentRepo() - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") - assertTrue(dataRepository.getBookView("b/b")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(dataRepository.getBook("b/b")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - assertEquals( - "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fb%2Fb.org", - syncRepo.books[0].uri.toString() - ) - assertEquals(1, dataRepository.getBooks().size.toLong()) - } - - @Test - fun testRenameBookNewSubfolderAndLeafName() { - setupContentRepo() - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") - assertTrue(dataRepository.getBookView("b/c")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(dataRepository.getBook("b/c")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - assertEquals( - "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fb%2Fc.org", - syncRepo.books[0].uri.toString() - ) - assertEquals(1, dataRepository.getBooks().size.toLong()) - } - - @Test - fun testRenameBookSameSubfolderNewLeafName() { - setupContentRepo() - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") - assertTrue(dataRepository.getBookView("a/c")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(dataRepository.getBook("a/c")!!.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - assertEquals( - "content://com.android.externalstorage.documents/tree/primary%3Aorgzly-local-dir-repo-test/document/primary%3Aorgzly-local-dir-repo-test%2Fa%2Fc.org", - syncRepo.books[0].uri.toString() - ) - assertEquals(1, dataRepository.getBooks().size.toLong()) + assertTrue(syncRepo.uri.toString().contains("space%20separated")) } private fun writeStringToRepoFile(content: String, fileName: String) { @@ -185,13 +76,8 @@ class ContentRepoTest : OrgzlyTest() { @Throws(UiObjectNotFoundException::class) private fun setupContentRepo() { addContentRepoInUi(repoDirName) - repo = dataRepository.getRepos()[0] - repoUri = Uri.parse(repo.url) + val repo = dataRepository.getRepos()[0] syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as ContentRepo - encodedRepoDirName = Uri.encode(repoDirName) - documentTreeSegment = "/document/primary%3A$encodedRepoDirName%2F" - treeDocumentFileUrl = "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" - assertEquals(treeDocumentFileUrl, repo.url) } companion object { diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 39f24d2ce..847508d1b 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -7,6 +7,7 @@ import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers import com.orgzly.BuildConfig import com.orgzly.R @@ -26,10 +27,12 @@ import com.orgzly.android.repos.RepoType.DROPBOX import com.orgzly.android.repos.RepoType.GIT import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV +import com.orgzly.android.sync.BookSyncStatus import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git +import org.hamcrest.CoreMatchers.containsString import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assume @@ -39,20 +42,25 @@ import org.junit.rules.ExpectedException import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.io.File +import java.io.FileNotFoundException import java.io.IOException import java.nio.file.Path +import java.util.UUID import kotlin.io.path.createTempDirectory @RunWith(value = Parameterized::class) class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { - private val repoDirectoryName = "orgzly-android-tests" + private val permanentRepoTestDir = "orgzly-android-tests" + private var topDirName = RANDOM_UUID private lateinit var repo: Repo private lateinit var syncRepo: SyncRepo + // Used by GitRepo private lateinit var gitWorkingTree: File private lateinit var gitBareRepoPath: Path private lateinit var gitFileSynchronizer: GitFileSynchronizer + // used by ContentRepo private lateinit var documentTreeSegment: String @@ -63,12 +71,17 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( - Parameter(repoType = DOCUMENT), - Parameter(repoType = GIT), +// Parameter(repoType = GIT), +// Parameter(repoType = DOCUMENT), Parameter(repoType = DROPBOX), - Parameter(repoType = WEBDAV), +// Parameter(repoType = WEBDAV), ) } + + /* For creating a unique directory per test suite instance for tests which interact with + the cloud (Dropbox, Webdav), to avoid collisions when they are run simultaneously on + different devices. */ + val RANDOM_UUID = UUID.randomUUID().toString() } override fun tearDown() { @@ -184,11 +197,8 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { ) val expectedUriString = when (param.repoType) { GIT -> "/Book 1.org" - MOCK -> TODO() - DROPBOX -> "dropbox:/orgzly-android-tests/Book%201.org" - DIRECTORY -> TODO() DOCUMENT -> repo.url + documentTreeSegment + "Book%201.org" - WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/$repoDirectoryName/Book%201.org" + else -> { repo.url + "/Book%201.org" } } assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) } @@ -352,12 +362,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { testUtils.sync() assertEquals(1, syncRepo.books.size) val expectedRookUri = when (param.repoType) { - WEBDAV -> "https://use10.thegood.cloud/remote.php/dav/files/orgzlyrevived@gmail.com/orgzly-android-tests/a%20folder/a%20book.org" - DOCUMENT -> repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org" - MOCK -> TODO() - DROPBOX -> "dropbox:/orgzly-android-tests/a%20folder/a%20book.org" - DIRECTORY -> TODO() GIT -> "/a folder/a book.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org" + else -> { repo.url + "/a%20folder/a%20book.org" } } assertEquals(expectedRookUri, dataRepository.getBooks()[0].syncedTo!!.uri.toString()) assertEquals("a folder/a book", dataRepository.getBooks()[0].book.name) @@ -493,7 +500,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { Espresso.pressBack() Espresso.pressBack() EspressoUtils.sync() - // EspressoUtils.onBook(0, R.id.item_book_last_action).check(ViewAssertions.matches(ViewMatchers.withText(CoreMatchers.endsWith("Saved to content://com.android.externalstorage.documents/tree/primary%3A$repoDirName"))) ) + EspressoUtils.onBook(0, R.id.item_book_last_action).check(matches(ViewMatchers.withText(containsString("Saved to ")))) // Delete notebook from Orgzly and reload it to verify that our change was successfully written EspressoUtils.onBook(0).perform(ViewActions.longClick()) EspressoUtils.contextualToolbarOverflowMenu().perform(ViewActions.click()) @@ -506,6 +513,135 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") } + @Test + fun testRenameBookFromRootToSubfolder() { + setupSyncRepo(param.repoType, "") + testUtils.setupBook("booky", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") + assertTrue(dataRepository.getBookView("a/b")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("a/b") + assertEquals(BookSyncStatus.NO_CHANGE.toString(), bookView!!.book.syncStatus) + val expectedRookUri = when (param.repoType) { + GIT -> "/a/b.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%2Fb.org" + else -> { repo.url + "/a/b.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookFromSubfolderToRoot() { + setupSyncRepo(param.repoType, "") + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") + assertTrue(dataRepository.getBookView("booky")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("booky") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (param.repoType) { + GIT -> "/booky.org" + DOCUMENT -> repo.url + documentTreeSegment + "booky.org" + else -> { repo.url + "/booky.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookNewSubfolderSameLeafName() { + setupSyncRepo(param.repoType, "") + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") + assertTrue(dataRepository.getBookView("b/b")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("b/b") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (param.repoType) { + GIT -> "/b/b.org" + DOCUMENT -> repo.url + documentTreeSegment + "b%2Fb.org" + else -> { repo.url + "/b/b.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookNewSubfolderAndLeafName() { + setupSyncRepo(param.repoType, "") + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") + assertTrue(dataRepository.getBookView("b/c")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("b/c") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (param.repoType) { + GIT -> "/b/c.org" + DOCUMENT -> repo.url + documentTreeSegment + "b%2Fc.org" + else -> { repo.url + "/b/c.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookSameSubfolderNewLeafName() { + setupSyncRepo(param.repoType, "") + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") + assertTrue(dataRepository.getBookView("a/c")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("a/c") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (param.repoType) { + GIT -> "/a/c.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%2Fc.org" + else -> { repo.url + "/a/c.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + @Throws(FileNotFoundException::class) + fun testSyncWithDirectoryContainingPercent() { + Assume.assumeTrue(param.repoType != GIT) // Git repo URLs will never contain a space + Assume.assumeTrue(param.repoType != DOCUMENT) // Tested in espresso.ContentRepoTest because of UI behavior + topDirName = "space separated" + setupSyncRepo(param.repoType, "") + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("content", tmpFile) + syncRepo.storeBook(tmpFile, "notebook.org") + } finally { + tmpFile.delete() + } + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + assertTrue(syncRepo.uri.toString().contains("space separated")) + } + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { when (repoType) { GIT -> setupGitRepo() @@ -525,7 +661,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private fun setupDropboxRepo() { testUtils.dropboxTestPreflight() - repo = testUtils.setupRepo(DROPBOX, "dropbox:/$repoDirectoryName") + repo = testUtils.setupRepo(DROPBOX, "dropbox:/$permanentRepoTestDir/$topDirName") syncRepo = testUtils.repoInstance(DROPBOX, repo.url, repo.id) } @@ -537,7 +673,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } private fun setupContentRepo() { - val encodedRepoDirName = Uri.encode(repoDirectoryName) + val encodedRepoDirName = Uri.encode(permanentRepoTestDir) documentTreeSegment = if (Build.VERSION.SDK_INT < 33) { "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName%2F" } else { @@ -550,7 +686,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) if (repoDirDocumentFile?.exists() == false) { - ContentRepoTest.addContentRepoInUi(repoDirectoryName) + ContentRepoTest.addContentRepoInUi(permanentRepoTestDir) repo = dataRepository.getRepos()[0] } else { repo = testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) @@ -571,9 +707,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { val repoProps: MutableMap = mutableMapOf( WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) - repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + repoDirectoryName, repoProps) + repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + permanentRepoTestDir + "/" + topDirName, repoProps) syncRepo = dataRepository.getRepoInstance(repo.id, WEBDAV, repo.url) - testUtils.sync() // Required to create the remote directory + testUtils.sync() // Necessary to create the remote directory } private fun tearDownWebdavRepo() { diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index 30e96ff06..b7cac4636 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -10,7 +10,6 @@ import com.burgstaller.okhttp.digest.CachingAuthenticator import com.burgstaller.okhttp.digest.Credentials import com.burgstaller.okhttp.digest.DigestAuthenticator import com.orgzly.android.BookName -import com.orgzly.android.util.UriUtils import com.thegrizzlylabs.sardineandroid.DavResource import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import okhttp3.OkHttpClient @@ -28,7 +27,6 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager -import kotlin.collections.ArrayList class WebdavRepo( @@ -171,7 +169,8 @@ class WebdavRepo( .list(url, -1) .mapNotNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.name, false)) { + val relativePath = it.getRelativePath() + if (!BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.getRelativePath(), it.isDirectory)) { null } else { it.toVersionedRook() @@ -232,16 +231,23 @@ class WebdavRepo( return sardine.list(fileUrl).first().toVersionedRook() } - override fun renameBook(from: Uri, name: String?): VersionedRook { - val destUrl = UriUtils.getUriForNewName(from, name).toUrl() + override fun renameBook(oldFullUri: Uri, newName: String): VersionedRook { + val oldBookName = BookName.fromFileName(BookName.getFileName(uri, oldFullUri)) + val newRelativePath = BookName.fileName(newName, oldBookName.format) + val newEncodedRelativePath = Uri.encode(newRelativePath, "/") + val newFullUrl = uri.buildUpon().appendEncodedPath(newEncodedRelativePath).build().toUrl() /* Abort if destination file already exists. */ - if (sardine.exists(destUrl)) { - throw IOException("File at $destUrl already exists") + if (sardine.exists(newFullUrl)) { + throw IOException("File at $newFullUrl already exists") } - sardine.move(from.toUrl(), destUrl) - return sardine.list(destUrl).first().toVersionedRook() + if (newName.contains("/")) { + ensureDirectoryHierarchy(newEncodedRelativePath) + } + + sardine.move(oldFullUri.toUrl(), newFullUrl) + return sardine.list(newFullUrl).first().toVersionedRook() } override fun delete(uri: Uri) { @@ -249,17 +255,30 @@ class WebdavRepo( } private fun DavResource.toVersionedRook(): VersionedRook { - val fullUrl = Uri.parse(uri.scheme + "://" + uri.authority + this.href.toString()) return VersionedRook( repoId, RepoType.WEBDAV, uri, - fullUrl, + this.getFullUrl(), this.modified.time.toString(), this.modified.time ) } + private fun DavResource.getFullUrl(): Uri { + return Uri.parse(uri.scheme + "://" + uri.authority + this.href.toString()) + + } + + private fun DavResource.getRelativePath(): String { + val fullUrlString = this.getFullUrl().toString() + return fullUrlString.replace(Regex("^$uri/"), "") + } + + private fun extractRelativePathFromFullUrl(fullUrl: Uri): String { + return fullUrl.toString().replace(Regex("^$uri/"), "") + } + private fun Uri.toUrl(): String { return this.toString().replace("^(?:web)?dav(s?://)".toRegex(), "http$1") } From b47e6e3ec15d5a3db5ff9b7abe7c8668cab844fd Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 10:52:16 +0200 Subject: [PATCH 44/73] Rename the arguments of SyncRepo.renameBook() --- .../com/orgzly/android/repos/ContentRepo.java | 15 +++++++-------- .../com/orgzly/android/repos/DatabaseRepo.java | 6 +++--- .../com/orgzly/android/repos/DirectoryRepo.java | 8 ++++---- .../com/orgzly/android/repos/DropboxRepo.java | 6 +++--- .../java/com/orgzly/android/repos/GitRepo.java | 6 +++--- .../java/com/orgzly/android/repos/MockRepo.java | 4 ++-- .../java/com/orgzly/android/repos/SyncRepo.java | 2 +- 7 files changed, 23 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index d49246bad..54781ab9f 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -222,28 +222,28 @@ private DocumentFile ensureDirectoryHierarchy(String relativePath) { * Allows renaming a notebook to any subdirectory (indicated with a "/"), ensuring that all * required subdirectories are created, if they do not already exist. Note that the file is * moved, but no "abandoned" directories are deleted. - * @param oldUri + * @param oldFullUri * @param newName * @return * @throws IOException */ @Override - public VersionedRook renameBook(Uri oldUri, String newName) throws IOException { - DocumentFile oldDocFile = DocumentFile.fromSingleUri(context, oldUri); + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + DocumentFile oldDocFile = DocumentFile.fromSingleUri(context, oldFullUri); long mtime = oldDocFile.lastModified(); String rev = String.valueOf(mtime); String oldDocFileName = oldDocFile.getName(); Uri oldDirUri = Uri.parse( - oldUri.toString().replace( + oldFullUri.toString().replace( Uri.encode("/" + oldDocFile.getName()), "" ) ); - BookName oldBookName = BookName.fromFileName(BookName.getFileName(repoUri, oldUri)); + BookName oldBookName = BookName.fromFileName(BookName.getFileName(repoUri, oldFullUri)); String newRelativePath = BookName.fileName(newName, oldBookName.getFormat()); String newDocFileName = Uri.parse(newRelativePath).getLastPathSegment(); DocumentFile newDir; - Uri newUri = oldUri; + Uri newUri = oldFullUri; if (newName.contains("/")) { newDir = ensureDirectoryHierarchy(newName); @@ -261,8 +261,7 @@ public VersionedRook renameBook(Uri oldUri, String newName) throws IOException { // File should be moved to a different directory if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { newUri = DocumentsContract.moveDocument( - context.getContentResolver(), - oldUri, + context.getContentResolver(), oldFullUri, oldDirUri, newDir.getUri() ); diff --git a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java index 7d371a12e..10b81b958 100644 --- a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java @@ -73,9 +73,9 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { } @Override - public VersionedRook renameBook(Uri fromUri, String name) { - Uri toUri = UriUtils.getUriForNewName(fromUri, name); - return dbRepo.renameBook(repoId, fromUri, toUri); + public VersionedRook renameBook(Uri oldFullUri, String newName) { + Uri toUri = UriUtils.getUriForNewName(oldFullUri, newName); + return dbRepo.renameBook(repoId, oldFullUri, toUri); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java index db2b59064..c2be91cbc 100644 --- a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java @@ -178,15 +178,15 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { } @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { - String fromFilePath = fromUri.getPath(); + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + String fromFilePath = oldFullUri.getPath(); if (fromFilePath == null) { - throw new IllegalArgumentException("No path in " + fromUri); + throw new IllegalArgumentException("No path in " + oldFullUri); } File fromFile = new File(fromFilePath); - Uri newUri = UriUtils.getUriForNewName(fromUri, name); + Uri newUri = UriUtils.getUriForNewName(oldFullUri, newName); String toFilePath = newUri.getPath(); if (toFilePath == null) { diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index e25acec63..f3006f674 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -59,9 +59,9 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { } @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { - Uri toUri = UriUtils.getUriForNewName(fromUri, name); - return client.move(repoUri, fromUri, toUri); + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + Uri toUri = UriUtils.getUriForNewName(oldFullUri, newName); + return client.move(repoUri, oldFullUri, toUri); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 1c8c10ddf..1e0b6bddf 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -297,9 +297,9 @@ public void delete(Uri uri) throws IOException { if (synchronizer.deleteFileFromRepo(uri)) synchronizer.tryPush(); } - public VersionedRook renameBook(Uri oldUri, String newBookName) throws IOException { - String oldFileName = oldUri.toString().replaceFirst("^/", ""); - String newFileName = BookName.fileName(newBookName, BookFormat.ORG); + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + String oldFileName = oldFullUri.toString().replaceFirst("^/", ""); + String newFileName = BookName.fileName(newName, BookFormat.ORG); if (synchronizer.renameFileInRepo(oldFileName, newFileName)) { synchronizer.tryPush(); return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(newFileName).build()); diff --git a/app/src/main/java/com/orgzly/android/repos/MockRepo.java b/app/src/main/java/com/orgzly/android/repos/MockRepo.java index 9145c9a3d..35f8b9cea 100644 --- a/app/src/main/java/com/orgzly/android/repos/MockRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/MockRepo.java @@ -70,9 +70,9 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { } @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { SystemClock.sleep(SLEEP_FOR_STORE_BOOK); - return databaseRepo.renameBook(fromUri, name); + return databaseRepo.renameBook(oldFullUri, newName); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java index ed22ef2d1..6009051a6 100644 --- a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java @@ -48,7 +48,7 @@ public interface SyncRepo { */ VersionedRook storeBook(File file, String fileName) throws IOException; - VersionedRook renameBook(Uri from, String name) throws IOException; + VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException; // VersionedRook moveBook(Uri from, Uri uri) throws IOException; From 052572b86423b1011944cbce899749afb267b853 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 11:04:37 +0200 Subject: [PATCH 45/73] DropboxRepo seems done All tests are now green on APIs 29 and 34. --- .../java/com/orgzly/android/repos/SyncRepoTest.kt | 12 ++++++------ .../java/com/orgzly/android/repos/DropboxRepo.java | 14 +++++++++----- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 847508d1b..5e14cd4af 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -71,10 +71,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Parameterized.Parameters(name = "{0}") fun data(): Collection { return listOf( -// Parameter(repoType = GIT), -// Parameter(repoType = DOCUMENT), + Parameter(repoType = GIT), + Parameter(repoType = DOCUMENT), Parameter(repoType = DROPBOX), -// Parameter(repoType = WEBDAV), + Parameter(repoType = WEBDAV), ) } @@ -685,11 +685,11 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" } val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) - if (repoDirDocumentFile?.exists() == false) { + repo = if (repoDirDocumentFile?.exists() == false) { ContentRepoTest.addContentRepoInUi(permanentRepoTestDir) - repo = dataRepository.getRepos()[0] + dataRepository.getRepos()[0] } else { - repo = testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) + testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) } syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) assertEquals(treeDocumentFileUrl, repo.url) diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index f3006f674..1dbe611ab 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -3,11 +3,12 @@ import android.content.Context; import android.net.Uri; -import com.orgzly.android.util.UriUtils; +import androidx.annotation.NonNull; + +import com.orgzly.android.BookName; import java.io.File; import java.io.IOException; - import java.io.InputStream; import java.util.List; @@ -60,8 +61,11 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { @Override public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { - Uri toUri = UriUtils.getUriForNewName(oldFullUri, newName); - return client.move(repoUri, oldFullUri, toUri); + BookName oldBookName = BookName.fromFileName(BookName.getFileName(repoUri, oldFullUri)); + String newRelativePath = BookName.fileName(newName, oldBookName.getFormat()); + String newEncodedRelativePath = Uri.encode(newRelativePath, "/"); + Uri newFullUri = repoUri.buildUpon().appendEncodedPath(newEncodedRelativePath).build(); + return client.move(repoUri, oldFullUri, newFullUri); } @Override @@ -71,12 +75,12 @@ public void delete(Uri uri) throws IOException { /** * Intended for tests. The delete() method does not allow deleting directories. - * @param uri */ public void deleteDirectory(Uri uri) throws IOException { client.deleteFolder(uri.getPath()); } + @NonNull @Override public String toString() { return repoUri.toString(); From eb7f3359b02b77a051e7bd82b51a93144eac6c44 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 19:58:36 +0200 Subject: [PATCH 46/73] Rename ContentRepo to DocumentRepo Because the mismatch between RepoType.DOCUMENT and ContentRepo is confusing, and "document repo" (from DocumentFile) seems more accurate than "content repo", since the latter presumably only stems from the URLs beginning with "content://". --- .../{ContentRepoTest.kt => DocumentRepoTest.kt} | 16 ++++++++-------- .../com/orgzly/android/repos/SyncRepoTest.kt | 16 ++++++++-------- .../{ContentRepo.java => DocumentRepo.java} | 6 +++--- .../java/com/orgzly/android/repos/RepoFactory.kt | 6 ++++-- .../ui/repo/directory/DirectoryRepoActivity.kt | 7 ++----- 5 files changed, 25 insertions(+), 26 deletions(-) rename app/src/androidTest/java/com/orgzly/android/espresso/{ContentRepoTest.kt => DocumentRepoTest.kt} (93%) rename app/src/main/java/com/orgzly/android/repos/{ContentRepo.java => DocumentRepo.java} (98%) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt similarity index 93% rename from app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt rename to app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt index 4fec8e20d..c8dc6b2cb 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ContentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt @@ -16,7 +16,7 @@ import androidx.test.uiautomator.UiSelector import com.orgzly.R import com.orgzly.android.OrgzlyTest import com.orgzly.android.espresso.util.EspressoUtils.waitId -import com.orgzly.android.repos.ContentRepo +import com.orgzly.android.repos.DocumentRepo import com.orgzly.android.repos.RepoType import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils @@ -30,10 +30,10 @@ import java.io.File import java.io.FileNotFoundException -class ContentRepoTest : OrgzlyTest() { +class DocumentRepoTest : OrgzlyTest() { private var repoDirName = "orgzly-android-tests" - private lateinit var syncRepo: ContentRepo + private lateinit var syncRepo: DocumentRepo @Before @Throws(Exception::class) @@ -54,7 +54,7 @@ class ContentRepoTest : OrgzlyTest() { @Throws(FileNotFoundException::class) fun testSyncWithDirectoryContainingPercent() { repoDirName = "space separated" - setupContentRepo() + setupDocumentRepo() writeStringToRepoFile("Notebook content 1", "notebook.org") testUtils.sync() assertEquals(1, dataRepository.getBooks().size.toLong()) @@ -74,14 +74,14 @@ class ContentRepoTest : OrgzlyTest() { * @throws UiObjectNotFoundException */ @Throws(UiObjectNotFoundException::class) - private fun setupContentRepo() { - addContentRepoInUi(repoDirName) + private fun setupDocumentRepo() { + setupDocumentRepoInUi(repoDirName) val repo = dataRepository.getRepos()[0] - syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as ContentRepo + syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as DocumentRepo } companion object { - fun addContentRepoInUi(repoDirName: String) { + fun setupDocumentRepoInUi(repoDirName: String) { ActivityScenario.launch(ReposActivity::class.java).use { onView(withId(R.id.activity_repos_directory)).perform(click()) onView(withId(R.id.activity_repo_directory_browse_button)) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 5e14cd4af..73576abf8 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -15,7 +15,7 @@ import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo -import com.orgzly.android.espresso.ContentRepoTest +import com.orgzly.android.espresso.DocumentRepoTest import com.orgzly.android.espresso.util.EspressoUtils import com.orgzly.android.git.GitFileSynchronizer import com.orgzly.android.git.GitPreferencesFromRepoPrefs @@ -61,7 +61,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private lateinit var gitBareRepoPath: Path private lateinit var gitFileSynchronizer: GitFileSynchronizer - // used by ContentRepo + // used by DocumentRepo private lateinit var documentTreeSegment: String data class Parameter(val repoType: RepoType) @@ -92,7 +92,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { MOCK -> TODO() DROPBOX -> tearDownDropboxRepo() DIRECTORY -> TODO() - DOCUMENT -> tearDownContentRepo() + DOCUMENT -> tearDownDocumentRepo() WEBDAV -> tearDownWebdavRepo() } } @@ -627,7 +627,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Throws(FileNotFoundException::class) fun testSyncWithDirectoryContainingPercent() { Assume.assumeTrue(param.repoType != GIT) // Git repo URLs will never contain a space - Assume.assumeTrue(param.repoType != DOCUMENT) // Tested in espresso.ContentRepoTest because of UI behavior + Assume.assumeTrue(param.repoType != DOCUMENT) // Tested in espresso.DocumentRepoTest because of UI behavior topDirName = "space separated" setupSyncRepo(param.repoType, "") val tmpFile = dataRepository.getTempBookFile() @@ -648,7 +648,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { MOCK -> TODO() DROPBOX -> setupDropboxRepo() DIRECTORY -> TODO() - DOCUMENT -> setupContentRepo() + DOCUMENT -> setupDocumentRepo() WEBDAV -> setupWebdavRepo() } if (ignoreRules != null) { @@ -672,7 +672,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } catch (_: IOException) {} } - private fun setupContentRepo() { + private fun setupDocumentRepo() { val encodedRepoDirName = Uri.encode(permanentRepoTestDir) documentTreeSegment = if (Build.VERSION.SDK_INT < 33) { "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName%2F" @@ -686,7 +686,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) repo = if (repoDirDocumentFile?.exists() == false) { - ContentRepoTest.addContentRepoInUi(permanentRepoTestDir) + DocumentRepoTest.setupDocumentRepoInUi(permanentRepoTestDir) dataRepository.getRepos()[0] } else { testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) @@ -695,7 +695,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(treeDocumentFileUrl, repo.url) } - private fun tearDownContentRepo() { + private fun tearDownDocumentRepo() { val repoDirectory = DocumentFile.fromTreeUri(context, repo.url.toUri()) for (file in repoDirectory!!.listFiles()) { file.delete() diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java similarity index 98% rename from app/src/main/java/com/orgzly/android/repos/ContentRepo.java rename to app/src/main/java/com/orgzly/android/repos/DocumentRepo.java index 54781ab9f..1062d1bd7 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java @@ -28,8 +28,8 @@ /** * Using DocumentFile, for devices running Lollipop or later. */ -public class ContentRepo implements SyncRepo { - private static final String TAG = ContentRepo.class.getName(); +public class DocumentRepo implements SyncRepo { + private static final String TAG = DocumentRepo.class.getName(); public static final String SCHEME = "content"; @@ -40,7 +40,7 @@ public class ContentRepo implements SyncRepo { private final DocumentFile repoDocumentFile; - public ContentRepo(RepoWithProps repoWithProps, Context context) { + public DocumentRepo(RepoWithProps repoWithProps, Context context) { Repo repo = repoWithProps.getRepo(); this.repoId = repo.getId(); diff --git a/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt b/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt index 25ad16cc7..f72a23298 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt +++ b/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt @@ -25,8 +25,10 @@ class RepoFactory @Inject constructor( type == RepoType.DIRECTORY.id -> DirectoryRepo(repoWithProps, false) - type == RepoType.DOCUMENT.id -> - ContentRepo(repoWithProps, context) + type == RepoType.DOCUMENT.id -> DocumentRepo( + repoWithProps, + context + ) type == RepoType.WEBDAV.id -> WebdavRepo.getInstance(repoWithProps) diff --git a/app/src/main/java/com/orgzly/android/ui/repo/directory/DirectoryRepoActivity.kt b/app/src/main/java/com/orgzly/android/ui/repo/directory/DirectoryRepoActivity.kt index d9f1d57a2..9c9460176 100644 --- a/app/src/main/java/com/orgzly/android/ui/repo/directory/DirectoryRepoActivity.kt +++ b/app/src/main/java/com/orgzly/android/ui/repo/directory/DirectoryRepoActivity.kt @@ -1,10 +1,8 @@ package com.orgzly.android.ui.repo.directory import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import android.text.TextUtils import androidx.activity.result.contract.ActivityResultContracts @@ -13,11 +11,10 @@ import androidx.lifecycle.ViewModelProvider import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.App -import com.orgzly.android.repos.ContentRepo +import com.orgzly.android.repos.DocumentRepo import com.orgzly.android.repos.RepoFactory import com.orgzly.android.repos.RepoType import com.orgzly.android.ui.CommonActivity -import com.orgzly.android.ui.repo.BrowserActivity import com.orgzly.android.ui.repo.RepoViewModel import com.orgzly.android.ui.repo.RepoViewModelFactory import com.orgzly.android.ui.showSnackbar @@ -129,7 +126,7 @@ class DirectoryRepoActivity : CommonActivity() { } private fun persistPermissions(uri: Uri) { - if (ContentRepo.SCHEME == uri.scheme) { + if (DocumentRepo.SCHEME == uri.scheme) { grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION From c97bfbc51e956a6a30cd36d854d5dc6e78dfb317 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 20:23:32 +0200 Subject: [PATCH 47/73] Correct API limit for differences in the system file picker UI and URLs All tests are now successful on APIs 29, 30 and 34. --- .../java/com/orgzly/android/espresso/DocumentRepoTest.kt | 2 +- .../androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt index c8dc6b2cb..9531400f4 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt @@ -89,7 +89,7 @@ class DocumentRepoTest : OrgzlyTest() { SystemClock.sleep(200) // In Android file browser (Espresso cannot be used): val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - if (Build.VERSION.SDK_INT < 33) { + if (Build.VERSION.SDK_INT < 30) { // Older system file picker UI mDevice.findObject(UiSelector().description("More options")).click() mDevice.findObject(UiSelector().text("New folder")).click() diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 73576abf8..c74469592 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -674,12 +674,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private fun setupDocumentRepo() { val encodedRepoDirName = Uri.encode(permanentRepoTestDir) - documentTreeSegment = if (Build.VERSION.SDK_INT < 33) { + documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName%2F" } else { "/document/primary%3A$encodedRepoDirName%2F" } - val treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 33) { + val treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName" } else { "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" From 703e144b8c62bed7d405e3fa402ec959f05221ba Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 20:58:49 +0200 Subject: [PATCH 48/73] Sleep longer between steps when setting up test DocumentRepo We only do this once per device, so we can afford a few seconds here. --- .../java/com/orgzly/android/espresso/DocumentRepoTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt index 9531400f4..aa3e01b3c 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt @@ -86,28 +86,28 @@ class DocumentRepoTest : OrgzlyTest() { onView(withId(R.id.activity_repos_directory)).perform(click()) onView(withId(R.id.activity_repo_directory_browse_button)) .perform(click()) - SystemClock.sleep(200) + SystemClock.sleep(500) // In Android file browser (Espresso cannot be used): val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) if (Build.VERSION.SDK_INT < 30) { // Older system file picker UI mDevice.findObject(UiSelector().description("More options")).click() mDevice.findObject(UiSelector().text("New folder")).click() - SystemClock.sleep(100) + SystemClock.sleep(500) mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName mDevice.findObject(UiSelector().text("OK")).click() mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() mDevice.findObject(UiSelector().text("ALLOW")).click() } else { mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - SystemClock.sleep(100) + SystemClock.sleep(500) mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName mDevice.findObject(UiSelector().text("OK")).click() mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() mDevice.findObject(UiSelector().text("ALLOW")).click() } // Back in Orgzly: - SystemClock.sleep(200) + SystemClock.sleep(500) onView(isRoot()).perform(waitId(R.id.fab, 5000)) onView(allOf(withId(R.id.fab), isDisplayed())).perform(click()) } From c33932e096caee76fddb6fc4c15fdbd882394272 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 21:01:16 +0200 Subject: [PATCH 49/73] Read cloud account credentials for WebDAV tests from Github repo secrets --- .github/workflows/test.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bb51aa23d..e209c402c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -74,12 +74,19 @@ jobs: ~/.android/adb* key: avd-${{ matrix.api-level }} - - name: Add Dropbox API credentials + - name: Add Dropbox API credentials (for DropboxRepo tests) shell: bash run: | echo "dropbox.refresh_token = \"${{ secrets.DROPBOX_REFRESH_TOKEN }}\"" >> app.properties echo "dropbox.app_key = \"${{ secrets.DROPBOX_APP_KEY }}\"" >> app.properties + - name: Add thegood.cloud credentials (for WebdavRepo tests) + shell: bash + run: | + echo "webdav.repo_url = \"${{ secrets.WEBDAV_TEST_REPO_URL }}\"" >> app.properties + echo "webdav.username = \"${{ secrets.WEBDAV_TEST_USERNAME }}\"" >> app.properties + echo "webdav.password = \"${{ secrets.WEBDAV_TEST_PASSWORD }}\"" >> app.properties + - name: Run tests uses: reactivecircus/android-emulator-runner@v2 with: From 6e420e7069eaaf803c5340a11b471f372d4a0bfa Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 22:43:10 +0200 Subject: [PATCH 50/73] Try to handle thegood.cloud being slow/flaky --- .../java/com/orgzly/android/repos/SyncRepoTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index c74469592..6b5719208 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -2,6 +2,7 @@ package com.orgzly.android.repos import android.net.Uri import android.os.Build +import android.os.SystemClock import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.test.core.app.ActivityScenario @@ -652,6 +653,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { WEBDAV -> setupWebdavRepo() } if (ignoreRules != null) { + if (param.repoType == WEBDAV) { + // thegood.cloud sometimes takes a while to create the repo directory + SystemClock.sleep(500) + } val tmpFile = File.createTempFile("orgzly-test", null) MiscUtils.writeStringToFile(ignoreRules, tmpFile) syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) From a13648f8482ff9b411e4db42ef1bba176560251f Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 22:43:28 +0200 Subject: [PATCH 51/73] Clean up unused code in WebdavRepo --- app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index b7cac4636..633cc47f4 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -169,7 +169,6 @@ class WebdavRepo( .list(url, -1) .mapNotNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val relativePath = it.getRelativePath() if (!BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.getRelativePath(), it.isDirectory)) { null } else { @@ -217,7 +216,7 @@ class WebdavRepo( } } - override fun storeBook(file: File?, fileName: String?): VersionedRook { + override fun storeBook(file: File, fileName: String): VersionedRook { val encodedFileName = Uri.encode(fileName, "/") if (encodedFileName != null) { if (encodedFileName.contains("/")) { @@ -275,10 +274,6 @@ class WebdavRepo( return fullUrlString.replace(Regex("^$uri/"), "") } - private fun extractRelativePathFromFullUrl(fullUrl: Uri): String { - return fullUrl.toString().replace(Regex("^$uri/"), "") - } - private fun Uri.toUrl(): String { return this.toString().replace("^(?:web)?dav(s?://)".toRegex(), "http$1") } From 5fe32eac702a7a0ffabe2a6ec1f424aeba18a034 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 27 Jul 2024 23:44:37 +0200 Subject: [PATCH 52/73] Try retry rule in SyncRepoTest To work around WebDAV tests talking to flaky cloud provider. --- .../java/com/orgzly/android/repos/SyncRepoTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 6b5719208..38e200a4d 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -14,6 +14,7 @@ import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest +import com.orgzly.android.RetryTestRule import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo import com.orgzly.android.espresso.DocumentRepoTest @@ -85,6 +86,10 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { val RANDOM_UUID = UUID.randomUUID().toString() } + @Rule + @JvmField + val mRetryTestRule = RetryTestRule() + override fun tearDown() { super.tearDown() if (this::repo.isInitialized) { @@ -746,4 +751,4 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { gitWorkingTree.deleteRecursively() gitBareRepoPath.toFile()!!.deleteRecursively() } -} \ No newline at end of file +} From 2f1f6fe89be40fc39fcf76de1277ea59d8161e55 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 29 Jul 2024 23:21:51 +0200 Subject: [PATCH 53/73] Another Espresso sleep --- .../java/com/orgzly/android/espresso/DocumentRepoTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt index aa3e01b3c..1a092e430 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt @@ -92,6 +92,7 @@ class DocumentRepoTest : OrgzlyTest() { if (Build.VERSION.SDK_INT < 30) { // Older system file picker UI mDevice.findObject(UiSelector().description("More options")).click() + SystemClock.sleep(300) mDevice.findObject(UiSelector().text("New folder")).click() SystemClock.sleep(500) mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName From 1ac30feeda82395c97a4b70a878d5624c4718160 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 30 Jul 2024 00:24:22 +0200 Subject: [PATCH 54/73] Use subfolder in WebdavRepoTest --- .../androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt index 1586170d8..3cdca539c 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -20,7 +20,7 @@ import java.util.UUID class WebdavRepoTest : OrgzlyTest() { - private val repoUriString = BuildConfig.WEBDAV_REPO_URL + "/orgzly-android-tests" + private val repoUriString = BuildConfig.WEBDAV_REPO_URL + "/orgzly-android-tests" + UUID.randomUUID().toString() private lateinit var syncRepo: SyncRepo @Before From 74be36b657a84516bc043f17c49c214cee3c5e6e Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 31 Jul 2024 00:23:07 +0200 Subject: [PATCH 55/73] The "space separated" repo dir test was tricky for DocumentRepo --- .../android/espresso/DocumentRepoTest.kt | 117 ------------------ .../com/orgzly/android/repos/SyncRepoTest.kt | 105 +++++++++++----- 2 files changed, 75 insertions(+), 147 deletions(-) delete mode 100644 app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt deleted file mode 100644 index 1a092e430..000000000 --- a/app/src/androidTest/java/com/orgzly/android/espresso/DocumentRepoTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.orgzly.android.espresso - -import android.os.Build -import android.os.SystemClock -import androidx.documentfile.provider.DocumentFile -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isRoot -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiObjectNotFoundException -import androidx.test.uiautomator.UiSelector -import com.orgzly.R -import com.orgzly.android.OrgzlyTest -import com.orgzly.android.espresso.util.EspressoUtils.waitId -import com.orgzly.android.repos.DocumentRepo -import com.orgzly.android.repos.RepoType -import com.orgzly.android.ui.repos.ReposActivity -import com.orgzly.android.util.MiscUtils -import org.hamcrest.core.AllOf.allOf -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.File -import java.io.FileNotFoundException - - -class DocumentRepoTest : OrgzlyTest() { - - private var repoDirName = "orgzly-android-tests" - private lateinit var syncRepo: DocumentRepo - - @Before - @Throws(Exception::class) - override fun setUp() { - super.setUp() - } - - @After - @Throws(Exception::class) - override fun tearDown() { - super.tearDown() - if (this::syncRepo.isInitialized) { - DocumentFile.fromTreeUri(context, syncRepo.uri)!!.delete() - } - } - - @Test - @Throws(FileNotFoundException::class) - fun testSyncWithDirectoryContainingPercent() { - repoDirName = "space separated" - setupDocumentRepo() - writeStringToRepoFile("Notebook content 1", "notebook.org") - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - assertTrue(syncRepo.uri.toString().contains("space%20separated")) - } - - private fun writeStringToRepoFile(content: String, fileName: String) { - val tmpFile = File.createTempFile("abc", null) - MiscUtils.writeStringToFile(content, tmpFile) - syncRepo.storeBook(tmpFile, fileName) - tmpFile.delete() - } - - /** - * An activity is required when creating this type of repo, because of the way Android handles - * access permissions to content:// URLs. - * @throws UiObjectNotFoundException - */ - @Throws(UiObjectNotFoundException::class) - private fun setupDocumentRepo() { - setupDocumentRepoInUi(repoDirName) - val repo = dataRepository.getRepos()[0] - syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) as DocumentRepo - } - - companion object { - fun setupDocumentRepoInUi(repoDirName: String) { - ActivityScenario.launch(ReposActivity::class.java).use { - onView(withId(R.id.activity_repos_directory)).perform(click()) - onView(withId(R.id.activity_repo_directory_browse_button)) - .perform(click()) - SystemClock.sleep(500) - // In Android file browser (Espresso cannot be used): - val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - if (Build.VERSION.SDK_INT < 30) { - // Older system file picker UI - mDevice.findObject(UiSelector().description("More options")).click() - SystemClock.sleep(300) - mDevice.findObject(UiSelector().text("New folder")).click() - SystemClock.sleep(500) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - } else { - mDevice.findObject(UiSelector().text("CREATE NEW FOLDER")).click() - SystemClock.sleep(500) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - } - // Back in Orgzly: - SystemClock.sleep(500) - onView(isRoot()).perform(waitId(R.id.fab, 5000)) - onView(allOf(withId(R.id.fab), isDisplayed())).perform(click()) - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 38e200a4d..879340f18 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -10,6 +10,9 @@ import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.BookName @@ -17,7 +20,6 @@ import com.orgzly.android.OrgzlyTest import com.orgzly.android.RetryTestRule import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo -import com.orgzly.android.espresso.DocumentRepoTest import com.orgzly.android.espresso.util.EspressoUtils import com.orgzly.android.git.GitFileSynchronizer import com.orgzly.android.git.GitPreferencesFromRepoPrefs @@ -31,10 +33,12 @@ import com.orgzly.android.repos.RepoType.MOCK import com.orgzly.android.repos.RepoType.WEBDAV import com.orgzly.android.sync.BookSyncStatus import com.orgzly.android.ui.main.MainActivity +import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.core.AllOf import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assume @@ -111,7 +115,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testLoadBook() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("...", tmpFile) @@ -132,7 +136,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testForceLoadBook() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) val bookView = testUtils.setupBook("booky", "content") testUtils.sync() var books = dataRepository.getBooks() @@ -147,7 +151,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testLoadBookWithSpaceInName() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("...", tmpFile) @@ -172,7 +176,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testExtension() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) // Add multiple files to repo for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { val tmpFile = File.createTempFile("orgzly-test", null) @@ -190,7 +194,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testSyncNewBookWithoutLinkAndOneRepo() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) testUtils.setupBook("Book 1", "content") testUtils.sync() val bookView = dataRepository.getBooks()[0] @@ -211,7 +215,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBook() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) testUtils.setupBook("oldname", "") testUtils.sync() var bookView = dataRepository.getBookView("oldname") @@ -234,7 +238,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookToNameWithSpace() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) testUtils.setupBook("oldname", "") testUtils.sync() var bookView = dataRepository.getBookView("oldname") @@ -261,7 +265,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookToExistingRepoFileName() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) testUtils.setupBook("a", "") testUtils.sync() @@ -282,7 +286,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookToExistingBookName() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) testUtils.setupBook("a", "") testUtils.setupBook("b", "") assertEquals(2, dataRepository.getBooks().size) @@ -363,7 +367,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test // @Ignore("Not yet implemented for all repo types") fun testStoreBookInSubfolder() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) testUtils.setupBook("a folder/a book", "") testUtils.sync() assertEquals(1, syncRepo.books.size) @@ -379,7 +383,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testLoadBookFromSubfolder() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("...", tmpFile) @@ -404,7 +408,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testForceLoadBookInSubfolder() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) val bookView = testUtils.setupBook("a folder/a book", "content") testUtils.sync() var books = dataRepository.getBooks() @@ -457,7 +461,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testStoreBookAndRetrieveBookProducesSameRookUri() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) val repoFilePath = "folder one/book one.org" @@ -485,7 +489,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testUpdateBookInSubfolder() { - setupSyncRepo(param.repoType, null) + setupSyncRepo(param.repoType) // Create org file in subfolder val tmpFile = dataRepository.getTempBookFile() try { @@ -521,7 +525,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookFromRootToSubfolder() { - setupSyncRepo(param.repoType, "") + setupSyncRepo(param.repoType) testUtils.setupBook("booky", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") @@ -543,7 +547,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookFromSubfolderToRoot() { - setupSyncRepo(param.repoType, "") + setupSyncRepo(param.repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") @@ -565,7 +569,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookNewSubfolderSameLeafName() { - setupSyncRepo(param.repoType, "") + setupSyncRepo(param.repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") @@ -587,7 +591,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookNewSubfolderAndLeafName() { - setupSyncRepo(param.repoType, "") + setupSyncRepo(param.repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") @@ -609,7 +613,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookSameSubfolderNewLeafName() { - setupSyncRepo(param.repoType, "") + setupSyncRepo(param.repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") @@ -633,9 +637,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Throws(FileNotFoundException::class) fun testSyncWithDirectoryContainingPercent() { Assume.assumeTrue(param.repoType != GIT) // Git repo URLs will never contain a space - Assume.assumeTrue(param.repoType != DOCUMENT) // Tested in espresso.DocumentRepoTest because of UI behavior topDirName = "space separated" - setupSyncRepo(param.repoType, "") + if (param.repoType == DOCUMENT) { + setupDocumentRepo(topDirName) + } else { + setupSyncRepo(param.repoType) + } val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("content", tmpFile) @@ -648,7 +655,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertTrue(syncRepo.uri.toString().contains("space separated")) } - private fun setupSyncRepo(repoType: RepoType, ignoreRules: String?) { + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String? = null) { when (repoType) { GIT -> setupGitRepo() MOCK -> TODO() @@ -658,10 +665,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { WEBDAV -> setupWebdavRepo() } if (ignoreRules != null) { - if (param.repoType == WEBDAV) { - // thegood.cloud sometimes takes a while to create the repo directory - SystemClock.sleep(500) - } val tmpFile = File.createTempFile("orgzly-test", null) MiscUtils.writeStringToFile(ignoreRules, tmpFile) syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) @@ -682,21 +685,28 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } catch (_: IOException) {} } - private fun setupDocumentRepo() { + private fun setupDocumentRepo(extraDir: String? = null) { val encodedRepoDirName = Uri.encode(permanentRepoTestDir) documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName%2F" } else { "/document/primary%3A$encodedRepoDirName%2F" } - val treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { + var treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName" } else { "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" } + if (extraDir != null) { + treeDocumentFileUrl = "$treeDocumentFileUrl%2F$extraDir" + } val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) repo = if (repoDirDocumentFile?.exists() == false) { - DocumentRepoTest.setupDocumentRepoInUi(permanentRepoTestDir) + if (extraDir != null) { + setupDocumentRepoInUi(extraDir) + } else { + setupDocumentRepoInUi(permanentRepoTestDir) + } dataRepository.getRepos()[0] } else { testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) @@ -705,6 +715,41 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(treeDocumentFileUrl, repo.url) } + private fun setupDocumentRepoInUi(repoDirName: String) { + ActivityScenario.launch(ReposActivity::class.java).use { + Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) + .perform(ViewActions.click()) + SystemClock.sleep(500) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + if (Build.VERSION.SDK_INT < 30) { + // Older system file picker UI + mDevice.findObject(UiSelector().description("More options")).click() + SystemClock.sleep(300) + mDevice.findObject(UiSelector().text("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } else { + mDevice.findObject(UiSelector().description("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } + // Back in Orgzly: + SystemClock.sleep(500) + Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) + Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } + } + private fun tearDownDocumentRepo() { val repoDirectory = DocumentFile.fromTreeUri(context, repo.url.toUri()) for (file in repoDirectory!!.listFiles()) { From 4085910e01983aadcfa9894f06b14b34f8db20f6 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Wed, 31 Jul 2024 09:31:34 +0200 Subject: [PATCH 56/73] Add another sleep --- .../java/com/orgzly/android/espresso/QueryFragmentTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index fe2d85478..ec429e787 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -726,6 +726,7 @@ public void testSearchWithState() { scenario = ActivityScenario.launch(MainActivity.class); onView(allOf(withText("notebook"), isDisplayed())).perform(click()); + SystemClock.sleep(200); searchForTextCloseKeyboard(".it.none"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(3))); From 100dcb3e4314b46478890d0334f25cfe4ac986eb Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Fri, 2 Aug 2024 14:31:54 +0200 Subject: [PATCH 57/73] wip no working app component --- app/build.gradle | 10 +++-- .../java/com/orgzly/android/WebdavTest.kt | 37 +++++++++++++++++++ build.gradle | 2 +- gradle.properties | 7 +++- gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/orgzly/android/WebdavTest.kt diff --git a/app/build.gradle b/app/build.gradle index 093feb55d..a8ab58c02 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,9 +38,11 @@ android { viewBinding true } -// testOptions { -// execution 'ANDROIDX_TEST_ORCHESTRATOR' -// } + testOptions { + unitTests { + includeAndroidResources = true + } + } buildTypes { release { @@ -154,6 +156,8 @@ dependencies { implementation "androidx.work:work-runtime-ktx:$versions.android_workmanager" testImplementation "junit:junit:$versions.junit" + testImplementation 'org.robolectric:robolectric:4.13' + testImplementation "io.github.atetzner:webdav-embedded-server:0.2.1" // AndroidX Test androidTestImplementation "androidx.test.espresso:espresso-core:$versions.android_test_espresso" diff --git a/app/src/test/java/com/orgzly/android/WebdavTest.kt b/app/src/test/java/com/orgzly/android/WebdavTest.kt new file mode 100644 index 000000000..3eb8f7bed --- /dev/null +++ b/app/src/test/java/com/orgzly/android/WebdavTest.kt @@ -0,0 +1,37 @@ +package com.orgzly.android + +import com.orgzly.android.App.appComponent +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.repos.RepoType +import com.orgzly.android.repos.RepoWithProps +import com.orgzly.android.repos.WebdavRepo +import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY +import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY +import io.github.atetzner.webdav.server.MiltonWebDAVFileServer +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WebdavTest { + + @Test + fun testRetrieveBook() { + val rootFolder = java.nio.file.Files.createTempDirectory("orgzly-webdav-test-").toFile() + val server = MiltonWebDAVFileServer(rootFolder) + server.userCredentials["user"] = "secret" + server.start() + val repoPropsMap = HashMap() + repoPropsMap[USERNAME_PREF_KEY] = "user" + repoPropsMap[PASSWORD_PREF_KEY] = "secret" + val repo = Repo(0, RepoType.WEBDAV, "http://localhost:8080/") + val repoWithProps = RepoWithProps(repo, repoPropsMap) + val syncRepo = WebdavRepo.getInstance(repoWithProps) + val books = syncRepo.books + assertEquals(0, books.size) + Thread.sleep(2000) + server.stop() + rootFolder.deleteRecursively() + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3926e82c0..2a93d9111 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { def versions = [:] - versions.android_gradle_plugin = '7.4.2' + versions.android_gradle_plugin = '8.1.1' versions.kotlin = '1.7.20' diff --git a/gradle.properties b/gradle.properties index 877be9ba9..47941b9b9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,5 +17,10 @@ android.databinding.incremental = true kotlin.code.style = official org.gradle.unsafe.configuration-cache=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false -# org.gradle.warning.mode=all \ No newline at end of file +# org.gradle.warning.mode=all + +android.jetifier.ignorelist = bcprov-jdk18on-1.78.1.jar \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 67f31dcaf..76ae8f5f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Jan 29 20:23:20 CET 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 4998d108ecc2aaad13a976fe388976c50b9b2c88 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 10 Aug 2024 12:08:50 +0200 Subject: [PATCH 58/73] Moved all tests of WebdavRepo to unit tests --- app/build.gradle | 8 +- .../com/orgzly/android/repos/SyncRepoTest.kt | 48 +--- .../orgzly/android/repos/WebdavRepoTest.kt | 65 ----- .../external/ExternalAccessReceiver.kt | 7 +- .../orgzly/android/repos/DatabaseRepo.java | 8 +- .../orgzly/android/repos/DirectoryRepo.java | 10 +- .../orgzly/android/repos/DocumentRepo.java | 20 +- .../com/orgzly/android/repos/DropboxRepo.java | 8 +- .../com/orgzly/android/repos/GitRepo.java | 4 +- .../com/orgzly/android/repos/MockRepo.java | 8 +- .../com/orgzly/android/repos/SyncRepo.java | 14 +- .../com/orgzly/android/repos/WebdavRepo.kt | 38 ++- .../java/com/orgzly/android/WebdavTest.kt | 242 ++++++++++++++++-- 13 files changed, 317 insertions(+), 163 deletions(-) delete mode 100644 app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt diff --git a/app/build.gradle b/app/build.gradle index a8ab58c02..c007cf2f7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -155,9 +155,15 @@ dependencies { implementation "androidx.work:work-runtime-ktx:$versions.android_workmanager" + testImplementation "androidx.test:runner:$versions.android_test" + testImplementation "androidx.test:rules:$versions.android_test" + testImplementation "androidx.test.ext:junit:$versions.android_test_ext_junit" testImplementation "junit:junit:$versions.junit" testImplementation 'org.robolectric:robolectric:4.13' testImplementation "io.github.atetzner:webdav-embedded-server:0.2.1" + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.8.2") + testImplementation "org.mockito:mockito-core:2.28.2" + testImplementation "org.mockito.kotlin:mockito-kotlin:5.1.0" // AndroidX Test androidTestImplementation "androidx.test.espresso:espresso-core:$versions.android_test_espresso" @@ -235,4 +241,4 @@ def orgJavaLocation() { logger.info("app: Using com.github.orgzly-revived:org-java:$versions.org_java from Maven repository") return "com.github.orgzly-revived:org-java:$versions.org_java" } -} +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 879340f18..6a520a994 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -13,7 +13,6 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector -import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest @@ -35,7 +34,6 @@ import com.orgzly.android.sync.BookSyncStatus import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils -import com.thegrizzlylabs.sardineandroid.impl.SardineException import org.eclipse.jgit.api.Git import org.hamcrest.CoreMatchers.containsString import org.hamcrest.core.AllOf @@ -80,7 +78,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { Parameter(repoType = GIT), Parameter(repoType = DOCUMENT), Parameter(repoType = DROPBOX), - Parameter(repoType = WEBDAV), ) } @@ -103,7 +100,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { DROPBOX -> tearDownDropboxRepo() DIRECTORY -> TODO() DOCUMENT -> tearDownDocumentRepo() - WEBDAV -> tearDownWebdavRepo() + WEBDAV -> TODO() } } } @@ -112,6 +109,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Rule var exceptionRule: ExpectedException = ExpectedException.none() + // TODO: Move to DataRepository tests @Test @Throws(IOException::class) fun testLoadBook() { @@ -133,6 +131,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals("booky", books[0].book.name) } + // TODO: Move to DataRepository tests @Test @Throws(IOException::class) fun testForceLoadBook() { @@ -192,6 +191,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(repo.url, books[0].repoUri.toString()) } + // TODO: Move to DataRepository tests @Test fun testSyncNewBookWithoutLinkAndOneRepo() { setupSyncRepo(param.repoType) @@ -205,14 +205,9 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { context.getString(R.string.sync_status_saved, repo.url), bookView.book.lastAction!!.message ) - val expectedUriString = when (param.repoType) { - GIT -> "/Book 1.org" - DOCUMENT -> repo.url + documentTreeSegment + "Book%201.org" - else -> { repo.url + "/Book%201.org" } - } - assertEquals(expectedUriString, bookView.syncedTo!!.uri.toString()) } + // TODO: Move to DataRepository tests @Test fun testRenameBook() { setupSyncRepo(param.repoType) @@ -236,6 +231,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertTrue(bookView.syncedTo!!.uri.toString().contains("newname.org")) } + // TODO: Move to DataRepository tests @Test fun testRenameBookToNameWithSpace() { setupSyncRepo(param.repoType) @@ -284,6 +280,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed:")) } + // TODO: Move to DataRepository tests (check happens there) @Test fun testRenameBookToExistingBookName() { setupSyncRepo(param.repoType) @@ -294,7 +291,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) } - @Test fun testIgnoreRulePreventsLoadingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) // .orgzlyignore not supported below API 26 @@ -337,6 +333,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals("notignored", dataRepository.getBooks()[0].book.name) } + // TODO: Move to DataRepository tests (check happens there) @Test fun testIgnoreRulePreventsRenamingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) @@ -353,6 +350,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { ) } + // TODO: Move to DataRepository tests (check happens there) @Test @Throws(java.lang.Exception::class) fun testIgnoreRulePreventsLinkingBook() { @@ -365,7 +363,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } @Test - // @Ignore("Not yet implemented for all repo types") fun testStoreBookInSubfolder() { setupSyncRepo(param.repoType) testUtils.setupBook("a folder/a book", "") @@ -404,6 +401,8 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { /** * Ensures that file names and book names are not parsed/created differently during * force-loading. + * + * TODO: Move - tests code in DataRepository, not SyncRepo */ @Test @Throws(IOException::class) @@ -487,6 +486,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(storedBook!!.uri, retrievedBook!!.uri!!) } + // TODO: Move - does not test SyncRepo code @Test fun testUpdateBookInSubfolder() { setupSyncRepo(param.repoType) @@ -635,7 +635,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(FileNotFoundException::class) - fun testSyncWithDirectoryContainingPercent() { + fun testSyncWithDirectoryWithSpaceInName() { Assume.assumeTrue(param.repoType != GIT) // Git repo URLs will never contain a space topDirName = "space separated" if (param.repoType == DOCUMENT) { @@ -662,7 +662,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { DROPBOX -> setupDropboxRepo() DIRECTORY -> TODO() DOCUMENT -> setupDocumentRepo() - WEBDAV -> setupWebdavRepo() + WEBDAV -> TODO() } if (ignoreRules != null) { val tmpFile = File.createTempFile("orgzly-test", null) @@ -757,26 +757,6 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } } - private fun setupWebdavRepo() { - testUtils.webdavTestPreflight() - val repoProps: MutableMap = mutableMapOf( - WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, - WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) - repo = testUtils.setupRepo(WEBDAV, BuildConfig.WEBDAV_REPO_URL + "/" + permanentRepoTestDir + "/" + topDirName, repoProps) - syncRepo = dataRepository.getRepoInstance(repo.id, WEBDAV, repo.url) - testUtils.sync() // Necessary to create the remote directory - } - - private fun tearDownWebdavRepo() { - try { - syncRepo.delete(repo.url.toUri()) - } catch (e: SardineException) { - if (e.statusCode != 404) { - throw e - } - } - } - private fun setupGitRepo() { gitBareRepoPath = createTempDirectory() Git.init().setBare(true).setDirectory(gitBareRepoPath.toFile()).call() diff --git a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt deleted file mode 100644 index 3cdca539c..000000000 --- a/app/src/androidTest/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.orgzly.android.repos - -import androidx.core.net.toUri -import com.orgzly.BuildConfig -import com.orgzly.android.BookName -import com.orgzly.android.OrgzlyTest -import com.orgzly.android.db.entity.BookView -import com.orgzly.android.db.entity.Repo -import com.orgzly.android.util.MiscUtils -import org.junit.After -import org.junit.Assert -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.ExpectedException -import java.io.File -import java.io.FileNotFoundException -import java.io.IOException -import java.util.UUID - -class WebdavRepoTest : OrgzlyTest() { - - private val repoUriString = BuildConfig.WEBDAV_REPO_URL + "/orgzly-android-tests" + UUID.randomUUID().toString() - private lateinit var syncRepo: SyncRepo - - @Before - @Throws(Exception::class) - override fun setUp() { - super.setUp() - testUtils.webdavTestPreflight() - } - - @After - override fun tearDown() { - super.tearDown() - if (this::syncRepo.isInitialized) { - syncRepo.delete(syncRepo.uri) - } - } - - @JvmField - @Rule - var exceptionRule: ExpectedException = ExpectedException.none() - - @Test - fun testUrl() { - val repo = testUtils.setupRepo(RepoType.WEBDAV, repoUriString, repoProps) - Assert.assertEquals( - "webdav:/dir", testUtils.repoInstance(RepoType.WEBDAV, "webdav:/dir", repo.id).uri.toString() - ) - } - - @Test - fun testSyncingUrlWithTrailingSlash() { - val repo = testUtils.setupRepo(RepoType.WEBDAV, "$repoUriString/", repoProps) - syncRepo = testUtils.repoInstance(RepoType.WEBDAV, repo.url, repo.id) - Assert.assertNotNull(testUtils.sync()) - } - - companion object { - private val repoProps: MutableMap = mutableMapOf( - WebdavRepo.USERNAME_PREF_KEY to BuildConfig.WEBDAV_USERNAME, - WebdavRepo.PASSWORD_PREF_KEY to BuildConfig.WEBDAV_PASSWORD) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt b/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt index 97b8ec116..300f3d41b 100644 --- a/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt +++ b/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt @@ -8,15 +8,14 @@ import com.orgzly.android.external.actionhandlers.* import com.orgzly.android.external.types.Response class ExternalAccessReceiver : BroadcastReceiver() { - val actionHandlers = listOf( + override fun onReceive(context: Context?, intent: Intent?) { + val actionHandlers = listOf( GetOrgInfo(), RunSearch(), EditNotes(), EditSavedSearches(), ManageWidgets() - ) - - override fun onReceive(context: Context?, intent: Intent?) { + ) val response = actionHandlers.asSequence() .mapNotNull { it.handle(intent!!, context!!) } .firstOrNull() diff --git a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java index 10b81b958..97d663096 100644 --- a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java @@ -48,8 +48,8 @@ public List getBooks() { } @Override - public VersionedRook retrieveBook(String fileName, File file) { - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + public VersionedRook retrieveBook(String repositoryPath, File file) { + Uri uri = repoUri.buildUpon().appendPath(repositoryPath).build(); return dbRepo.retrieveBook(repoId, repoUri, uri, file); } @@ -59,13 +59,13 @@ public InputStream openRepoFileInputStream(String fileName) throws IOException { } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String repositoryPath) throws IOException { String content = MiscUtils.readStringFromFile(file); String rev = "MockedRevision-" + System.currentTimeMillis(); long mtime = System.currentTimeMillis(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = repoUri.buildUpon().appendPath(repositoryPath).build(); VersionedRook vrook = new VersionedRook(repoId, RepoType.MOCK, repoUri, uri, rev, mtime); diff --git a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java index c2be91cbc..c208503ee 100644 --- a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java @@ -124,8 +124,8 @@ public List getBooks() { } @Override - public VersionedRook retrieveBook(String fileName, File destinationFile) throws IOException { - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + public VersionedRook retrieveBook(String repositoryPath, File destinationFile) throws IOException { + Uri uri = repoUri.buildUpon().appendPath(repositoryPath).build(); String path = uri.getPath(); @@ -150,12 +150,12 @@ public InputStream openRepoFileInputStream(String fileName) throws IOException { } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String repositoryPath) throws IOException { if (!file.exists()) { throw new FileNotFoundException("File " + file + " does not exist"); } - File destinationFile = new File(mDirectory, fileName); + File destinationFile = new File(mDirectory, repositoryPath); File destinationFileParent = destinationFile.getParentFile(); @@ -172,7 +172,7 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { String rev = String.valueOf(destinationFile.lastModified()); long mtime = destinationFile.lastModified(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = repoUri.buildUpon().appendPath(repositoryPath).build(); return new VersionedRook(repoId, RepoType.DIRECTORY, repoUri, uri, rev, mtime); } diff --git a/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java b/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java index 1062d1bd7..4782cb51a 100644 --- a/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java @@ -141,13 +141,13 @@ private DocumentFile getDocumentFileFromFileName(String fileName) { } @Override - public VersionedRook retrieveBook(String fileName, File destinationFile) throws IOException { - DocumentFile sourceFile = getDocumentFileFromFileName(fileName); + public VersionedRook retrieveBook(String repositoryPath, File destinationFile) throws IOException { + DocumentFile sourceFile = getDocumentFileFromFileName(repositoryPath); if (sourceFile == null) { - throw new FileNotFoundException("Book " + fileName + " not found in " + repoUri); + throw new FileNotFoundException("Book " + repositoryPath + " not found in " + repoUri); } else { if (BuildConfig.LOG_DEBUG) { - LogUtils.d(TAG, "Found DocumentFile for " + fileName + ": " + sourceFile.getUri()); + LogUtils.d(TAG, "Found DocumentFile for " + repositoryPath + ": " + sourceFile.getUri()); } } @@ -170,20 +170,20 @@ public InputStream openRepoFileInputStream(String fileName) throws IOException { } @Override - public VersionedRook storeBook(File file, String path) throws IOException { + public VersionedRook storeBook(File file, String repositoryPath) throws IOException { if (!file.exists()) { throw new FileNotFoundException("File " + file + " does not exist"); } - DocumentFile destinationFile = getDocumentFileFromFileName(path); - if (path.contains("/")) { - DocumentFile destinationDir = ensureDirectoryHierarchy(path); - String fileName = Uri.parse(path).getLastPathSegment(); + DocumentFile destinationFile = getDocumentFileFromFileName(repositoryPath); + if (repositoryPath.contains("/")) { + DocumentFile destinationDir = ensureDirectoryHierarchy(repositoryPath); + String fileName = Uri.parse(repositoryPath).getLastPathSegment(); if (destinationDir.findFile(fileName) == null) { destinationFile = destinationDir.createFile("text/*", fileName); } } else { if (!destinationFile.exists()) { - repoDocumentFile.createFile("text/*", path); + repoDocumentFile.createFile("text/*", repositoryPath); } } diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index 1dbe611ab..4164cd544 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -45,8 +45,8 @@ public List getBooks() throws IOException { } @Override - public VersionedRook retrieveBook(String fileName, File file) throws IOException { - return client.download(repoUri, fileName, file); + public VersionedRook retrieveBook(String repositoryPath, File file) throws IOException { + return client.download(repoUri, repositoryPath, file); } @Override @@ -55,8 +55,8 @@ public InputStream openRepoFileInputStream(String fileName) throws IOException { } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { - return client.upload(file, repoUri, fileName); + public VersionedRook storeBook(File file, String repositoryPath) throws IOException { + return client.upload(file, repoUri, repositoryPath); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 1e0b6bddf..5c56c39e2 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -206,9 +206,9 @@ RevCommit getCommitFromRevisionString(String revisionString) throws IOException } @Override - public VersionedRook retrieveBook(String fileName, File destination) throws IOException { + public VersionedRook retrieveBook(String repositoryPath, File destination) throws IOException { - Uri sourceUri = Uri.parse("/" + fileName); + Uri sourceUri = Uri.parse("/" + repositoryPath); // Ensure our repo copy is up-to-date. This is necessary when force-loading a book. synchronizer.mergeWithRemote(); diff --git a/app/src/main/java/com/orgzly/android/repos/MockRepo.java b/app/src/main/java/com/orgzly/android/repos/MockRepo.java index 35f8b9cea..8eaea5640 100644 --- a/app/src/main/java/com/orgzly/android/repos/MockRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/MockRepo.java @@ -53,9 +53,9 @@ public List getBooks() throws IOException { } @Override - public VersionedRook retrieveBook(String fileName, File file) throws IOException { + public VersionedRook retrieveBook(String repositoryPath, File file) throws IOException { SystemClock.sleep(SLEEP_FOR_RETRIEVE_BOOK); - return databaseRepo.retrieveBook(fileName, file); + return databaseRepo.retrieveBook(repositoryPath, file); } @Override @@ -64,9 +64,9 @@ public InputStream openRepoFileInputStream(String fileName) throws IOException { } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String repositoryPath) throws IOException { SystemClock.sleep(SLEEP_FOR_STORE_BOOK); - return databaseRepo.storeBook(file, fileName); + return databaseRepo.storeBook(file, repositoryPath); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java index 6009051a6..192bbf470 100644 --- a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java @@ -31,7 +31,7 @@ public interface SyncRepo { /** * Download the latest available revision of the book and store its content to {@code File}. */ - VersionedRook retrieveBook(String fileName, File destination) throws IOException; + VersionedRook retrieveBook(String repositoryPath, File destination) throws IOException; /** * Open a file in the repository for reading. Originally added for parsing the .orgzlyignore @@ -44,10 +44,18 @@ public interface SyncRepo { /** * Uploads book storing it under given filename under repo's url. * @param file The contents of this file should be stored at the remote location/repo - * @param fileName The contents ({@code file}) should be stored under this name + * @param repositoryPath The contents ({@code file}) should be stored under this + * (non-encoded) name */ - VersionedRook storeBook(File file, String fileName) throws IOException; + VersionedRook storeBook(File file, String repositoryPath) throws IOException; + /** + * + * @param oldFullUri Uri of the original repository file + * @param newName The new desired book name + * @return + * @throws IOException + */ VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException; // VersionedRook moveBook(Uri from, Uri uri) throws IOException; diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index 633cc47f4..03f96d475 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -19,6 +19,7 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.io.InputStream +import java.net.URI import java.security.KeyStore import java.security.cert.CertificateFactory import java.util.* @@ -27,6 +28,7 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import kotlin.io.path.toPath class WebdavRepo( @@ -185,8 +187,8 @@ class WebdavRepo( .toMutableList() } - override fun retrieveBook(fileName: String?, destination: File?): VersionedRook { - val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + override fun retrieveBook(repositoryPath: String?, destination: File?): VersionedRook { + val fileUrl = Uri.withAppendedPath(uri, repositoryPath).toUrl() sardine.get(fileUrl).use { inputStream -> FileOutputStream(destination).use { outputStream -> @@ -206,7 +208,8 @@ class WebdavRepo( private fun ensureDirectoryHierarchy(relativePath: String) { val levels: ArrayList = ArrayList(relativePath.split("/")) - var currentDir: String = uri.toString() + // N.B. Strip off trailing slash from repo URL, if present + var currentDir: String = uri.toString().replace(Regex("/$"), "") while (levels.size > 1) { val nextDirName: String = levels.removeAt(0) currentDir = "$currentDir/$nextDirName" @@ -216,8 +219,8 @@ class WebdavRepo( } } - override fun storeBook(file: File, fileName: String): VersionedRook { - val encodedFileName = Uri.encode(fileName, "/") + override fun storeBook(file: File, repositoryPath: String): VersionedRook { + val encodedFileName = Uri.encode(repositoryPath, "/") if (encodedFileName != null) { if (encodedFileName.contains("/")) { ensureDirectoryHierarchy(encodedFileName) @@ -258,23 +261,34 @@ class WebdavRepo( repoId, RepoType.WEBDAV, uri, - this.getFullUrl(), + Uri.parse(this.getFullUrlString()), this.modified.time.toString(), this.modified.time ) } - private fun DavResource.getFullUrl(): Uri { - return Uri.parse(uri.scheme + "://" + uri.authority + this.href.toString()) - + /** + * A WebDAV href can be either an absolute (full) URI, or an "absolute path" + * (cf. http://www.webdav.org/specs/rfc4918.html#url-handling). The full URI can be built from + * the absolute path. + */ + private fun DavResource.getFullUrlString(): String { + if (this.href.isAbsolute) { + // absolute-URI - return the href as-is + return this.href.toString() + } else { + // path-absolute - build the absolut URI + return uri.scheme + "://" + uri.authority + this.href.toString() + } } private fun DavResource.getRelativePath(): String { - val fullUrlString = this.getFullUrl().toString() - return fullUrlString.replace(Regex("^$uri/"), "") + val absoluteUri = URI.create(this.getFullUrlString()) + val relativePath = URI.create(uri.toString()).relativize(absoluteUri) + return relativePath.path } private fun Uri.toUrl(): String { return this.toString().replace("^(?:web)?dav(s?://)".toRegex(), "http$1") } -} +} \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/WebdavTest.kt b/app/src/test/java/com/orgzly/android/WebdavTest.kt index 3eb8f7bed..a121f5043 100644 --- a/app/src/test/java/com/orgzly/android/WebdavTest.kt +++ b/app/src/test/java/com/orgzly/android/WebdavTest.kt @@ -1,37 +1,249 @@ package com.orgzly.android -import com.orgzly.android.App.appComponent +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.orgzly.android.db.entity.Repo +import com.orgzly.android.repos.RepoIgnoreNode import com.orgzly.android.repos.RepoType import com.orgzly.android.repos.RepoWithProps +import com.orgzly.android.repos.SyncRepo import com.orgzly.android.repos.WebdavRepo import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY +import com.orgzly.android.util.MiscUtils import io.github.atetzner.webdav.server.MiltonWebDAVFileServer +import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.IOException -@RunWith(RobolectricTestRunner::class) + +@RunWith(AndroidJUnit4::class) class WebdavTest { - - @Test - fun testRetrieveBook() { - val rootFolder = java.nio.file.Files.createTempDirectory("orgzly-webdav-test-").toFile() - val server = MiltonWebDAVFileServer(rootFolder) - server.userCredentials["user"] = "secret" - server.start() + + private val serverUrl = "http://localhost:8081/" + + private lateinit var serverRootDir: File + private lateinit var localServer: MiltonWebDAVFileServer + private lateinit var syncRepo: SyncRepo + private lateinit var tmpFile: File + + @Before + fun setup() { + serverRootDir = java.nio.file.Files.createTempDirectory("orgzly-webdav-test-").toFile() + localServer = MiltonWebDAVFileServer(serverRootDir) + localServer.userCredentials["user"] = "secret" + localServer.start() + val repo = Repo(0, RepoType.WEBDAV, serverUrl) val repoPropsMap = HashMap() repoPropsMap[USERNAME_PREF_KEY] = "user" repoPropsMap[PASSWORD_PREF_KEY] = "secret" - val repo = Repo(0, RepoType.WEBDAV, "http://localhost:8080/") val repoWithProps = RepoWithProps(repo, repoPropsMap) - val syncRepo = WebdavRepo.getInstance(repoWithProps) + syncRepo = WebdavRepo.getInstance(repoWithProps) + assertEquals(serverUrl, repo.url) + tmpFile = kotlin.io.path.createTempFile().toFile() + } + + @After + fun tearDown() { + tmpFile.delete() + if (this::localServer.isInitialized) { + localServer.stop() + } + if (this::serverRootDir.isInitialized) { + serverRootDir.deleteRecursively() + } + } + + @Test + fun testGetBooks_singleOrgFile() { + // N.B. Expected book name contains space + val remoteBookFile = File(serverRootDir.absolutePath + "/book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val books = syncRepo.books + assertEquals(1, books.size) + assertEquals(serverUrl + "book%20one.org", books[0].uri.toString()) + val retrievedBookFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook("book one.org", retrievedBookFile) + // Assert that the two files are identical + assertEquals(remoteBookFile.readText(), retrievedBookFile.readText()) + // Assert reported file name + val rookFileName = BookName.getFileName(syncRepo.uri, books[0].uri) + assertEquals("book one.org", rookFileName) + } + + @Test + fun testGetBooks_singleFileInSubfolder() { + val subFolder = File(serverRootDir.absolutePath + "/folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath + "/book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val books = syncRepo.books + assertEquals(1, books.size) + assertEquals(serverUrl + "folder/book%20one.org", books[0].uri.toString()) + // Assert reported file name + val rookFileName = BookName.getFileName(syncRepo.uri, books[0].uri) + assertEquals("folder/book one.org", rookFileName) + // Assert that the two files are identical + val retrievedBookFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook(rookFileName, retrievedBookFile) + assertEquals(remoteBookFile.readText(), retrievedBookFile.readText()) + } + + @Test + fun testGetBooks_allFilesAreIgnored() { + val subFolder = File(serverRootDir.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val ignoreFile = File(serverRootDir.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile("*\n", ignoreFile) + val books = syncRepo.books + assertEquals(0, books.size) + } + + @Test + fun testGetBooks_specificFileInSubfolderIsIgnored() { + val subFolder = File(serverRootDir.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val ignoreFile = File(serverRootDir.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile("folder/book one.org\n", ignoreFile) val books = syncRepo.books assertEquals(0, books.size) - Thread.sleep(2000) - server.stop() - rootFolder.deleteRecursively() + } + + @Test + fun testGetBooks_specificFileIsUnignored() { + val subFolder = File(serverRootDir.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val ignoreFile = File(serverRootDir.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile("*\n!folder/book one.org", ignoreFile) + val books = syncRepo.books + assertEquals(1, books.size) + } + + @Test + fun testGetBooks_ignoredExtensions() { + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + val remoteBookFile = File(serverRootDir.absolutePath, fileName) + MiscUtils.writeStringToFile("...", remoteBookFile) + } + val books = syncRepo.books + assertEquals(1, books.size.toLong()) + assertEquals("file three", BookName.fromFileName(BookName.getFileName(syncRepo.uri, books[0].uri)).name) + } + + @Test + fun testStoreBook_expectedUri() { + MiscUtils.writeStringToFile("...", tmpFile) + val vrook = syncRepo.storeBook(tmpFile, "Book one.org") + assertEquals(syncRepo.uri.toString() + "Book%20one.org", vrook.uri.toString()) + } + + @Test + fun testStoreBook_producesSameUriAsRetrieveBook() { + val repositoryPath = "a folder/a book.org" + MiscUtils.writeStringToFile("...", tmpFile) + val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) + val retrievedBook = syncRepo.retrieveBook(repositoryPath, tmpFile) + assertEquals(retrievedBook.uri, storedRook.uri) + } + + @Test + fun testStoreBook_producesSameUriAsGetBooks() { + val repositoryPath = "a folder/a book.org" + val repoSubDir = File(serverRootDir.absolutePath, "a folder") + repoSubDir.mkdir() + val repoBookFile = File(repoSubDir, "a book.org") + MiscUtils.writeStringToFile("...", repoBookFile) + val getBook = syncRepo.books[0] + MiscUtils.writeStringToFile(".......", tmpFile) + val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) + assertEquals(getBook.uri, storedRook.uri) + } + + @Test + fun testStoreBook_inSubfolder() { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "a folder/a book.org") + val subFolder = File(serverRootDir, "a folder") + assertTrue(subFolder.exists()) + val bookFile = File(subFolder, "a book.org") + assertTrue(bookFile.exists()) + assertEquals("...", bookFile.readText()) + } + + @Test + fun testRenameBook_expectedUri() { + val remoteBookFile = File(serverRootDir.absolutePath + "/Book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val originalVrook = syncRepo.books[0] + assertEquals(syncRepo.uri.toString() + "Book%20one.org", originalVrook.uri.toString()) + val renamedVrook = syncRepo.renameBook(originalVrook.uri, "Renamed book") + assertEquals(syncRepo.uri.toString() + "Renamed%20book.org", renamedVrook.uri.toString()) + } + + @Test(expected = IOException::class) + fun testRenameBook_repoFileAlreadyExists() { + for (bookName in arrayOf("Original", "Renamed")) { + val remoteBookFile = File(serverRootDir.absolutePath + "/" + bookName + ".org") + MiscUtils.writeStringToFile("...", remoteBookFile) + } + val originalRook = syncRepo.retrieveBook("Original.org", tmpFile) +// exceptionRule.expect(IOException::class.java) +// exceptionRule.expectMessage("File at " + syncRepo.uri.toString() + "Renamed.org already exists") + try { + syncRepo.renameBook(originalRook.uri, "Renamed") + } catch (e: IOException) { + assertTrue(e.message!!.contains("File at " + syncRepo.uri.toString() + "Renamed.org already exists")) + throw e + } + } + + @Test + fun testRenameBook_fromRootToSubfolder() { + MiscUtils.writeStringToFile("...", tmpFile) + val originalRook = syncRepo.storeBook(tmpFile, "Original.org") + val renamedRook = syncRepo.renameBook(originalRook.uri, "a folder/Renamed") + assertEquals(syncRepo.uri.toString() + "a%20folder/Renamed.org", renamedRook.uri.toString()) + } + + @Test + fun testRenameBook_fromSubfolderToRoot() { + MiscUtils.writeStringToFile("...", tmpFile) + val originalRook = syncRepo.storeBook(tmpFile, "a folder/Original.org") + val renamedRook = syncRepo.renameBook(originalRook.uri, "Renamed") + assertEquals(syncRepo.uri.toString() + "Renamed.org", renamedRook.uri.toString()) + } + + @Test + fun testRenameBook_newSubfolderSameLeafName() { + MiscUtils.writeStringToFile("...", tmpFile) + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original.org") + val renamedRook = syncRepo.renameBook(originalRook.uri, "new folder/Original") + assertEquals(syncRepo.uri.toString() + "new%20folder/Original.org", renamedRook.uri.toString()) + } + + @Test + fun testRenameBook_newSubfolderAndLeafName() { + MiscUtils.writeStringToFile("...", tmpFile) + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") + val renamedRook = syncRepo.renameBook(originalRook.uri, "new folder/New book") + assertEquals(syncRepo.uri.toString() + "new%20folder/New%20book.org", renamedRook.uri.toString()) + } + + @Test + fun testRenameBook_sameSubfolderNewLeafName() { + MiscUtils.writeStringToFile("...", tmpFile) + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") + val renamedRook = syncRepo.renameBook(originalRook.uri, "old folder/New book") + assertEquals(syncRepo.uri.toString() + "old%20folder/New%20book.org", renamedRook.uri.toString()) } } \ No newline at end of file From 09c17876f44bb2be88d5cf4ce569568a99eb662a Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 11 Aug 2024 13:37:37 +0200 Subject: [PATCH 59/73] wip --- app/build.gradle | 2 + .../com/orgzly/android/repos/SyncRepoTest.kt | 175 +++++++++++------- 2 files changed, 113 insertions(+), 64 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c007cf2f7..931132b27 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -185,6 +185,8 @@ dependencies { androidTestImplementation "de.sven-jacobs:loremipsum:$versions.loremipsum" + androidTestImplementation "io.github.atetzner:webdav-embedded-server:0.2.1" + // Dagger implementation "com.google.dagger:dagger:$versions.dagger" kapt "com.google.dagger:dagger-compiler:$versions.dagger" diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 6a520a994..1c94e9db0 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -16,7 +16,6 @@ import androidx.test.uiautomator.UiSelector import com.orgzly.R import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest -import com.orgzly.android.RetryTestRule import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo import com.orgzly.android.espresso.util.EspressoUtils @@ -24,16 +23,12 @@ import com.orgzly.android.git.GitFileSynchronizer import com.orgzly.android.git.GitPreferencesFromRepoPrefs import com.orgzly.android.prefs.AppPreferences import com.orgzly.android.prefs.RepoPreferences -import com.orgzly.android.repos.RepoType.DIRECTORY -import com.orgzly.android.repos.RepoType.DOCUMENT -import com.orgzly.android.repos.RepoType.DROPBOX -import com.orgzly.android.repos.RepoType.GIT -import com.orgzly.android.repos.RepoType.MOCK -import com.orgzly.android.repos.RepoType.WEBDAV +import com.orgzly.android.repos.RepoType.* import com.orgzly.android.sync.BookSyncStatus import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils +import io.github.atetzner.webdav.server.MiltonWebDAVFileServer import org.eclipse.jgit.api.Git import org.hamcrest.CoreMatchers.containsString import org.hamcrest.core.AllOf @@ -53,7 +48,7 @@ import java.util.UUID import kotlin.io.path.createTempDirectory @RunWith(value = Parameterized::class) -class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { +class SyncRepoTest(private val repoType: RepoType) : OrgzlyTest() { private val permanentRepoTestDir = "orgzly-android-tests" private var topDirName = RANDOM_UUID @@ -65,32 +60,33 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { private lateinit var gitBareRepoPath: Path private lateinit var gitFileSynchronizer: GitFileSynchronizer - // used by DocumentRepo + // Used by DocumentRepo private lateinit var documentTreeSegment: String - data class Parameter(val repoType: RepoType) + // Used by WebdavRepo + private val webDavServerUrl = "http://localhost:8081/" + private lateinit var serverRootDir: File + private lateinit var localServer: MiltonWebDAVFileServer + private lateinit var tmpFile: File companion object { @JvmStatic @Parameterized.Parameters(name = "{0}") - fun data(): Collection { - return listOf( - Parameter(repoType = GIT), - Parameter(repoType = DOCUMENT), - Parameter(repoType = DROPBOX), + fun data(): Array { + return arrayOf( +// GIT, +// DOCUMENT, +// DROPBOX, + WEBDAV, ) } /* For creating a unique directory per test suite instance for tests which interact with - the cloud (Dropbox, Webdav), to avoid collisions when they are run simultaneously on + the cloud (Dropbox), to avoid collisions when they are run simultaneously on different devices. */ val RANDOM_UUID = UUID.randomUUID().toString() } - @Rule - @JvmField - val mRetryTestRule = RetryTestRule() - override fun tearDown() { super.tearDown() if (this::repo.isInitialized) { @@ -100,7 +96,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { DROPBOX -> tearDownDropboxRepo() DIRECTORY -> TODO() DOCUMENT -> tearDownDocumentRepo() - WEBDAV -> TODO() + WEBDAV -> tearDownWebdavRepo() } } } @@ -113,7 +109,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testLoadBook() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("...", tmpFile) @@ -135,7 +131,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testForceLoadBook() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) val bookView = testUtils.setupBook("booky", "content") testUtils.sync() var books = dataRepository.getBooks() @@ -150,7 +146,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testLoadBookWithSpaceInName() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("...", tmpFile) @@ -175,7 +171,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testExtension() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) // Add multiple files to repo for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { val tmpFile = File.createTempFile("orgzly-test", null) @@ -194,7 +190,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // TODO: Move to DataRepository tests @Test fun testSyncNewBookWithoutLinkAndOneRepo() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("Book 1", "content") testUtils.sync() val bookView = dataRepository.getBooks()[0] @@ -210,7 +206,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // TODO: Move to DataRepository tests @Test fun testRenameBook() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("oldname", "") testUtils.sync() var bookView = dataRepository.getBookView("oldname") @@ -234,7 +230,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // TODO: Move to DataRepository tests @Test fun testRenameBookToNameWithSpace() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("oldname", "") testUtils.sync() var bookView = dataRepository.getBookView("oldname") @@ -252,7 +248,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { bookView = dataRepository.getBookView("new name") assertEquals(repo.url, bookView!!.linkRepo!!.url) assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - val expectedRookUriName = when (param.repoType) { + val expectedRookUriName = when (repoType) { GIT -> "new name.org" else -> { "new%20name.org" } } @@ -261,7 +257,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookToExistingRepoFileName() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a", "") testUtils.sync() @@ -283,7 +279,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // TODO: Move to DataRepository tests (check happens there) @Test fun testRenameBookToExistingBookName() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a", "") testUtils.setupBook("b", "") assertEquals(2, dataRepository.getBooks().size) @@ -298,7 +294,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { ignoredbook.org ignored-*.org """.trimIndent() - setupSyncRepo(param.repoType, ignoreRules) + setupSyncRepo(repoType, ignoreRules) // Add multiple files to repo for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { val tmpFile = File.createTempFile("orgzly-test", null) @@ -319,7 +315,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { *.org !notignored.org """.trimIndent() - setupSyncRepo(param.repoType, ignoreFileContents) + setupSyncRepo(repoType, ignoreFileContents) // Add multiple files to repo for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -337,7 +333,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testIgnoreRulePreventsRenamingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(param.repoType,"bad name*") + setupSyncRepo(repoType,"bad name*") // Create book and sync it testUtils.setupBook("good name", "") @@ -355,7 +351,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Throws(java.lang.Exception::class) fun testIgnoreRulePreventsLinkingBook() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(param.repoType, "*.org") + setupSyncRepo(repoType, "*.org") testUtils.setupBook("booky", "") exceptionRule.expect(IOException::class.java) exceptionRule.expectMessage("matches a rule in .orgzlyignore") @@ -364,11 +360,11 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testStoreBookInSubfolder() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a folder/a book", "") testUtils.sync() assertEquals(1, syncRepo.books.size) - val expectedRookUri = when (param.repoType) { + val expectedRookUri = when (repoType) { GIT -> "/a folder/a book.org" DOCUMENT -> repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org" else -> { repo.url + "/a%20folder/a%20book.org" } @@ -380,7 +376,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testLoadBookFromSubfolder() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) val tmpFile = dataRepository.getTempBookFile() try { MiscUtils.writeStringToFile("...", tmpFile) @@ -407,7 +403,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(IOException::class) fun testForceLoadBookInSubfolder() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) val bookView = testUtils.setupBook("a folder/a book", "content") testUtils.sync() var books = dataRepository.getBooks() @@ -423,7 +419,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testIgnoreFileInSubfolder() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(param.repoType, "subfolder1/book1.org") + setupSyncRepo(repoType, "subfolder1/book1.org") // Write 2 org files to subfolder in repo for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -442,7 +438,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testUnIgnoreSingleFileInSubfolder() { Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(param.repoType, "subfolder1/**\n!subfolder1/book2.org") + setupSyncRepo(repoType, "subfolder1/**\n!subfolder1/book2.org") // Write 2 org files to subfolder in repo for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { val tmpFile = File.createTempFile("orgzlytest", null) @@ -460,7 +456,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testStoreBookAndRetrieveBookProducesSameRookUri() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) val repoFilePath = "folder one/book one.org" @@ -489,7 +485,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { // TODO: Move - does not test SyncRepo code @Test fun testUpdateBookInSubfolder() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) // Create org file in subfolder val tmpFile = dataRepository.getTempBookFile() try { @@ -525,7 +521,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookFromRootToSubfolder() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("booky", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") @@ -534,7 +530,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) val bookView = dataRepository.getBookView("a/b") assertEquals(BookSyncStatus.NO_CHANGE.toString(), bookView!!.book.syncStatus) - val expectedRookUri = when (param.repoType) { + val expectedRookUri = when (repoType) { GIT -> "/a/b.org" DOCUMENT -> repo.url + documentTreeSegment + "a%2Fb.org" else -> { repo.url + "/a/b.org" } @@ -547,7 +543,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookFromSubfolderToRoot() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") @@ -556,7 +552,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) val bookView = dataRepository.getBookView("booky") assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (param.repoType) { + val expectedRookUri = when (repoType) { GIT -> "/booky.org" DOCUMENT -> repo.url + documentTreeSegment + "booky.org" else -> { repo.url + "/booky.org" } @@ -569,7 +565,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookNewSubfolderSameLeafName() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") @@ -578,7 +574,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) val bookView = dataRepository.getBookView("b/b") assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (param.repoType) { + val expectedRookUri = when (repoType) { GIT -> "/b/b.org" DOCUMENT -> repo.url + documentTreeSegment + "b%2Fb.org" else -> { repo.url + "/b/b.org" } @@ -591,7 +587,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookNewSubfolderAndLeafName() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") @@ -600,7 +596,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) val bookView = dataRepository.getBookView("b/c") assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (param.repoType) { + val expectedRookUri = when (repoType) { GIT -> "/b/c.org" DOCUMENT -> repo.url + documentTreeSegment + "b%2Fc.org" else -> { repo.url + "/b/c.org" } @@ -613,7 +609,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test fun testRenameBookSameSubfolderNewLeafName() { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) testUtils.setupBook("a/b", "") testUtils.sync() dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") @@ -622,7 +618,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(1, dataRepository.getBooks().size.toLong()) val bookView = dataRepository.getBookView("a/c") assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (param.repoType) { + val expectedRookUri = when (repoType) { GIT -> "/a/c.org" DOCUMENT -> repo.url + documentTreeSegment + "a%2Fc.org" else -> { repo.url + "/a/c.org" } @@ -636,12 +632,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { @Test @Throws(FileNotFoundException::class) fun testSyncWithDirectoryWithSpaceInName() { - Assume.assumeTrue(param.repoType != GIT) // Git repo URLs will never contain a space + Assume.assumeTrue(repoType != GIT) // Git repo URLs will never contain a space topDirName = "space separated" - if (param.repoType == DOCUMENT) { + if (repoType == DOCUMENT) { setupDocumentRepo(topDirName) } else { - setupSyncRepo(param.repoType) + setupSyncRepo(repoType) } val tmpFile = dataRepository.getTempBookFile() try { @@ -652,7 +648,28 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } testUtils.sync() assertEquals(1, dataRepository.getBooks().size.toLong()) - assertTrue(syncRepo.uri.toString().contains("space separated")) + if (repoType == DOCUMENT) { + assertTrue(syncRepo.uri.toString().contains("space%20separated")) + } else { + assertTrue(syncRepo.uri.toString().contains("space separated")) + } + } + + @Test + fun testGetBooks_singleOrgFile() { + // N.B. Expected book name contains space + val remoteBookFile = File(serverRootDir.absolutePath + "/book one.org") + MiscUtils.writeStringToFile("...", remoteBookFile) + val books = syncRepo.books + assertEquals(1, books.size) + assertEquals(webDavServerUrl + "book%20one.org", books[0].uri.toString()) + val retrievedBookFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook("book one.org", retrievedBookFile) + // Assert that the two files are identical + assertEquals(remoteBookFile.readText(), retrievedBookFile.readText()) + // Assert reported file name + val rookFileName = BookName.getFileName(syncRepo.uri, books[0].uri) + assertEquals("book one.org", rookFileName) } private fun setupSyncRepo(repoType: RepoType, ignoreRules: String? = null) { @@ -662,7 +679,7 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { DROPBOX -> setupDropboxRepo() DIRECTORY -> TODO() DOCUMENT -> setupDocumentRepo() - WEBDAV -> TODO() + WEBDAV -> setupWebdavRepo() } if (ignoreRules != null) { val tmpFile = File.createTempFile("orgzly-test", null) @@ -672,6 +689,31 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } } + private fun setupWebdavRepo() { + serverRootDir = java.nio.file.Files.createTempDirectory("orgzly-webdav-test-").toFile() + localServer = MiltonWebDAVFileServer(serverRootDir) + localServer.userCredentials["user"] = "secret" + localServer.start() + val repo = Repo(0, WEBDAV, webDavServerUrl) + val repoPropsMap = HashMap() + repoPropsMap[WebdavRepo.USERNAME_PREF_KEY] = "user" + repoPropsMap[WebdavRepo.PASSWORD_PREF_KEY] = "secret" + val repoWithProps = RepoWithProps(repo, repoPropsMap) + syncRepo = WebdavRepo.getInstance(repoWithProps) + assertEquals(webDavServerUrl, repo.url) + tmpFile = kotlin.io.path.createTempFile().toFile() + } + + private fun tearDownWebdavRepo() { + tmpFile.delete() + if (this::localServer.isInitialized) { + localServer.stop() + } + if (this::serverRootDir.isInitialized) { + serverRootDir.deleteRecursively() + } + } + private fun setupDropboxRepo() { testUtils.dropboxTestPreflight() repo = testUtils.setupRepo(DROPBOX, "dropbox:/$permanentRepoTestDir/$topDirName") @@ -686,19 +728,18 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { } private fun setupDocumentRepo(extraDir: String? = null) { - val encodedRepoDirName = Uri.encode(permanentRepoTestDir) documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { - "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName%2F" + "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$permanentRepoTestDir%2F" } else { - "/document/primary%3A$encodedRepoDirName%2F" + "/document/primary%3A$permanentRepoTestDir%2F" } var treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { - "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$encodedRepoDirName" + "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$permanentRepoTestDir" } else { - "content://com.android.externalstorage.documents/tree/primary%3A$encodedRepoDirName" + "content://com.android.externalstorage.documents/tree/primary%3A$permanentRepoTestDir" } if (extraDir != null) { - treeDocumentFileUrl = "$treeDocumentFileUrl%2F$extraDir" + treeDocumentFileUrl = "$treeDocumentFileUrl%2F" + Uri.encode(extraDir) } val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) repo = if (repoDirDocumentFile?.exists() == false) { @@ -715,6 +756,12 @@ class SyncRepoTest(private val param: Parameter) : OrgzlyTest() { assertEquals(treeDocumentFileUrl, repo.url) } + /** + * Note that this solution only works the first time the tests are run on any given virtual + * device. On the second run, the file picker will start in a different folder, resulting in + * a different repo URL, making some tests fail. If you are running locally, you must work + * around this by wiping the device's data between test suite runs. + */ private fun setupDocumentRepoInUi(repoDirName: String) { ActivityScenario.launch(ReposActivity::class.java).use { Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) From 86b7e81c75a4376a123e1e25c85fe58041ec1b02 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 10 Aug 2024 12:50:06 +0200 Subject: [PATCH 60/73] Tell Gradle to use Java 17 And use gradle/actions/setup-gradle@v4 --- .github/workflows/test.yaml | 10 +++++++++- app/build.gradle | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e209c402c..e6e72288f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -39,8 +39,16 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: 8.1 - name: AVD cache uses: actions/cache@v4 diff --git a/app/build.gradle b/app/build.gradle index 931132b27..0b11c31cd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -97,12 +97,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = 11 + jvmTarget = 17 } packagingOptions { @@ -243,4 +243,4 @@ def orgJavaLocation() { logger.info("app: Using com.github.orgzly-revived:org-java:$versions.org_java from Maven repository") return "com.github.orgzly-revived:org-java:$versions.org_java" } -} \ No newline at end of file +} From 82ff1f5cf920e779aa8fe1a4da29bc5f42127edc Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 10 Aug 2024 13:12:37 +0200 Subject: [PATCH 61/73] Complete Gradle update to 8.1.1 --- .github/workflows/test.yaml | 2 - gradle/wrapper/gradle-wrapper.jar | Bin 49896 -> 61608 bytes gradle/wrapper/gradle-wrapper.properties | 7 +- gradlew | 304 ++++++++++++++--------- gradlew.bat | 66 ++--- 5 files changed, 229 insertions(+), 150 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e6e72288f..ef2a7ed63 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -47,8 +47,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - with: - gradle-version: 8.1 - name: AVD cache uses: actions/cache@v4 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8c0fb64a8698b08ecc4158d828ca593c4928e9dd..ccebba7710deaf9f98673a68957ea02138b60d0a 100644 GIT binary patch literal 61608 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 literal 49896 zcmagFb986H(k`5d^NVfUwr$(C?M#x1ZQHiZiEVpg+jrjgoQrerx!>1o_ul)D>ebz~ zs=Mmxr&>W81QY-S1PKWQ%N-;H^tS;2*XwVA`dej1RRn1z<;3VgfE4~kaG`A%QSPsR z#ovnZe+tS9%1MfeDyz`RirvdjPRK~p(#^q2(^5@O&NM19EHdvN-A&StN>0g6QA^VN z0Gx%Gq#PD$QMRFzmK+utjS^Y1F0e8&u&^=w5K<;4Rz|i3A=o|IKLY+g`iK6vfr9?+ z-`>gmU&i?FGSL5&F?TXFu`&Js6h;15QFkXp2M1H9|Eq~bpov-GU(uz%mH0n55wUl- zv#~ccAz`F5wlQ>e_KlJS3@{)B?^v*EQM=IxLa&76^y51a((wq|2-`qON>+4dLc{Oo z51}}o^Zen(oAjxDK7b++9_Yg`67p$bPo3~BCpGM7uAWmvIhWc5Gi+gQZ|Pwa-Gll@<1xmcPy z|NZmu6m)g5Ftu~BG&Xdxclw7Cij{xbBMBn-LMII#Slp`AElb&2^Hw+w>(3crLH!;I zN+Vk$D+wP1#^!MDCiad@vM>H#6+`Ct#~6VHL4lzmy;lSdk>`z6)=>Wh15Q2)dQtGqvn0vJU@+(B5{MUc*qs4!T+V=q=wy)<6$~ z!G>e_4dN@lGeF_$q9`Ju6Ncb*x?O7=l{anm7Eahuj_6lA{*#Gv*TaJclevPVbbVYu z(NY?5q+xxbO6%g1xF0r@Ix8fJ~u)VRUp`S%&rN$&e!Od`~s+64J z5*)*WSi*i{k%JjMSIN#X;jC{HG$-^iX+5f5BGOIHWAl*%15Z#!xntpk($-EGKCzKa zT7{siZ9;4TICsWQ$pu&wKZQTCvpI$Xvzwxoi+XkkpeE&&kFb!B?h2hi%^YlXt|-@5 zHJ~%AN!g_^tmn1?HSm^|gCE#!GRtK2(L{9pL#hp0xh zME}|DB>(5)`iE7CM)&_+S}-Bslc#@B5W4_+k4Cp$l>iVyg$KP>CN?SVGZ(&02>iZK zB<^HP$g$Lq*L$BWd?2(F?-MUbNWTJVQdW7$#8a|k_30#vHAD1Z{c#p;bETk0VnU5A zBgLe2HFJ3032$G<`m*OB!KM$*sdM20jm)It5OSru@tXpK5LT>#8)N!*skNu1$TpIw zufjjdp#lyH5bZ%|Iuo|iu9vG1HrIVWLH>278xo>aVBkPN3V$~!=KnlXQ4eDqS7%E% zQ!z^$Q$b^6Q)g#cLpwur(|<0gWHo6A6jc;n`t(V9T;LzTAU{IAu*uEQ%Ort1k+Kn+f_N`9|bxYC+~Z1 zCC1UCWv*Orx$_@ydv9mIe(liLfOr7mhbV@tKw{6)q^1DH1nmvZ0cj215R<~&I<4S| zgnr;9Cdjqpz#o8i0CQjtl`}{c*P)aSdH|abxGdrR)-3z+02-eX(k*B)Uqv6~^nh** z zGh0A%o~bd$iYvP!egRY{hObDIvy_vXAOkeTgl5o!33m!l4VLm@<-FwT0+k|yl~vUh z@RFcL4=b(QQQmwQ;>FS_e96dyIU`jmR%&&Amxcb8^&?wvpK{_V_IbmqHh);$hBa~S z;^ph!k~noKv{`Ix7Hi&;Hq%y3wpqUsYO%HhI3Oe~HPmjnSTEasoU;Q_UfYbzd?Vv@ zD6ztDG|W|%xq)xqSx%bU1f>fF#;p9g=Hnjph>Pp$ZHaHS@-DkHw#H&vb1gARf4A*zm3Z75QQ6l( z=-MPMjish$J$0I49EEg^Ykw8IqSY`XkCP&TC?!7zmO`ILgJ9R{56s-ZY$f> zU9GwXt`(^0LGOD9@WoNFK0owGKDC1)QACY_r#@IuE2<`tep4B#I^(PRQ_-Fw(5nws zpkX=rVeVXzR;+%UzoNa;jjx<&@ABmU5X926KsQsz40o*{@47S2 z)p9z@lt=9?A2~!G*QqJWYT5z^CTeckRwhSWiC3h8PQ0M9R}_#QC+lz>`?kgy2DZio zz&2Ozo=yTXVf-?&E;_t`qY{Oy>?+7+I= zWl!tZM_YCLmGXY1nKbIHc;*Mag{Nzx-#yA{ zTATrWj;Nn;NWm6_1#0zy9SQiQV=38f(`DRgD|RxwggL(!^`}lcDTuL4RtLB2F5)lt z=mNMJN|1gcui=?#{NfL{r^nQY+_|N|6Gp5L^vRgt5&tZjSRIk{_*y<3^NrX6PTkze zD|*8!08ZVN)-72TA4Wo3B=+Rg1sc>SX9*X>a!rR~ntLVYeWF5MrLl zA&1L8oli@9ERY|geFokJq^O$2hEpVpIW8G>PPH0;=|7|#AQChL2Hz)4XtpAk zNrN2@Ju^8y&42HCvGddK3)r8FM?oM!3oeQ??bjoYjl$2^3|T7~s}_^835Q(&b>~3} z2kybqM_%CIKk1KSOuXDo@Y=OG2o!SL{Eb4H0-QCc+BwE8x6{rq9j$6EQUYK5a7JL! z`#NqLkDC^u0$R1Wh@%&;yj?39HRipTeiy6#+?5OF%pWyN{0+dVIf*7@T&}{v%_aC8 zCCD1xJ+^*uRsDT%lLxEUuiFqSnBZu`0yIFSv*ajhO^DNoi35o1**16bg1JB z{jl8@msjlAn3`qW{1^SIklxN^q#w|#gqFgkAZ4xtaoJN*u z{YUf|`W)RJfq)@6F&LfUxoMQz%@3SuEJHU;-YXb7a$%W=2RWu5;j44cMjC0oYy|1! zed@H>VQ!7=f~DVYkWT0nfQfAp*<@FZh{^;wmhr|K(D)i?fq9r2FEIatP=^0(s{f8GBn<8T zVz_@sKhbLE&d91L-?o`13zv6PNeK}O5dv>f{-`!ms#4U+JtPV=fgQ5;iNPl9Hf&9( zsJSm5iXIqN7|;I5M08MjUJ{J2@M3 zYN9ft?xIjx&{$K_>S%;Wfwf9N>#|ArVF^shFb9vS)v9Gm00m_%^wcLxe;gIx$7^xR zz$-JDB|>2tnGG@Rrt@R>O40AreXSU|kB3Bm)NILHlrcQ&jak^+~b`)2;otjI(n8A_X~kvp4N$+4|{8IIIv zw*(i}tt+)Kife9&xo-TyoPffGYe;D0a%!Uk(Nd^m?SvaF-gdAz4~-DTm3|Qzf%Pfd zC&tA;D2b4F@d23KV)Csxg6fyOD2>pLy#n+rU&KaQU*txfUj&D3aryVj!Lnz*;xHvl zzo}=X>kl0mBeSRXoZ^SeF94hlCU*cg+b}8p#>JZvWj8gh#66A0ODJ`AX>rubFqbBw z-WR3Z5`33S;7D5J8nq%Z^JqvZj^l)wZUX#7^q&*R+XVPln{wtnJ~;_WQzO{BIFV55 zLRuAKXu+A|7*2L*<_P${>0VdVjlC|n^@lRi}r?wnzQQm z3&h~C3!4C`w<92{?Dpea@5nLP2RJrxvCCBh%Tjobl2FupWZfayq_U$Q@L%$uEB6#X zrm_1TZA8FEtkd`tg)a_jaqnv3BC_O*AUq-*RNLOT)$>2D!r>FZdH&$x5G_FiAPaw4 zgK*7>(qd6R?+M3s@h>Z|H%7eGPxJWn_U$w`fb(Mp+_IK2Kj37YT#Xe5e6KS-_~mW} z`NXEovDJh7n!#q4b+=ne<7uB7Y2(TAR<3@PS&o3P$h#cZ-xF$~JiH6_gsv9v(#ehK zhSB_#AI%lF#+!MB5DMUN+Zhf}=t~{B|Fn{rGM?dOaSvX!D{oGXfS*%~g`W84JJAy4 zMdS?9Bb$vx?`91$J`pD-MGCTHNxU+SxLg&QY+*b_pk0R=A`F}jw$pN*BNM8`6Y=cm zgRh#vab$N$0=XjH6vMyTHQg*+1~gwOO9yhnzZx#e!1H#|Mr<`jJGetsM;$TnciSPJ z5I-R0)$)0r8ABy-2y&`2$33xx#%1mp+@1Vr|q_e=#t7YjjWXH#3F|Fu<G#+-tE2K7 zOJkYxNa74@UT_K4CyJ%mR9Yfa$l=z}lB(6)tZ1Ksp2bv$^OUn3Oed@=Q0M}imYTwX zQoO^_H7SKzf_#kPgKcs%r4BFUyAK9MzfYReHCd=l)YJEgPKq-^z3C%4lq%{&8c{2CGQ3jo!iD|wSEhZ# zjJoH87Rt{4*M_1GdBnBU3trC*hn@KCFABd=Zu`hK;@!TW`hp~;4Aac@24m|GI)Ula z4y%}ClnEu;AL4XVQ6^*!()W#P>BYC@K5mw7c4X|Hk^(mS9ZtfMsVLoPIiwI?w_X0- z#vyiV5q9(xq~fS`_FiUZw->8Awktga>2SrWyvZ|h@LVFtnY#T z%OX30{yiSov4!43kFd(8)cPRMyrN z={af_ONd;m=`^wc7lL|b7V!;zmCI}&8qz=?-6t=uOV;X>G{8pAwf9UJ`Hm=ubIbgR zs6bw3pFeQHL`1P1m5fP~fL*s?rX_|8%tB`Phrij^Nkj{o0oCo*g|ELexQU+2gt66=7}w5A+Qr}mHXC%)(ODT# zK#XTuzqOmMsO~*wgoYjDcy)P7G`5x7mYVB?DOXV^D3nN89P#?cp?A~c%c$#;+|10O z8z(C>mwk#A*LDlpv2~JXY_y_OLZ*Mt)>@gqKf-Ym+cZ{8d%+!1xNm3_xMygTp-!A5 zUTpYFd=!lz&4IFq)Ni7kxLYWhd0o2)ngenV-QP@VCu;147_Lo9f~=+=Nw$6=xyZzp zn7zAe41Sac>O60(dgwPd5a^umFVSH;<7vN>o;}YlMYhBZFZ}-sz`P^3oAI>SCZy&zUtwKSewH;CYysPQN7H>&m215&e2J? zY}>5N-LhaDeRF~C0cB>M z7@y&xh9q??*EIKnh*;1)n-WuSl6HkrI?OUiS^lx$Sr2C-jUm6zhd{nd(>#O8k9*kF zPom7-%w1NjFpj7WP=^!>Vx^6SG^r`r+M&s7V(uh~!T7aE;_ubqNSy)<5(Vi)-^Mp9 zEH@8Vs-+FEeJK%M0z3FzqjkXz$n~BzrtjQv`LagAMo>=?dO8-(af?k@UpL5J#;18~ zHCnWuB(m6G6a2gDq2s`^^5km@A3Rqg-oHZ68v5NqVc zHX_Iw!OOMhzS=gfR7k;K1gkEwuFs|MYTeNhc0js>Wo#^=wX4T<`p zR2$8p6%A9ZTac;OvA4u#Oe3(OUep%&QgqpR8-&{0gjRE()!Ikc?ClygFmGa(7Z^9X zWzmV0$<8Uh)#qaH1`2YCV4Zu6@~*c*bhtHXw~1I6q4I>{92Eq+ZS@_nSQU43bZyidk@hd$j-_iL=^^2CwPcaXnBP;s;b zA4C!k+~rg4U)}=bZ2q*)c4BZ#a&o!uJo*6hK3JRBhOOUQ6fQI;dU#3v>_#yi62&Sp z-%9JJxwIfQ`@w(_qH0J0z~(lbh`P zHoyp2?Oppx^WXwD<~20v!lYm~n53G1w*Ej z9^B*j@lrd>XGW43ff)F;5k|HnGGRu=wmZG9c~#%vDWQHlOIA9(;&TBr#yza{(?k0> zcGF&nOI}JhuPl`kLViBEd)~p2nY9QLdX42u9C~EUWsl-@CE;05y@^V1^wM$ z&zemD1oZd$Z))kEw9)_Mf+X#nT?}n({(+aXHK2S@j$MDsdrw-iLb?#r{?Vud?I5+I zVQ8U?LXsQ}8-)JBGaoawyOsTTK_f8~gFFJ&lhDLs8@Rw$ey-wr&eqSEU^~1jtHmz6 z!D2g4Yh?3VE*W8=*r&G`?u?M~AdO;uTRPfE(@=Gkg z7gh=EGu!6VJJ?S_>|5ZwY?dGFBp3B9m4J1=7u=HcGjsCW+y6`W?OWxfH?S#X8&Zk& zvz6tWcnaS1@~3FTH}q_*$)AjYA_j;yl0H0{I(CW7Rq|;5Q2>Ngd(tmJDp+~qHe_8y zPU_fiCrn!SJ3x&>o6;WDnjUVEt`2fhc9+uLI>99(l$(>Tzwpbh>O775OA5i`jaBdp zXnCwUgomyF3K$0tXzgQhSAc!6nhyRh_$fP}Rd$|*Y7?ah(JrN=I7+)+Hp4BLJJ2P~ zFD!)H^uR2*m7GQZpLUVS#R3^?2wCd}(gcFcz!u5KN9ldNJdh@%onf06z9m~T0n;dqg6@?>G@S|rPO*Kj>{su+R|7bH>osA&uD4eqxtr**k($ii`uO? z7-&VkiL4Rp3S&e+T}2Z#;NtWHZco(v8O3QMvN0g7l8GV|U2>x-DbamkZo5)bjaSFR zr~Y9(EvF9{o*@|nBPj+e5o$_K`%TH1hD=|its}|qS^o6EQu_gOuDUH=Dtzik;P7G$ zq%_T<>9O}bGIB?;IQ*H`BJ5NWF6+XLv@G7aZwcy(&BoepG~u`aIcG>y+;J7+L=wTZ zB=%n@O}=+mjBO%1lMo6C0@1*+mhBqqY((%QMUBhyeC~r*5WVqzisOXFncr*5Lr0q6 zyPU&NOV}Vt2jl>&yig4I6j93?D>Ft=keRh=Y;3*^Z-I26nkZ#Jj5OJ89_?@#9lNjp z#gfAO6i937)~I|98P%xAWxwmk(F&@lTMx63*FZ~2b{NHU+}EV8+kMAB0bM*Zn#&7ubt98!PT^ZcMOfwMgkYz6+;?CKbvV zQ}Z@s_3JcMPhF&y1?}9uZFIBiPR3g7lf=+XEr9Bl%zRfGcaKb*ZQq5b35ZkR@=JEw zP#iqgh2^#@VA-h)>r`7R-$1_ddGr&oWWV$rx;pkG0Yohp9p@In_p)hKvMo@qIv zcN2t{23&^Nj=Y&gX;*vJ;kjM zHE2`jtjVRRn;=WqVAY&m$z=IoKa{>DgJ;To@OPqNbh=#jiS$WE+O4TZIOv?niWs47 zQfRBG&WGmU~>2O{}h17wXGEnigSIhCkg%N~|e?hG8a- zG!Wv&NMu5z!*80>;c^G9h3n#e>SBt5JpCm0o-03o2u=@v^n+#6Q^r#96J5Q=Dd=>s z(n0{v%yj)=j_Je2`DoyT#yykulwTB+@ejCB{dA7VUnG>4`oE?GFV4sx$5;%9&}yxfz<-wWk|IlA|g&! zN_Emw#w*2GT=f95(%Y1#Viop;Yro3SqUrW~2`Fl?Ten{jAt==a>hx$0$zXN`^7>V_ zG*o7iqeZV)txtHUU2#SDTyU#@paP;_yxp!SAG##cB= zr@LoQg4f~Uy5QM++W`WlbNrDa*U;54`3$T;^YVNSHX4?%z|`B~i7W+kl0wBB`8|(l zAyI6dXL&-Sei0=f#P^m`z=JJ`=W;PPX18HF;5AaB%Zlze`#pz;t#7Bzq0;k8IyvdK=R zBW+4GhjOv+oNq^~#!5(+pDz)Ku{u60bVjyym8Or8L;iqR|qTcxEKTRm^Y%QjFYU=ab+^a|!{!hYc+= z%Qc02=prKpzD+jiiOwzyb(dELO|-iyWzizeLugO!<1(j|3cbR!8Ty1$C|l@cWoi?v zLe<5+(Z-eH++=fX**O-I8^ceYZgiA!!dH+7zfoP-Q+@$>;ab&~cLFg!uOUX7h0r== z`@*QP9tnV1cu1!9pHc43C!{3?-GUBJEzI(&#~vY9MEUcRNR*61)mo!RG>_Yb^rNN7 zR9^bI45V?3Lq`^^BMD!GONuO4NH#v9OP3@s%6*Ha3#S*;f z6JEi)qW#Iq#5BtIXT9Gby|H?NJG}DN#Li82kZ_Rt1=T0Z@U6OAdyf}4OD|Sk^2%-1 zzgvqZ@b6~kL!^sZLO$r{s!3fQ5bHW}8r$uTVS*iw1u8^9{YlPp_^Xm5IN zF|@)ZOReX zB*#tEbWEX~@f)ST|s$oUKS@drycE1tYtdJ9b*(uFTxNZ{n3BI*kF7wXgT6+@PI@vwH7iQS{1T!Nauk>fm8gOLe`->Pi~ z8)3=UL_$OLl2n7QZlHt846nkYFu4V};3LpYA%5VaF#a2#d2g0&ZO~3WA%1XlerVpg zCAlM;(9OqH@`(>Tha{*@R%twB!}1ng4V=^+R`Q{#fkRk)C|suozf-uCXrkIH2SC^C z6wlxR`yS;-U#uu#`OnD%U<41%C4mp>LYLPIbgVO~WsT1if)Y)T*8nUB`2*(B;U_ha1NWv2`GqrZ z3MWWpT3tZ!*N@d*!j3=@K4>X*gX4A^@QPAz24?7u90AXaLiFq=Z$|5p$Ok2|YCX_Z zFgNPiY2r_Bg2BQE!0z=_N*G?%0cNITmAru*!Mws=F+F&Qw!&1?DBN{vSy%IvGRV@1 zS->PARgL^XS!-aZj zi@`~LhWfD!H-L0kNv=Jil9zR0>jZLqu)cLq?$yXVyk%EteKcWbe^qh#spHJPa#?92 za(N(Kw0se^$7nQUQZBet;C_Dj5(2_?TdrXFYwmebq}YGQbN5Ex7M zGSCX~Ey;5AqAzEDNr%p^!cuG?&wIeY&Bm5guVg>8F=!nT%7QZTGR(uGM&IZuMw0V_ zhPiIFWm?H?aw*(v6#uVT@NEzi2h5I$cZ-n0~m$tmwdMTjG*of^Y%1 zW?Y%o*-_iMqEJhXo^!Qo?tGFUn1Mb|urN4_;a)9bila2}5rBS#hZ5wV+t1xbyF1TW zj+~cdjbcMgY$zTOq6;ODaxzNA@PZIXX(-=cT8DBd;9ihfqqtbDr9#gXGtK24BPxjZ z9+Xp>W1(s)->-}VX~BoQv$I|-CBdO`gULrvNL>;@*HvTdh@wyNf}~IB5mFnTitX2i z;>W>tlQyc2)T4Mq+f!(i3#KuK-I8Kj3Wm(UYx?KWWt8DEPR_Jdb9CE~Fjc7Rkh#gh zowNv()KRO@##-C+ig0l!^*ol!Bj%d32_N*~d!|&>{t!k3lc?6VrdlCCb1?qyoR42m zv;4KdwCgvMT*{?tJKa(T?cl|b;k4P>c&O@~g71K5@}ys$)?}WSxD;<5%4wEz7h=+q ztLumn6>leWdDk#*@{=v9p)MsvuJMyf_VEs;pJh?i3z7_W@Q|3p$a}P@MQ-NpMtDUBgH!h4Ia#L&POr4Qw0Tqdw^}gCmQAB z8Dgkzn?V!_@04(cx0~-pqJOpeP1_}@Ml3pCb45EJoghLows9ET13J8kt0;m$6-jO( z4F|p+JFD1NT%4bpn4?&)d+~<360$z5on`eS6{H`S>t`VS$>(D`#mC*XK6zULj1Da# zpV$gw$2Ui{07NiYJQQNK;rOepRxA>soNK~B2;>z;{Ovx`k}(dlOHHuNHfeR}7tmIp zcM}q4*Fq8vSNJYi@4-;}`@bC?nrUy`3jR%HXhs79qWI5;hyTpH5%n-NcKu&j(aGwT z1~{geeq?Jd>>HL+?2`0K8dB2pvTS=LO~tb~vx_<=iN8^rW!y@~lBTAaxHmvVQJSeJ z!cb9ffMdP1lgI=>QJN{XpM4{reRrdIt|v|0-8!p}M*Qw^uV1@Ho-YsNd0!a(os$F* zT0tGHA#0%u0j*%S>kL*73@~7|iP;;!JbWSTA@`#VHv_l_%Z7CgX@>dhg_ zgn0|U)SY~U-E5{QiT@(uPp#1jaz!(_3^Cbz2 z4ZgWWz=PdGCiGznk{^4TBfx_;ZjAHQ>dB4YI}zfEnTbf60lR%=@VWt0yc=fd38Ig* z)Q38#e9^+tA7K}IDG5Z~>JE?J+n%0_-|i2{E*$jb4h?|_^$HRHjVkiyX6@Y+)0C2a zA+eegpT1dUpqQFIwx;!ayQcWQBQTj1n5&h<%Lggt@&tE19Rm~Rijtqw6nmYip_xg0 zO_IYpU304embcWP+**H|Z5~%R*mqq+y{KbTVqugkb)JFSgjVljsR{-c>u+{?moCCl zTL)?85;LXk0HIDC3v*|bB-r_z%zvL6Dp__L*A~Z*o?$rm>cYux&)W=6#+Cb}TF&Kd zdCgz3(ZrNA>-V>$C{a^Y^2F!l_%3lFe$s(IOfLBLEJ4Mcd!y&Ah9r)7q?oc z5L(+S8{AhZ)@3bw0*8(}Xw{94Vmz6FrK&VFrJN;xB96QmqYEibFz|yHgUluA-=+yS}I-+#_Pk zN67-#8W(R^e7f!;i0tXbJgMmJZH%yEwn*-}5ew13D<_FYWnt?{Mv1+MI~u;FN~?~m z{hUnlD1|RkN}c1HQ6l@^WYbHAXPJ^m0te1woe;LDJ}XEJqh1tPf=sD0%b+OuR1aCoP>I>GBn4C24Zu$D)qg=gq;D??5 zUSj%;-Hvk_ffj-+SI{ZCp`gZcNu=L@_N}kCcs?TyMr-37fhy$?a<7lt1`fZw<%$8@B6(Wgo!#!z9z{ab|x`+&;kP!(gfdY}A-GP&4Cbh-S< z1(kmgnMyB2z3ipEj5;4<{(=&<7a>A_Jl`ujUKYV@%k(oD=cD7W@8~5O=R*zdjM_y; zXwme~0wo0aDa~9rDnjF=B}Bbj|DHRQjN|?@(F^=bVFdr!#mwr|c0843k>%~5J|7|v zSY=T)iPU6rEAwrM(xTZwPio%D4y9Z4kL0bMLKvu4yd)0ZJA3<;>a2q~rEfcREn}~1 zCJ~3c?Afvx?3^@+!lnf(kB6YwfsJ*u^y7kZA?VmM%nBmaMspWu?WXq4)jQsq`9EbT zlF2zJ)wXuAF*2u|yd5hNrG>~|i}R&ZyeetTQ!?Hz6xGZZb3W6|vR>Hq=}*m=V=Lsp zUOMxh;ZfP4za~C{Ppn^%rhitvpnu^G{Z#o-r?TdEgSbtK_+~_iD49xM;$}X*mJF02|WBL{SDqK9}p4N!G$3m=x#@T+4QcapM{4j|Q zwO!(hldpuSW#by!zHEP@tzIC|KdD z%BJzQ7Ho1(HemWm`Z8m_D#*`PZ-(R%sZmPrS$aHS#WPjH3EDitxN|DY+ zYC|3S?PQ3NNYau$Qk8f>{w}~xCX;;CE=7;Kp4^xXR8#&^L+y-jep7oO^wnQ840tg1 zuN17QKsfdqZPlB8OzwF+)q#IsmenEmIbRAJHJ$JjxzawKpk8^sBm3iy=*kB%LppNb zhSdk`^n?01FKQ;=iU+McN7Mk0^`KE>mMe1CQ2a_R26_}^$bogFm=2vqJake7x)KN( zYz;gRPL+r4*KD>1U+DU+1jh{mT8#P#(z9^(aDljpeN{mRmx{AZX&hXKXNuxj3x*RrpjvOaZ#`1EqK!$+8=0yv8}=;>f=E?5tGbRUd4%?QL zy$kq6mZeF%k6E1&8nwAYMd!-lRkhQTob$7s`*XqcHs;l~mHV}fx&0I&i!CHaPVSM{ zHdRh7a>hP)t@YTrWm9y zl-ENWSVzlKVvTdWK>)enmGCEw(WYS=FtY{srdE{Z(3~4svwd)ct;`6Y{^qiW+9E@A ztzd?lj5F#k`=E1U-n*1JJc0{x{0q!_tkD<_S6bGsW)^RxGu%Rj^Mvw|R0WP1SqvAI zs(MiAd@Y5x!UKu376&|quQNxir;{Iz(+}3k-GNb29HaQh?K30u=6sXpIc?j0hF{VY zM$Do*>pN)eRljAOgpx7fMfSrnZ7>fi@@>Jh;qxj1#-Vj}JC3E^GCbC(r55_AG>6cq z4ru34FtVuBt)bkX4>ZFWjToyu)VA>IE6hXc+^(3ruUaKRqHnx3z)(GXetm;^0D95s zQ&drwfjhM4*|q=;i5Io0eDf?I{p}qo@7i7abHX5qLu~VDwYf4bmV~-^M_U?DL(+cG z{AyE^a|*73Ft)o5k-p)+GLXj#q01VlJ9#ZJkf|+c%6qfRgVp&6NsU3~F?!uh}HJm73xq>v$h zYoW3wJE6n9P|;{8U<^%UE2wjR4x^G_Nc$J(i)!>;g4`CCh2z^Dth#ah#<`#axDR?F z4>~hnN2%B2ZUuU6j>m1Qjj~5jQSdA&Q#7hOky#=Ue)}7LPJ!8nbZO_0Sw{G>>M7&E zb1dy|0Zi$(ubk`4^XkVI%4WIpe?Bh!D~IjvZs14yHw=aQ8-`N-=P*?Kzi&eRGZ_6Z zT>eis`!Dy3eT3=vt#Lbc+;}i5XJf7zM3QneL{t?w=U<1rk7+z2Cu^|~=~54tAeSYF zsXHsU;nM0dpK>+71yo(NFLV-^Lf7%U?Q$*q{^j04Gl71ya2)^j`nmJ$cmI9eFMjp+ z#)jKmi4lZc<;l>!={@jTm%?!5jS;6;c*Ml55~r6Y?22B^K3bPhKQ(ICc&z%w<4W1= zjTTtz_}IA$%kCqU)h#$!Yq>>2mVG}qYL}!avmCWYV}x4!YEeq)pgTp| zR;+skHuc7YXRLrcbYXt>?@pa{l^2pL>RrZ!22zMmi1ZR?nkaWF*`@XFK4jGh&Em3vn(l z3~^Q9&tM^eV=f^lccCUc9v02z%^n5VV6s$~k0uq5B#Ipd6`M1Kptg^v<2jiNdlAWQ z_MmtNEaeYIHaiuaFQdG&df7miiB5lZkSbg&kxY*Eh|KTW`Tk~VwKC~+-GoYE+pvwc{+nIEizq6!xP>7ZQ(S2%48l$Y98L zvs7s<&0ArXqOb*GdLH0>Yq-f!{I~e~Z@FUIPm?jzqFZvz9VeZLYNGO}>Vh<=!Er7W zS!X6RF^et7)IM1pq57z*^hP5w7HKSDd8jHX!*gkKrGc-GssrNu5H%7-cNE{h$!aEQK3g*qy;= z)}pxO8;}nLVYm_24@iEs8)R7i;Th0n4->&$8m6(LKCRd(yn7KY%QHu_f=*#e`H^U( z{u!`9JaRD?Z?23fEXrjx>A@+a!y-_oaDB)o@2s{2%A97-ctFfrN0cXQ@6aGH`X~Nr z144?qk;MzDU-cgQOLfT3-ZR#hKmYtKG*iGf4ZJ`|`9!^SkBDUUSJCba)>mM!)k~(z zdjUqB`)~!UObMHB1b$UItM$<0kwlqHH;c z=)+~bkOcIT7vI0Iy(wD)vsg9|oi##%Rgrq`Ek;pN)}lbpz`iv{F4K*{ZZ?Zjixxxr zY|SPl2NsXH+5pimj+MvbZ_+HrfvdC13|9Zs)Y=nW$z<0mhl}%irBSm5T3ZrN#2AhY z_ZrTmS(L`U#y}VZ@~QL9wUS6AnU*7LWS02Xyz`b>%rTml#Wb0yr>@c(Ym*40g;P{V zjV1XSHdU>oY!&Jh7MzhzUV8(9E+yl5UJYga>=0Ldjwtc`5!1>LxaB-kVW;IlSPs+0 zUBx=m8OKVp<`frNvMK>WMO(iKY%PuvqD+PK*vP6f?_o!O)MCW5Ic zv(%f5PLHyOJ2h@Yn_to@54Yq;fdoy40&sbe3A$4uUXHsHP_~K}h#)p&TyOx(~JE?y(IBAQKl}~VQjVC-c6oZwmESL;`Xth?2)-b6ImNcJi z;w|`Q*k?`L(+Dp}t(FocvzWB(%~9$EAB6_J6CrA}hMj-Vy*6iA$FdV}!lvk%6}M)4 zTf<)EbXr9^hveAav1yA?>O0aNEpv0&rju{(Gt|dP=AP%)uQm~OE7@+wEhILrRLt&E zoEsF^nz>4yK1|EOU*kM+9317S;+bb7?TJM2UUpc!%sDp}7!<`i=W!ot8*C&fpj>mk#qt~GCeqcy)?W6sl>eUnR%yCBR&Ow-rc|q;lhnI+f-%`6Xf)% zIYZru;27%vA{Qi2=J`PQC<28;tFx(V^sgXf>)8WNxxQwT14M9I6- z+V0@tiCiDkv`7r-06sJS8@s|Lf>mV+8h}SPT4ZGPSMaFK7_SMXH$3KN7b2V?iV-jA zh1!Z>2tv^HVbHnNUAf-wQW#zMV(h8=3x2Swd|-%AczEIWLcm~EAu7rc3s%56b;7ME zj}$pe#fc^314Mb9i)xH^_#({)tTD4hsoz!7XcHUh9*G|}?k=D?9LBkTm2?fgaIG(%%$DL#}a-_990rQBU+M;jrf zCcvgM`+oyZmsUqc?lly9axZfO)02l$TMS#I+jHYY`Uk!gtDv|@GBQ||uaG^n*QR3Q z@tV?D;R;KmkxSDQh<2DkDC1?m?jTvf2i^T;+}aYhzL?ymNZmdns2e)}2V>tDCRw{= zTV3q3ZQDkdZQHi3?y{@8Y@1!SZQHi(y7|qSx$~Vl=iX<2`@y3eSYpsBV zI`Q-6;)B=p(ZbX55C*pu1C&yqS|@Pytis3$VDux0kxKK}2tO&GC;cH~759o?W2V)2 z)`;U(nCHBE!-maQz%z#zoRNpJR+GmJ!3N^@cA>0EGg?OtgM_h|j1X=!4N%!`g~%hdI3%yz&wq4rYChPIGnSg{H%i>96! z-(@qsCOfnz7ozXoUXzfzDmr>gg$5Z1DK$z#;wn9nnfJhy6T5-oi9fT^_CY%VrL?l} zGvnrMZP_P|XC$*}{V}b^|Hc38YaZQESOWqA1|tiXKtIxxiQ%Zthz?_wfx@<8I{XUW z+LH%eO9RxR_)8gia6-1>ZjZB2(=`?uuX|MkX082Dz*=ep%hMwK$TVTyr2*|gDy&QOWu zorR#*(SDS{S|DzOU$<-I#JTKxj#@0(__e&GRz4NuZZLUS8}$w+$QBgWMMaKge*2-) zrm62RUyB?YSUCWTiP_j-thgG>#(ZEN+~bMuqT~i3;Ri`l${s0OCvCM>sqtIX?Cy`8 zm)MRz-s^YOw>9`aR#J^tJz6$S-et%elmR2iuSqMd(gr6a#gA_+=N(I6%Cc+-mg$?_1>PlK zbgD2`hLZ?z4S~uhJf=rraLBL?H#c$cXyqt{u^?#2vX2sFb z^EU-9jmp{IZ~^ii@+7ogf!n_QawvItcLiC}w^$~vgEi(mX79UwDdBg`IlF42E5lWE zbSibqoIx*0>WWMT{Z_NadHkSg8{YW4*mZ@6!>VP>ey}2PuGwo%>W7FwVv7R!OD32n zW6ArEJX8g_aIxkbBl^YeTy5mhl1kFGI#n>%3hI>b(^`1uh}2+>kKJh0NUC|1&(l)D zh3Barl&yHRG+Le2#~u>KoY-#GSF>v)>xsEp%zgpq4;V6upzm3>V&yk^AD}uIF{vIn zRN-^d4(Sk6ioqcK@EObsAi#Z-u&Hh#kZdv1rjm4u=$2QF<6$mgJ4BE0yefFI zT7HWn?f668n!;x>!CrbdA~lDfjX?)315k1fMR~lG)|X_o()w|NX&iYUTKxI2TLl|r z{&TWcBxP>*;|XSZ1GkL&lSg?XL9rR4Ub&4&03kf};+6$F)%2rsI%9W_i_P|P%Z^b@ zDHH2LV*jB@Izq0~E4F^j04+C|SFiV8{!bth%bz(KfCg42^ zGz5P7xor$)I4VX}Cf6|DqZ$-hG7(}91tg#AknfMLFozF1-R~KS3&5I0GNb`P1+hIB z?OPmW8md3RB6v#N{4S5jm@$WTT{Sg{rVEs*)vA^CQLx?XrMKM@*gcB3mk@j#l0(~2 z9I=(Xh8)bcR(@8=&9sl1C?1}w(z+FA2`Z^NXw1t(!rpYH3(gf7&m=mm3+-sls8vRq z#E(Os4ZNSDdxRo&`NiRpo)Ai|7^GziBL6s@;1DZqlN@P_rfv4Ce1={V2BI~@(;N`A zMqjHDayBZ);7{j>)-eo~ZwBHz0eMGRu`43F`@I0g!%s~ANs>Vum~RicKT1sUXnL=gOG zDR`d=#>s?m+Af1fiaxYxSx{c5@u%@gvoHf#s6g>u57#@#a2~fNvb%uTYPfBoT_$~a^w96(}#d;-wELAoaiZCbM zxY4fKlS6-l1!b1!yra|`LOQoJB))=CxUAYqFcTDThhA?d}6FD$gYlk**!# zD=!KW>>tg1EtmSejwz{usaTPgyQm~o+NDg`MvNo)*2eWX*qAQ)4_I?Pl__?+UL>zU zvoT(dQ)pe9z1y}qa^fi-NawtuXXM>*o6Al~8~$6e>l*vX)3pB_2NFKR#2f&zqbDp7 z5aGX%gMYRH3R1Q3LS91k6-#2tzadzwbwGd{Z~z+fBD5iJ6bz4o1Rj#7cBL|x8k%jO z{cW0%iYUcCODdCIB(++gAsK(^OkY5tbWY;)>IeTp{{d~Y#hpaDa-5r#&Ha?+G{tn~ zb(#A1=WG1~q1*ReXb4CcR7gFcFK*I6Lr8bXLt9>9IybMR&%ZK15Pg4p_(v5Sya_70 ziuUYG@EBKKbKYLWbDZ)|jXpJJZ&bB|>%8bcJ7>l2>hXuf-h5Bm+ zHZ55e9(Sg>G@8a`P@3e2(YWbpKayoLQ}ar?bOh2hs89=v+ifONL~;q(d^X$7qfw=; zENCt`J*+G;dV_85dL3Tm5qz2K4m$dvUXh>H*6A@*)DSZ2og!!0GMoCPTbcd!h z@fRl3f;{F%##~e|?vw6>4VLOJXrgF2O{)k7={TiDIE=(Dq*Qy@oTM*zDr{&ElSiYM zp<=R4r36J69aTWU+R9Hfd$H5gWmJ?V){KU3!FGyE(^@i!wFjeZHzi@5dLM387u=ld zDuI1Y9aR$wW>s#I{2!yLDaVkbP0&*0Rw%6bi(LtieJQ4(1V!z!ec zxPd)Ro0iU%RP#L|_l?KE=8&DRHK>jyVOYvhGeH+Dg_E%lgA(HtS6e$v%D7I;JSA2x zJyAuin-tvpN9g7>R_VAk2y;z??3BAp?u`h-AVDA;hP#m+Ie`7qbROGh%_UTW#R8yfGp<`u zT0}L)#f%(XEE)^iXVkO8^cvjflS zqgCxM310)JQde*o>fUl#>ZVeKsgO|j#uKGi)nF_ur&_f+8#C0&TfHnfsLOL|l(2qn zzdv^wdTi|o>$q(G;+tkTKrC4rE)BY?U`NHrct*gVx&Fq2&`!3htkZEOfODxftr4Te zoseFuag=IL1Nmq45nu|G#!^@0vYG5IueVyabw#q#aMxI9byjs99WGL*y)AKSaV(zx z_`(}GNM*1y<}4H9wYYSFJyg9J)H?v((!TfFaWx(sU*fU823wPgN}sS|an>&UvI;9B(IW(V)zPBm!iHD} z#^w74Lpmu7Q-GzlVS%*T-z*?q9;ZE1rs0ART4jnba~>D}G#opcQ=0H)af6HcoRn+b z<2rB{evcd1C9+1D2J<8wZ*NxIgjZtv5GLmCgt?t)h#_#ke{c+R6mv6))J@*}Y25ef z&~LoA&qL-#o=tcfhjH{wqDJ;~-TG^?2bCf~s0k4Rr!xwz%Aef_LeAklxE=Yzv|3jf zgD0G~)e9wr@)BCjlY84wz?$NS8KC9I$wf(T&+79JjF#n?BTI)Oub%4wiOcqw+R`R_q<`dcuoF z%~hKeL&tDFFYqCY)LkC&5y(k7TTrD>35rIAx}tH4k!g9bwYVJ>Vdir4F$T*wC@$08 z9Vo*Q0>*RcvK##h>MGUhA9xix+?c1wc6xJhn)^9;@BE6i*Rl8VQdstnLOP1mq$2;!bfASHmiW7|=fA{k$rs^-8n{D6_ z!O0=_K}HvcZJLSOC6z-L^pl3Gg>8-rU#Sp1VHMqgXPE@9x&IHe;K3;!^SQLDP1Gk&szPtk| z!gP;D7|#y~yVQ?sOFiT*V(Z-}5w1H6Q_U5JM#iW16yZiFRP1Re z6d4#47#NzEm};1qRP9}1;S?AECZC5?6r)p;GIW%UGW3$tBN7WTlOy|7R1?%A<1!8Z zWcm5P6(|@=;*K&3_$9aiP>2C|H*~SEHl}qnF*32RcmCVYu#s!C?PGvhf1vgQ({MEQ z0-#j>--RMe{&5&$0wkE87$5Ic5_O3gm&0wuE-r3wCp?G1zA70H{;-u#8CM~=RwB~( zn~C`<6feUh$bdO1%&N3!qbu6nGRd5`MM1E_qrbKh-8UYp5Bn)+3H>W^BhAn;{BMii zQ6h=TvFrK)^wKK>Ii6gKj}shWFYof%+9iCj?ME4sR7F+EI)n8FL{{PKEFvB65==*@ ztYjjVTJCuAFf8I~yB-pN_PJtqH&j$`#<<`CruB zL=_u3WB~-;t3q)iNn0eU(mFTih<4nOAb>1#WtBpLi(I)^zeYIHtkMGXCMx+I zxn4BT0V=+JPzPeY=!gAL9H~Iu%!rH0-S@IcG%~=tB#6 z3?WE7GAfJ{>GE{?Cn3T!QE}GK9b*EdSJ02&x@t|}JrL{^wrM@w^&})o;&q816M5`} zv)GB;AU7`haa1_vGQ}a$!m-zkV(+M>q!vI0Swo18{;<>GYZw7-V-`G#FZ z;+`vsBihuCk1RFz1IPbPX8$W|nDk6yiU8Si40!zy{^nmv_P1=2H*j<^as01|W>BQS zU)H`NU*-*((5?rqp;kgu@+hDpJ;?p8CA1d65)bxtJikJal(bvzdGGk}O*hXz+<}J? zLcR+L2OeA7Hg4Ngrc@8htV!xzT1}8!;I6q4U&S$O9SdTrot<`XEF=(`1{T&NmQ>K7 zMhGtK9(g1p@`t)<)=eZjN8=Kn#0pC2gzXjXcadjHMc_pfV(@^3541)LC1fY~k2zn&2PdaW`RPEHoKW^(p_b=LxpW&kF?v&nzb z1`@60=JZj9zNXk(E6D5D}(@k4Oi@$e2^M%grhlEuRwVGjDDay$Qpj z`_X-Y_!4e-Y*GVgF==F0ow5MlTTAsnKR;h#b0TF>AyJe`6r|%==oiwd6xDy5ky6qQ z)}Rd0f)8xoNo)1jj59p;ChIv4Eo7z*{m2yXq6)lJrnziw9jn%Ez|A-2Xg4@1)ET2u zIX8`u5M4m=+-6?`S;?VDFJkEMf+=q?0D7?rRv)mH=gptBFJGuQo21rlIyP>%ymGWk z=PsJ>>q~i>EN~{zO0TklBIe(8i>xkd=+U@;C{SdQ`E03*KXmWm4v#DEJi_-F+3lrR z;0al0yXA&axWr)U%1VZ@(83WozZbaogIoGYpl!5vz@Tz5?u36m;N=*f0UY$ssXR!q zWj~U)qW9Q9Fg9UW?|XPnelikeqa9R^Gk77PgEyEqW$1j=P@L z*ndO!fwPeq_7J_H1Sx>#L$EO_;MfYj{lKuD8ZrUtgQLUUEhvaXA$)-<61v`C=qUhI zioV&KR#l50fn!-2VT`aMv|LycLOFPT{rRSRGTBMc)A`Cl%K&4KIgMf}G%Qpb2@cB* zw8obt-BI3q8Lab!O<#zeaz{P-lI2l`2@qrjD+Qy)^VKks5&SeT(I)i?&Kf59{F`Rw zuh7Q>SQNwqLO%cu2lzcJ7eR*3!g}U)9=EQ}js-q{d%h!wl6X3%H0Z2^8f&^H;yqti4z6TNWc& zDUU8YV(ZHA*34HHaj#C43PFZq7a>=PMmj4+?C4&l=Y-W1D#1VYvJ1~K%$&g-o*-heAgLXXIGRhU zufonwl1R<@Kc8dPKkb`i5P9VFT_NOiRA=#tM0WX2Zut)_ zLjAlJS1&nnrL8x8!o$G+*z|kmgv4DMjvfnvH)7s$X=-nQC3(eU!ioQwIkaXrl+58 z@v)uj$7>i`^#+Xu%21!F#AuX|6lD-uelN9ggShOX&ZIN+G#y5T0q+RL*(T(EP)(nP744-ML= z+Rs3|2`L4I;b=WHwvKX_AD56GU+z92_Q9D*P|HjPYa$yW0o|NO{>4B1Uvq!T;g_N- zAbNf%J0QBo1cL@iahigvWJ9~A4-glDJEK?>9*+GI6)I~UIWi>7ybj#%Po}yT6d6Li z^AGh(W{NJwz#a~Qs!IvGKjqYir%cY1+8(5lFgGvl(nhFHc7H2^A(P}yeOa_;%+bh` zcql{#E$kdu?yhRNS$iE@F8!9E5NISAlyeuOhRD)&xMf0gz^J927u5aK|P- z>B%*9vSHy?L_q)OD>4+P;^tz4T>d(rqGI7Qp@@@EQ-v9w-;n;7N05{)V4c7}&Y^!`kH3}Q z4RtMV6gAARY~y$hG7uSbU|4hRMn97Dv0$Le@1jDIq&DKy{D$FOjqw{NruxivljBGw zP4iM(4Nrz^^~;{QBD7TVrb6PB=B$<-e9!0QeE8lcZLdDeb?Gv$ePllO2jgy&FSbW* zSDjDUV^=`S(Oo0;k(Idvzh}aXkfO)F6AqB?wWqYJw-1wOn5!{-ghaHb^v|B^92LmQ9QZj zHA&X)fd%B$^+TQaM@FPXM$$DdW|Vl)4bM-#?Slb^qUX1`$Yh6Lhc4>9J$I4ba->f3 z9CeGO>T!W3w(){M{OJ+?9!MK68KovK#k9TSX#R?++W4A+N>W8nnk**6AB)e;rev=$ zN_+(?(YEX;vsZ{EkEGw%J#iJYgR8A}p+iW;c@V>Z1&K->wI>!x-+!0*pn|{f=XA7J zfjw88LeeJgs4YI?&dHkBL|PRX`ULOIZlnniTUgo-k`2O2RXx4FC76;K^|ZC6WOAEw zz~V0bZ29xe=!#Xk?*b{sjw+^8l0Koy+e7HjWXgmPa4sITz+$VP!YlJ$eyfi3^6gGx6jZLpbUzX;!Z6K}aoc!1CRi zB6Lhwt%-GMcUW;Yiy6Y7hX(2oksbsi;Z6k*=;y;1!taBcCNBXkhuVPTi+1N*z*}bf z`R=&hH*Ck5oWz>FR~>MO$3dbDSJ!y|wrff-H$y(5KadrA_PR|rR>jS=*9&J*ykWLr z-1Z^QOxE=!6I z%Bozo)mW7#2Hd$-`hzg=F@6*cNz^$#BbGlIf${ZV1ADc}sNl=B72g`41|F7JtZ^BT z+y}nqn3Ug`2scS_{MjykPW2~*k$i6PhvvxJCW;n!SK5B8Rpm41fCEdy=ea-4F`rN5 zF>ClKp#4?}pI7eR#6U|}t`DA!GQJB7nT$HVV*{qPjIRU1Ou3W;I^pCt54o|ZHvWaH zooFx9L%#yv)!P;^er5LCU$5@qXMhJ-*T5Ah8|}byGNU5oMp3V)yR;hWJKojJEregX z<1UPt%&~=5OuP(|B{ty);vLdoe7o^?`tkQa7zoXKAW6D@lc+FTzucotaOfJ!(Bm zHE8f8j@6||lH`y2<&hP}Q1wr(=6ze0D6NRL{7QaE1=nTAzqjIeD}Be&@#_d*dyurz z&L7xo-D9!dS`i>^GaIPArR@r=N#-ppIh!UBcb!N*?nLUO+*%C>_dCF1IH)q>5oT(t zjQo{AoDB;mWL;3&;vTt?;bvJSj>^Gq4Jrh}S}D>G)+b!>oRDWI?c_d77$kF5ms{Gx zak*>~*5AvaB-Xl)IgdZ^Cupv6HxQ0 zM(KPaDpPsPOd)e)aFw}|=tfzg@J1P8oJx2ZBY=g4>_G(Hkgld(u&~jN((eJ}5@b1} zI(P7j443AZj*I@%q!$JQ2?DZV47U!|Tt6_;tlb`mSP3 z74DE4#|1FMDqwYbT4P6#wSI%s?*wDc>)MR$4z9ZtJg04+CTUds>1JSDwI}=vpRoRR zLqx(Tvf34CvkTMOPkoH~$CG~fSZb;(2S4Q6Vpe9G83V={hwQ>acu+MCX)@0i>Vd`% z4I8Ye+7&Kcbh(*bN1etKmrpN)v|=eI+$oD=zzii6nP&w|kn2Y-f!(v<aE zKmOz#{6PZB(8zD={il`RO6D}v(@mN_66KXUAEefgg|;VmBfP?UrfB$&zaRw7oanna zkNmVGz4Vhd!vZSnp1(&_5^t;eSv6O771BloJAHi=Pnn+aa6y(e2iiE97uZ{evzQ^8 z*lN@ZYx<-hLXP^IuYLGf<01O*>nDp0fo;;Iyt`JADrxt7-jEF(vv_btyp6CT8=@5t zm`I0lW+2+_xj2CRL|40kcYysuyYeiGihGe&a)yilqP}5h+^)m8$=mzrUe`$(?BIY> zfF7-V10Gu0CkWF)wz04&hhI>es0NS7d`cnT`4y8K!wUAKv$H09fa>KeNQvwUNDT1zn}_*RHykC$CD%*h7vRCQ&Z z4&N-!L>(@8i?K$l5)13n0%VPPV`iG7Q$2{1T3JypLSvN%1kX73goBIOEmg=Uf$9e? zm}g>JFu}EQKH>|K!)m9teoCmTc`y2Ll}msZYyy0Pkqjeid66>DP_?C{KCw94lHvLW z-+X!2YSm70s833lH0o+|A%Xwsw`@8lE3ia0n_Dve;LC7@I+i~@%$lD|3fNf&R6ob6 z@iGfx^OC4s`$|vO!0jTWwVpX;X^EqJF{i324I>N=f@u+rTN+xJGGR0LsCQc;iFD=F zbZJrgOpS;04o^wP7HF5QBaJ$KJgS2V4u02ViWD=6+7rcu`uc&MOoyf%ZBU|gQZkUg z<}ax>*Fo?d*77Ia)+{(`X45{a8>Bi$u-0BWSteyp#GJnTs?&k&<0NeHA$Qb3;SAJK zl}H*~eyD-0qHI3SEcn`_7d zq@YRsFdBig+k490BZSQwW)j}~GvM7x>2ymO4zakaHZ!q6C2{fz^NvvD8+e%7?BQBH z-}%B{oROo2+|6g%#+XmyyIJrK_(uEbg%MHlBn3^!&hWi+9c0iqM69enep#5FvV_^r z?Yr(k*5FbG{==#CGI1zU0Wk{V?UGhBBfv9HP9A-AmcJmL^f4S zY3E2$WQa&n#WRQ5DOqty_Pu z-NWQGCR^Hnu^Vo2rm`-M>zzf|uMCUd1X0{wISJL2Pp=AO5 zF@(50!g|SYw3n<_VP0T~`WUjtY**6Npphr5bD%i3#*p7h8$#;XTLJAt5J-x~O1~`z z`2C~P4%XSI(JbrEmVMEwqdsa^aqXWg;A6KBn^jDxTl!}Q!^WhprL$kb(Iqq zUS`i$tIPs#hdE-zAaMGoxcG?Z;RO2L0Y|gcjV_)FFo|e)MtTl`msLTwq>po$`H6_U zhdWK97~M>idl9GE_WgobQkK_P85H_0jN?s3O)+m&68B`_;FnbZ3W*Qm++ghSs7|T4b7m~VVV%j0gl`Iw!?+-9#Lsb!j3O%fSTVuK z37V>qM81D+Atl};23`TqEAfEkQDpz$-1$e__>X2jN>xh@Sq)I6sj@< ziJ^66GSmW9c%F7eu6&_t$UaLXF4KweZecS1ZiHPWy-$e_7`jVk74OS*!z=l#(CQ^K zW-ke|g^&0o=hn+4uh-8lUh0>!VIXXnQXwKr>`94+2~<;+`k z$|}QZ>#pm2g}8k*;)`@EnM~ZQtci%_$ink9t6`HP{gn}P1==;WDAld3JX?k%^GcTU za>m|CH|UsyFhyJBwG5=`6562hkVRMQ=_ron-Vlm$4bG^GFz|Jh5mM{J1`!!hAr~8F^w> z^YhQ=c|bFn_6~9X$v(30v$5IX;#Nl-XXRPgs{g_~RS*znH^6Vhe}8>T?aMA|qfnWO zQpf(wr^PfygfM+m2u!9}F|frrZPBQ!dh(varsYo!tCV)WA(Wn^_t=WR_G7cQU`AGx zrK^B6<}9+$w;$vra)QWMKf_Tnqg93AMVZ6Qd=q6rdB{;ZhsoT zWy9QhnpEnc@Dauz4!8gq zqDanAX#$^vf-4~ZqUJtSe?SO+Hmb?)l2#}v(8}2+P{ZZuhlib0$3G0|a5?JR>QgUUP$HTE5hb`h>imq#7P+Y*-UVLm@9km|V# zoigziFt$bxgQMwqKKhd!c--&ciywIED>faY3zHLrA{V#IA)!mq!FXxf?1coGK~N(b zjwu*@2B1^(bzFVBJO`4EJ$=it!a0kbgUvPL;Er(0io{W4G7Bkqh)=g)uS|l0YfD}f zaCJwY7vR-D=P9M68`cmtmQ^!F-$lt@0S|9G7cHgT13A0xMv)HmH#Z<4{~iYo_VOD{ z5!kU+>mUOvHouw+-y?*cNlUlDwD#;6ZvAIc$YcwG&qKZFh>EtM(Eda+w)E$HcfZyB zG*$<*ae_ApE%gxWx%O^~XMnRSNLv!y`g99F(J_m)spJAc95P|_joOIoru%atbw z9PYgkcE*8x#)-W{>96KDl&74iW<#wrK)1s zxzU{`rW5af+dT6Z@_1dG<}CtDMT`EGVEXSL_5D9)Z;6UJe-TW7)M?bY%E;8G?Yc!$ zic;F5=#dba^P~7f#qvC}Nd#XEo2r_UlgfR_`B2^W0QjXU?RAi$>f&{G_Lu8Fp0qDp z?vAdm%z#3kcZmaJ@afooB=A@>8_N~O9Yzu=ZCEikM>UgU+{%>pPvmSNzGk@*jnc5~ z(Z#H4OL^gw>)gqZ!9X|3i4LAdp9vo)?F9QCR3##{BHoZ73Uk^Ha={2rc*TBijfKH- z=$cZQdc<5%*$kVo|{+bL3 zEoU&tq*YPR)^y-SISeQNQ)YZ9v>Hm4O=J)lf(y=Yu1ao&zj#5GVGxyj%V%vl9}dw< zO;@NRd4qe@Et}E@Q;SChBR2QPKll1{*5*jT*<$$5TywvC77vt=1=0xZ46>_17YzbiBoDffH(1_qFP7v2SVhZmA_7JDB50t#C39 z8V<9(E?bVWI<7d6MzcS^w!XmZ**{AO!~DZNU)pgr=yY1 zT@!AapE;yg&hmj*g{I3vd## zx+d%^O?d%%?Dba|l~X6ZOW|>FPsrjPjn-h4swysH!RNJUWofC?K(^0uHrBPrH5#W> zMn8^@USzjUucqo%+5&))Dnnw`5l1mp>roaA99Nkk4keZl2wAF7oa(!x?@8uGWzc5Q zM}g`}zf-D@B6lVFYWmmJ8a+_%z8g$C7Ww~PD9&jki08NY!b!fK288R;E?e3Z+Pk{is%HxQU`xu9+y5 zq?DWJD7kKp(B2J$t5Ij8-)?g!T9_n<&0L8F5-D0dp>9!Qnl#E{eDtkNo#lw6rMJG$ z9Gz_Z&a_6ie?;F1Y^6I$Mg9_sml@-z6t!YLr=ml<6{^U~UIbZUUa_zy>fBtR3Rpig zc1kLSJj!rEJILzL^uE1mQ}hjMCkA|ZlWVC9T-#=~ip%McP%6QscEGlYLuUxDUC=aX zCK@}@!_@~@z;70I+Hp5#Tq4h#d4r!$Np1KhXkAGlY$ap7IZ9DY})&(xoTyle8^dBXbQUhPE6ehWHrfMh&0=d<)E2+pxvWo=@`^ zIk@;-$}a4zJmK;rnaC)^a1_a_ie7OE*|hYEq1<6EG>r}!XI9+(j>oe!fVBG%7d}?U z#ja?T@`XO(;q~fe2CfFm-g8FbVD;O7y9c;J)k0>#q7z-%oMy4l+ zW>V~Y?s`NoXkBeHlXg&u*8B7)B%alfYcCriYwFQWeZ6Qre!4timF`d$=YN~_fPM5Kc8P;B-WIDrg^-j=|{Szq6(TC)oa!V7y zLmMFN1&0lM`+TC$7}on;!51{d^&M`UW ztI$U4S&}_R?G;2sI)g4)uS-t}sbnRoXVwM!&vi3GfYsU?fSI5Hn2GCOJ5IpPZ%Y#+ z=l@;;{XiY_r#^RJSr?s1) z4b@ve?p5(@YTD-<%79-%w)Iv@!Nf+6F4F1`&t~S{b4!B3fl-!~58a~Uj~d4-xRt`k zsmGHs$D~Wr&+DWK$cy07NH@_z(Ku8gdSN989efXqpreBSw$I%17RdxoE<5C^N&9sk!s2b9*#}#v@O@Hgm z2|U7Gs*@hu1JO$H(Mk)%buh~*>paY&Z|_AKf-?cz6jlT-v6 zF>l9?C6EBRpV2&c1~{1$VeSA|G7T(VqyzZr&G>vm87oBq2S%H0D+RbZm}Z`t5Hf$C zFn7X*;R_D^ z#Ug0tYczRP$s!6w<27;5Mw0QT3uNO5xY($|*-DoR1cq8H9l}_^O(=g5jLnbU5*SLx zGpjfy(NPyjL`^Oln_$uI6(aEh(iS4G=$%0;n39C(iw79RlXG>W&8;R1h;oVaODw2nw^v{~`j(1K8$ z5pHKrj2wJhMfw0Sos}kyOS48Dw_~=ka$0ZPb!9=_FhfOx9NpMxd80!a-$dKOmOGDW zi$G74Sd(-u8c!%35lL|GkyxZdlYUCML{V-Ovq{g}SXea9t`pYM^ioot&1_(85oVZ6 zUhCw#HkfCg7mRT3|>99{swr3FlA@_$RnE?714^o;vps4j4}u=PfUAd zMmV3j;Rogci^f!ms$Z;gqiy7>soQwo7clLNJ4=JAyrz;=*Yhe8q7*$Du970BXW89Xyq92M4GSkNS-6uVN~Y4r7iG>{OyW=R?@DmRoi9GS^QtbP zFy2DB`|uZTv8|ow|Jcz6?C=10U$*_l2oWiacRwyoLafS!EO%Lv8N-*U8V+2<_~eEA zgPG-klSM19k%(%;3YM|>F||hE4>7GMA(GaOvZBrE{$t|Hvg(C2^PEsi4+)w#P4jE2XDi2SBm1?6NiSkOp-IT<|r}L9)4tLI_KJ*GKhv16IV}An+Jyx z=Mk`vCXkt-qg|ah5=GD;g5gZQugsv!#)$@ zkE=6=6W9u9VWiGjr|MgyF<&XcKX&S3oN{c{jt-*1HHaQgY({yjZiWW97rha^TxZy< z2%-5X;0EBP>(Y9|x*603*Pz-eMF5*#4M;F`QjTBH>rrO$r3iz5 z?_nHysyjnizhZQMXo1gz7b{p`yZ8Q78^ zFJ3&CzM9fzAqb6ac}@00d*zjW`)TBzL=s$M`X*0{z8$pkd2@#4CGyKEhzqQR!7*Lo@mhw`yNEE6~+nF3p;Qp;x#-C)N5qQD)z#rmZ#)g*~Nk z)#HPdF_V$0wlJ4f3HFy&fTB#7Iq|HwGdd#P3k=p3dcpfCfn$O)C7;y;;J4Za_;+DEH%|8nKwnWcD zBgHX)JrDRqtn(hC+?fV5QVpv1^3=t2!q~AVwMBXohuW@6p`!h>>C58%sth4+Baw|u zh&>N1`t(FHKv(P+@nT$Mvcl){&d%Y5dx|&jkUxjpUO3ii1*^l$zCE*>59`AvAja%`Bfry-`?(Oo?5wY|b4YM0lC?*o7_G$QC~QwKslQTWac z#;%`sWIt8-mVa1|2KH=u!^ukn-3xyQcm4@|+Ra&~nNBi0F81BZT$XgH@$2h2wk2W% znpo1OZuQ1N>bX52II+lsnQ`WVUxmZ?4fR_f0243_m`mbc3`?iy*HBJI)p2 z`GQ{`uS;@;e1COn-vgE2D!>EheLBCF-+ok-x5X8Cu>4H}98dH^O(VlqQwE>jlLcs> zNG`aSgDNHnH8zWw?h!tye^aN|%>@k;h`Z_H6*py3hHO^6PE1-GSbkhG%wg;+vVo&dc)3~9&` zPtZtJyCqCdrFUIEt%Gs_?J``ycD16pKm^bZn>4xq3i>9{b`Ri6yH|K>kfC; zI5l&P)4NHPR)*R0DUcyB4!|2cir(Y1&Bsn3X8v4D(#QW8Dtv@D)CCO zadQC85Zy=Rkrhm9&csynbm>B_nwMTFah9ETdNcLU@J{haekA|9*DA2pY&A|FS*L!*O+>@Q$00FeL+2lg2NWLITxH5 z0l;yj=vQWI@q~jVn~+5MG!mV@Y`gE958tV#UcO#56hn>b69 zM;lq+P@MW=cIvIXkQmKS$*7l|}AW%6zETA2b`qD*cL z(=k4-4=t6FzQo#uMXVwF{4HvE%%tGbiOlO)Q3Y6D<5W$ z9pm>%TBUI99MC`N9S$crpOCr4sWJHP)$Zg#NXa~j?WeVo03P3}_w%##A@F|Bjo-nNxJZX%lbcyQtG8sO zWKHes>38e-!hu1$6VvY+W-z?<942r=i&i<88UGWdQHuMQjWC-rs$7xE<_-PNgC z_aIqBfG^4puRkogKc%I-rLIVF=M8jCh?C4!M|Q=_kO&3gwwjv$ay{FUDs?k7xr%jD zHreor1+#e1_;6|2wGPtz$``x}nzWQFj8V&Wm8Tu#oaqM<$BLh+Xis=Tt+bzEpC}w) z_c&qJ6u&eWHDb<>p;%F_>|`0p6kXYpw0B_3sIT@!=fWHH`M{FYdkF}*CxT|`v%pvx z#F#^4tdS0|O9M1#db%MF(5Opy;i( zL(Pc2aM4*f_Bme@o{xMrsO=)&>YKQw+)P-`FwEHR4vjU>#9~X7ElQ#sRMjR^Cd)wl zg^67Bgn9CK=WP%Ar>T4J!}DcLDe z=ehSmTp##KyQ78cmArL=IjOD6+n@jHCbOatm)#4l$t5YV?q-J86T&;>lEyK&9(XLh zr{kPuX+P8LN%rd%8&&Ia)iKX_%=j`Mr*)c)cO1`-B$XBvoT3yQCDKA>8F0KL$GpHL zPe?6dkE&T+VX=uJOjXyrq$BQ`a8H@wN1%0nw4qBI$2zBx)ID^6;Ux+? zu{?X$_1hoz9d^jkDJpT-N6+HDNo%^MQ2~yqsSBJj4@5;|1@w+BE04#@Jo4I63<~?O?ok%g%vQakTJKpMsk&oeVES1>cnaF7ZkFpqN6lx` zzD+YhR%wq2DP0fJCNC}CXK`g{AA6*}!O}%#0!Tdho4ooh&a5&{xtcFmjO4%Kj$f(1 zTk||{u|*?tAT{{<)?PmD_$JVA;dw;UF+x~|!q-EE*Oy?gFIlB*^``@ob2VL?rogtP z0M34@?2$;}n;^OAV2?o|zHg`+@Adk+&@Syd!rS zWvW$e5w{onua4sp+jHuJ&olMz#V53Z5y-FkcJDz>Wk%_J>COk5<0ya*aZLZl9LH}A zJhJ`Q-n9K+c8=0`FWE^x^xn4Fa7PDUc;v2+us(dSaoIUR4D#QQh91R!${|j{)=Zy1 zG;hqgdhSklM-VKL6HNC3&B(p1B)2Nshe7)F=-HBe=8o%OhK1MN*Gq6dBuPvqDRVJ{ z;zVNY?wSB%W0s^OMR_HL(Ws)va7eWGF*MWx<1wG7hZ}o=B62D?i|&0b14_7UG287YDr%?aYMMpeCkY1i`b+H!J9sqrvKc#Y6c8At@QiLSwj)@ifz~Z|c$lOMA@?cPqFRmZ%_>bz2X4(B=`^3;MDjsEeAO=? zSoD&+L>A|fGt7+6kF2@LqhL06sD%|~YsIe=EcWqy{e_61N_D(*CacnMvyXMjP87HI z4PT6!$fzxx{}=>jeqzkkoN+!r9e|@lZUN4pn(T28v`k=_vIhTn^i9O3qTqd)-%!QQ zYB6*6B@&b(!#X4C~59SLZuorNU_wWZA36{>O%iX)VS5NNZh49C_ppI>?)wwml}_0MLzOXT>lmo#&Ew6d?mu8~~I_^4VGBQtCAke;RQa5DL` z1PFDPsKb3CS$v;RhlQ1J@AHa1VRuuxp}NOIvrC>4$$A0Ix0VpAc0lfG%8{mR{TRQ( zbXM#1Tci3H*Wt>cVuMta^6^z`=^B@j+YhJqq9?>zZPxyg2U(wvod=uwJs{8gtpyab zXHQX<0FOGW6+dw&%c_qMUOI^+Rnb?&HB7Fee|33p4#8i>%_ev(aTm7N1f#6lV%28O zQ`tQh$VDjy8x(Lh#$rg1Kco$Bw%gULq+lc4$&HFGvLMO30QBSDvZ#*~hEHVZ`5=Kw z3y^9D512@P%d~s{x!lrHeL4!TzL`9(ITC97`Cwnn8PSdxPG@0_v{No|kfu3DbtF}K zuoP+88j4dP+Bn7hlGwU$BJy+LN6g&d3HJWMAd1P9xCXG-_P)raipYg5R{KQO$j;I9 z1y1cw#13K|&kfsRZ@qQC<>j=|OC?*v1|VrY$s=2!{}e33aQcZghqc@YsHKq^)kpkg z>B;CWNX+K=u|y#N)O>n5YuyvPl5cO6B^scmG?J zC8ix)E1PlhNaw8FpD+b|D$z`Id^4)rJe78MNiBga?Z- z0$L&MRTieSB1_E#KaN*H#Ns1}?zOA%Ybr{G+Sn3moXTVZj=L`nt?D&-MjOMz-Yq&@ z$P3h23d_F8Dcf*?txX7}p>nM*s+65t z1il8bHHsBynUK|aEXSjzY6sz1nZ%|%XeWTcGLRyRl@q4YAR)JovbdTTY&7u>@}28A zgV^Npp?}I!?3K7IXu9ml-Lw;w@9m zBYTeU+Seh8uJ-w?4e_6byq0f7>O3xm(hO}Y=fgU5^vW|>0yQ^0+?}LT55ei$i zzlU-iRbd8TRX9Ept%h%ariV=%u%F@@FA>U*XdAalcH%>#5_a&w)g`uW%3}m?vP- zc5}DkuF6ruKDwEYj+2YTSQ9=rkp19U5P@(zRm(nLod(sG9{~nw1BUoS2OFDXa{xfw zZ~UaZLFUZxfQ*9?_X?*~`d;nn-BbaefLJ`DT13KF6?T5Mnt;v5d>H}s)aAIzJcs#B z|CuXPJKww}hWBKsUfks#Kh$)ptp?5U1b@ttXFRbe_BZ&_R9XC6CA4WhWhMUE9Y2H4 z{w#CBCR<)Fd1M;mx*m?Z=L-^1kv1WKtqG(BjMiR4M^5yN4rlFM6oGUS2Wf~7Z@e*- ze84Vr`Bmi!(a1y}-m^HHMpbAiKPVEv|(7=|}D#Ihfk+-S5Hlkfch02z&$(zS3vrYz2g*ic{xBy~*gIp(eG}^gMc7 zPu2Eivnp@BH3SOgx!aJXttx*()!=2)%Bf$Gs^4cCs@)=(PJNxhH5lVY&qSZYaa?A^LhZW`B9(N?fx<^gCb(VE%3QpA*_Pohgp6vCB36iVaq zc1TI%L2Le?kuv?6Dq`H+W>AqnjyEzUBK948|DB|)U0_4DzWF#7L{agwo%y$hC>->r z4|_g_6ZC!n2=GF4RqVh6$$reQ(bG0K)i9(oC1t6kY)R@DNxicxGxejwL2sB<>l#w4 zE$QkyFI^(kZ#eE5srv*JDRIqRp2Totc8I%{jWhC$GrPWVc&gE1(8#?k!xDEQ)Tu~e zdU@aD8enALmN@%1FmWUz;4p}41)@c>Fg}1vv~q>xD}KC#sF|L&FU);^Ye|Q;1#^ps z)WmmdQI2;%?S%6i86-GD88>r|(nJackvJ#50vG6fm$1GWf*f6>oBiDKG0Kkwb17KPnS%7CKb zB7$V58cTd8x*NXg=uEX8Man_cDu;)4+P}BuCvYH6P|`x-#CMOp;%u$e z&BZNHgXz-KlbLp;j)si^~BI{!yNLWs5fK+!##G;yVWq|<>7TlosfaWN-;C@oag~V`3rZM_HN`kpF`u1p# ztNTl4`j*Lf>>3NIoiu{ZrM9&E5H~ozq-Qz@Lkbp-xdm>FbHQ2KCc8WD7kt?=R*kG# z!rQ178&ZoU(~U<;lsg@n216Ze3rB2FwqjbZ=u|J?nN%<4J9(Bl(90xevE|7ejUYm9 zg@E_xX}u2d%O1mpA2XzjRwWinvSeg)gHABeMH(2!A^g@~4l%8e0WWAkBvv60Cr>TR zQB1%EQ zUoZeUdqjh+1gFo6h~C~z#A57mf5ibmq$y_uVtA_kWv8X)CzfVEooDaY!#P?5$Y zGPKXbE<75nc%D-|w4OrP#;87oL@2^4+sxKah;a-5&z_&SUf~-z(1}bP=tM^GYtR3a z!x4zjSa^)KWG6jxfUI#{<26g$iAI;o_+B{LXY@WfWEdEl6%#8s3@b`?&Tm#aSK!~| z^%DdrXnijW`d!ajWuKApw&{L+WCPpFialo&^dZ9jC7A%BO`2ZF&YUDe;Yu|zFuv`2 z)BE*7Lkay)M7uohJ)446X``0x0%PzPTWY92`1Oq4a2D_7V0wypPnXFR)WM0IlFgg@ zqz#hv2xJEQL8eu}O;e(w4rSA?5|eZHbS6jENytJBq59?bOf>Wrl8ySZH36H(6fGR#vHM6q zn}!7!I@4$*+LFXs{x?|=q2*QtYT%Lw3+5(8uc0j8o3}TrG(zSV#>4wo6~)u|R+Yx# z?0$AspZDjv{dfv417~C17Oy%Fal{%+B6H(NX`$Bl>II-L3N3 zZc+sKZbqewU*&_Xt;9k=%4*aVYBvE1n&JZS7Uqjd%n8nOQmzh^x#vWK{;In~=QO)g zT-n3OU(1@3QfL|$g1d2xeBb@O15Rl01+hmpup2De7p%Yrd$E7(In!*R+;IJZh}v!svi z;7N~pq8KZDXXap0qd_D=Y^B)rz4S0^SF=&v6YYTAV$ad43#x!+n~-6< zK{8*vWoAdW(gGGt&URD}@g6tMoY(+Lw=vvxhfIIK9AjvNF_(W}1Rxn(mp;tJfDV<0 zbJN0t(@Xb8UeO{&T{$$uDrs7)j$}=?WsuDl+T2N5Y<4TMHGOMcocPr$%~(yvtKv(n z`U96d!D0cb9>Dx2zz$m&lAhazs%UeR^K*gb>d8CPs+?qlpfA;t{InXa)^2ryC(FU(Zc6Xbnnh`lg`K&g^JeS>}^c0MJKUCfV+~ zV(EN0Z5ztoN;hqcj!8V+VRbSltJ<~|y`U+9#wv|~H zNE!j9uXa=dec@JQSgJ6N6@Il&tzCBJv9#ldR`Lm*<)YwH4tdlAlG0Fl8Nfa(J~c%DQ2AA-}x8D=p(l#n1+hgx;N;1Aq?lq@{Lt9FKu89CjnnHD1G_@p;%Lp`+b@ttb33!E_Xt;QUD9~nRQl&xAro9-{+&6^ljK2f-d>&qy&d#0xwH z@slNv@ULKp!Cf*JHuS@#4c?F->WjPc)yiuSargAIEg>muRxzY?Hzdq@G5CS)U1*Et zE2SLh=@DI1J(guiy2Igq(?(xI9WL%g^f@{5Hmr|!Qz4`vn|LjrtO=b~I6~5EU5Fxy z;-#<)6w#w=DkpSthAu+E;OL?!?6C9Mwt*o(@68(Jhvs-eX4V z=d=>HI|`3J%H5X|gSrC8KH^IL?h5=3ID6svwHH@(wRbSG`Zsor^q4`3PCn#-(YX?< z_q8+T)51$E0xyKR{L!LN(G=+9K6$3#PDT^IAe|Igkx=!4#rqKWoXiZdh`&ocjp=Ok zemJe6*{it~>;sr(B0fSmp(S#*y5I0)OOz~Oe6Im+($S}e3tyx7Y6pA8vKCBmSEQDa zLfkm*;uMbTLpcR0)tF_v-lbK%`5>POyI2E(!)2=Rj0p;WKi=|UNt6HsQv0xR3QIK9 zsew(AFyzH!7Azxum{%VC^`cqhGdGbABGQ4cYdNBPTx+XpJ=NUEDeP^e^w^AOE1pQI zP{Us-sk!v$gj}@684E!uWjzvpoF|%v-6hwnitN1sCSg@(>RDCVgU8Ile_-xX`hL6u zzI4*Q)AVu(-ef8{#~P9STQ5t|qIMRoh&S?7Oq+cL6vxG?{NUr@k(~7^%w)P6nPbDa~4Jw}*p-|cT4p1?)!c0FoB(^DNJ+FDg+LoP6=RgB7Or673WD5MG&C!4< zerd6q$ODkBvFoy*%cpHGKSt z3uDC6Sc=xvv@kDzRD)aIO`x}BaWLycA%(w-D`Pd+uL*rL|etagQ;U&xt_9?7#}=}5HI)cU-0 z%pMA`>Xb7s)|Y)4HKSZOu;{lg=KjeIyXb0{@EM`FTDkLRH`!W%z*lQJ74P%Ka76)H zblrSIzf+dMWbO`g;=(b@{pS)zUcO&GrIFe%&?YeX4r8B2bBArB%-5ZrQ+vonr%AYy z1+u0*K{UVUmV>h5vD!F;6}a%KdMZQLs04oGkpiaC)zI( zT2U9qta5o|6Y+It1)sE8>u&0)W~l$NX@ZQ8UZfB=`($EW6?FT%{EoRhOrb9)z@3r8y?Z99FNLDE;7V=Q zotj&igu*Rh^VQn3MQKBq!T{yTwGhn1YL6k*?j?{_ek5xe8#i#GG4S-a_Re2lssG!} z`Y-d0BcOdB@!m?4y&hMN68}#0-IIlm_xO)d#}ugX{q^OZe{-@LeJyv`cY&ze4t2~! zKb{qX-j;kt{?gC(vW%}X4pm@1F?~LH{^Q8d@X$dy@5ff~p!J3zmA>H`A)y+6RB_h* zZfIO+bd=*LiymRw{asW%xxaVl33_xtdVrrqIPn zc@y8oMJvNtgcO~4i0`f)GCFkWY8EF?4duLVjHTdb6oYLnO9}Q-pe{CKQJL)hV8)JI z$mVA0Dq&7Z1TbYdSC(WbJ+IBjXngZTu&I+vHF|>Zo$757{8lL;8Zr-Exkf?3jzN5k z_d9I>{>^J?!l)< zNd$7E9FVrta}3qy3L7Ys$^fRWNuu^hs^{*eXvazd&+Q*?lTfc>2+EdP(o0P_Z05HX zVKsfFAQ{t^CRu~Dw(CuJ>tvx*p$5@flA>QRl455b&{*U?xU8`)nF2T$uu_(l8VNtq z?pBiRQIckGzk8W&SFSB=g6eG`ZC;6v9w`?eF*S}3E@N`2ropeHP)E}o?qJkyVEI;K$!)bWY zt9>4WmDVJh7U~m$|K`T#hF!v|znj^=M;69uXrFys#51XT;DbMr4H)>7UQ1e2(cuQf z4kr~Tt1tpBB2GaJ(|j~lHgW40EgMMVqR6eJoJig1SBg|2=$~4I3P0eP$q%_`sS&4~ z26=&a&tLjQbch1`cVXa-2fTl1y8}->|Nqu?uVrNTov!=VKh)g89wUPTgAzkSKZ57_ zr=B^mcldE3K04t4{;RaG53&9yovq;@aR#VHx+R1^^*kr-vEEd!uea68Z<{R%_DD6fn&T4 zu;fDj07L-(_fLSJGdkeh&c&7A(ZLj`7iwnkAcqUexU;WjUkqeg1m1-IUZTIZA(4dtr2Gr`e{BIejlCgS<33MB=1!8?a74!F%=Uo7N`F@k} ze+1C_eU4Y_$mvdjci zwEtCIphA2PBzBhng5=M#e4r%)RW5rVD|_`PvY$7BK`}w~d>%0O9sY#*LUAq=^OjMF^PY5m<7!=s5jyRfosCQAo#hL`h5vN-M}6Q z0Li}){5?wi8)GVHNkF|U9*8V5ej)nhb^TLw1KqiPK(@{P1^L&P=`ZNt?_+}&0(8Uh zfyyZFPgMV7ECt;Jdw|`|{}b$w4&x77VxR>8wUs|GQ5FBf1UlvasqX$qfk5rI4>Wfr zztH>y`=daAef**C12yJ7;LDf&3;h3X+5@dGPy@vS(RSs3CWimbTp=g '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum -warn ( ) { +warn () { echo "$*" -} +} >&2 -die ( ) { +die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -82,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -90,75 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index aec99730b..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,4 +1,20 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -8,20 +24,24 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,44 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From a8afbfbe2b88f26c654df5354ee13d036e24b471 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sun, 11 Aug 2024 20:31:18 +0200 Subject: [PATCH 62/73] Started moving SyncRepo test cases to a shared library --- app/build.gradle | 4 +- .../java/com/orgzly/android/OrgzlyTest.java | 2 - .../orgzly/android/repos/DocumentRepoTest.kt | 127 +++ .../orgzly/android/repos/SyncRepoIntegTest.kt | 776 +++++++++++++++++ .../com/orgzly/android/repos/SyncRepoTest.kt | 789 +----------------- .../orgzly/android/{ => repos}/WebdavTest.kt | 38 +- settings.gradle | 1 + shared-test/.gitignore | 1 + shared-test/build.gradle | 45 + shared-test/proguard-rules.pro | 21 + shared-test/src/main/AndroidManifest.xml | 17 + .../com/orgzly/android/repos/SyncRepoTests.kt | 82 ++ 12 files changed, 1093 insertions(+), 810 deletions(-) create mode 100644 app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt create mode 100644 app/src/androidTest/java/com/orgzly/android/repos/SyncRepoIntegTest.kt rename app/src/test/java/com/orgzly/android/{ => repos}/WebdavTest.kt (83%) create mode 100644 shared-test/.gitignore create mode 100644 shared-test/build.gradle create mode 100644 shared-test/proguard-rules.pro create mode 100644 shared-test/src/main/AndroidManifest.xml create mode 100644 shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt diff --git a/app/build.gradle b/app/build.gradle index 0b11c31cd..e4f2e03e1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -185,8 +185,6 @@ dependencies { androidTestImplementation "de.sven-jacobs:loremipsum:$versions.loremipsum" - androidTestImplementation "io.github.atetzner:webdav-embedded-server:0.2.1" - // Dagger implementation "com.google.dagger:dagger:$versions.dagger" kapt "com.google.dagger:dagger-compiler:$versions.dagger" @@ -225,6 +223,8 @@ dependencies { implementation("androidx.biometric:biometric-ktx:$versions.biometric_ktx") { because 'Protect SSH key with biometric prompt' } + testImplementation(project(":shared-test")) + androidTestImplementation(project(":shared-test")) } repositories { diff --git a/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java b/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java index dea5ce2e8..42c7256ae 100644 --- a/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java +++ b/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java @@ -4,7 +4,6 @@ import android.Manifest; import android.app.Activity; -import android.app.UiAutomation; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; @@ -20,7 +19,6 @@ import com.orgzly.android.repos.RepoFactory; import com.orgzly.android.util.UserTimeFormatter; import com.orgzly.org.datetime.OrgDateTime; -import com.orgzly.test.BuildConfig; import org.junit.After; import org.junit.Before; diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt new file mode 100644 index 000000000..40be239e2 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -0,0 +1,127 @@ +package com.orgzly.android.repos + +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.orgzly.R +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.ui.repos.ReposActivity +import org.hamcrest.core.AllOf +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class DocumentRepoTest : SyncRepoTests, OrgzlyTest() { + + private lateinit var documentTreeSegment: String + private lateinit var repo: Repo + private lateinit var syncRepo: SyncRepo + private lateinit var repoDirectory: DocumentFile + + @Before + override fun setUp() { + super.setUp() + setupDocumentRepo() + } + + @After + override fun tearDown() { + super.tearDown() + for (file in repoDirectory.listFiles()) { + file.delete() + } + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTests.testGetBooks_singleOrgFile(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTests.testGetBooks_singleFileInSubfolder(repoDirectory, syncRepo) + } + + private fun setupDocumentRepo(extraDir: String? = null) { + val repoDirName = SyncRepoTests.repoDirName + documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { + "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" + } else { + "/document/primary%3A$repoDirName%2F" + } + var treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { + "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName" + } else { + "content://com.android.externalstorage.documents/tree/primary%3A$repoDirName" + } + if (extraDir != null) { + treeDocumentFileUrl = "$treeDocumentFileUrl%2F" + Uri.encode(extraDir) + } + repoDirectory = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri())!! + repo = if (!repoDirectory.exists()) { + if (extraDir != null) { + setupDocumentRepoInUi(extraDir) + } else { + setupDocumentRepoInUi(repoDirName) + } + dataRepository.getRepos()[0] + } else { + testUtils.setupRepo(RepoType.DOCUMENT, treeDocumentFileUrl) + } + syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) + Assert.assertEquals(treeDocumentFileUrl, repo.url) + } + + /** + * Note that this solution only works the first time the tests are run on any given virtual + * device. On the second run, the file picker will start in a different folder, resulting in + * a different repo URL, making some tests fail. If you are running locally, you must work + * around this by wiping the device's data between test suite runs. + */ + private fun setupDocumentRepoInUi(repoDirName: String) { + ActivityScenario.launch(ReposActivity::class.java).use { + Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) + .perform(ViewActions.click()) + SystemClock.sleep(500) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + if (Build.VERSION.SDK_INT < 30) { + // Older system file picker UI + mDevice.findObject(UiSelector().description("More options")).click() + SystemClock.sleep(300) + mDevice.findObject(UiSelector().text("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } else { + mDevice.findObject(UiSelector().description("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } + // Back in Orgzly: + SystemClock.sleep(500) + Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) + Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoIntegTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoIntegTest.kt new file mode 100644 index 000000000..0efad6b9f --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoIntegTest.kt @@ -0,0 +1,776 @@ +package com.orgzly.android.repos + +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.orgzly.R +import com.orgzly.android.BookName +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.BookView +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.git.GitFileSynchronizer +import com.orgzly.android.git.GitPreferencesFromRepoPrefs +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.prefs.RepoPreferences +import com.orgzly.android.repos.RepoType.* +import com.orgzly.android.sync.BookSyncStatus +import com.orgzly.android.ui.main.MainActivity +import com.orgzly.android.ui.repos.ReposActivity +import com.orgzly.android.util.MiscUtils +import org.eclipse.jgit.api.Git +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.core.AllOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.nio.file.Path +import java.util.UUID +import kotlin.io.path.createTempDirectory + +@RunWith(value = Parameterized::class) +class SyncRepoIntegTest(private val repoType: RepoType) : OrgzlyTest() { + + private val permanentRepoTestDir = "orgzly-android-tests" + private var topDirName = RANDOM_UUID + private lateinit var repo: Repo + private lateinit var syncRepo: SyncRepo + + // Used by GitRepo + private lateinit var gitWorkingTree: File + private lateinit var gitBareRepoPath: Path + private lateinit var gitFileSynchronizer: GitFileSynchronizer + + // Used by DocumentRepo + private lateinit var documentTreeSegment: String + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Array { + return arrayOf( + GIT, + DOCUMENT, + DROPBOX, + ) + } + + /* For creating a unique directory per test suite instance for tests which interact with + the cloud (Dropbox), to avoid collisions when they are run simultaneously on + different devices. */ + val RANDOM_UUID = UUID.randomUUID().toString() + } + + override fun tearDown() { + super.tearDown() + if (this::repo.isInitialized) { + when (repo.type) { + GIT -> tearDownGitRepo() + MOCK -> TODO() + DROPBOX -> tearDownDropboxRepo() + DIRECTORY -> TODO() + DOCUMENT -> tearDownDocumentRepo() + WEBDAV -> TODO() + } + } + } + + @JvmField + @Rule + var exceptionRule: ExpectedException = ExpectedException.none() + + // TODO: Move to DataRepository tests + @Test + @Throws(IOException::class) + fun testLoadBook() { + setupSyncRepo(repoType) + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "booky.org") + } finally { + tmpFile.delete() + } + val repoBooks = syncRepo.books + assertEquals(1, repoBooks.size.toLong()) + assertEquals(repo.url, repoBooks[0].repoUri.toString()) + testUtils.sync() + val books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the resulting notebook gets the right name + assertEquals("booky", books[0].book.name) + } + + // TODO: Move to DataRepository tests + @Test + @Throws(IOException::class) + fun testForceLoadBook() { + setupSyncRepo(repoType) + val bookView = testUtils.setupBook("booky", "content") + testUtils.sync() + var books = dataRepository.getBooks() + assertEquals(1, books.size) + assertEquals("booky", books[0].book.name) + dataRepository.forceLoadBook(bookView.book.id) + books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the name has not changed + assertEquals("booky", books[0].book.name) + } + + @Test + fun testLoadBookWithSpaceInName() { + setupSyncRepo(repoType) + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "book one.org") + } finally { + tmpFile.delete() + } + val repoBooks = syncRepo.books + assertEquals(1, repoBooks.size.toLong()) + assertEquals(repo.url, repoBooks[0].repoUri.toString()) + // Check that the notebook gets the right name based on the repository file's name + assertEquals("book one", BookName.getInstance(context, repoBooks[0]).name) + // Check that the remote filename is parsed and stored correctly + assertEquals("book one.org", BookName.getInstance(context, repoBooks[0]).fileName) + // Check that the resulting local book gets the right name + testUtils.sync() + val books = dataRepository.getBooks() + assertEquals(1, books.size) + assertEquals("book one", books[0].book.name) + } + + @Test + @Throws(IOException::class) + fun testExtension() { + setupSyncRepo(repoType) + // Add multiple files to repo + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + val books = syncRepo.books + assertEquals(1, books.size.toLong()) + assertEquals("file three", BookName.getInstance(context, books[0]).name) + assertEquals("file three.org", BookName.getInstance(context, books[0]).fileName) + assertEquals(repo.id, books[0].repoId) + assertEquals(repo.url, books[0].repoUri.toString()) + } + + // TODO: Move to DataRepository tests + @Test + fun testSyncNewBookWithoutLinkAndOneRepo() { + setupSyncRepo(repoType) + testUtils.setupBook("Book 1", "content") + testUtils.sync() + val bookView = dataRepository.getBooks()[0] + assertEquals(repo.url, bookView.linkRepo?.url) + assertEquals(1, syncRepo.books.size) + assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) + assertEquals( + context.getString(R.string.sync_status_saved, repo.url), + bookView.book.lastAction!!.message + ) + } + + // TODO: Move to DataRepository tests + @Test + fun testRenameBook() { + setupSyncRepo(repoType) + testUtils.setupBook("oldname", "") + testUtils.sync() + var bookView = dataRepository.getBookView("oldname") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertTrue(bookView.syncedTo!!.uri.toString().contains("oldname.org")) + + dataRepository.renameBook(bookView, "newname") + + assertEquals(1, syncRepo.books.size.toLong()) + assertEquals( + "newname.org", + BookName.getInstance(context, syncRepo.books[0]).fileName + ) + bookView = dataRepository.getBookView("newname") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertTrue(bookView.syncedTo!!.uri.toString().contains("newname.org")) + } + + // TODO: Move to DataRepository tests + @Test + fun testRenameBookToNameWithSpace() { + setupSyncRepo(repoType) + testUtils.setupBook("oldname", "") + testUtils.sync() + var bookView = dataRepository.getBookView("oldname") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + assertTrue(bookView.syncedTo!!.uri.toString().contains("oldname.org")) + + dataRepository.renameBook(bookView, "new name") + + assertEquals(1, syncRepo.books.size.toLong()) + assertEquals( + "new name.org", + BookName.getInstance(context, syncRepo.books[0]).fileName + ) + bookView = dataRepository.getBookView("new name") + assertEquals(repo.url, bookView!!.linkRepo!!.url) + assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) + val expectedRookUriName = when (repoType) { + GIT -> "new name.org" + else -> { "new%20name.org" } + } + assertTrue(bookView.syncedTo!!.uri.toString().endsWith(expectedRookUriName)) + } + + @Test + fun testRenameBookToExistingRepoFileName() { + setupSyncRepo(repoType) + testUtils.setupBook("a", "") + testUtils.sync() + + // Create "unsynced" file in repo + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile("bla bla", tmpFile) + syncRepo.storeBook(tmpFile, "b.org") + tmpFile.delete() + assertEquals(2, syncRepo.books.size) // The remote repo should now contain 2 books + + dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + + // The remote repo should still contain 2 books - otherwise the existing b.org has been + // overwritten. + assertEquals(2, syncRepo.books.size) + assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed:")) + } + + // TODO: Move to DataRepository tests (check happens there) + @Test + fun testRenameBookToExistingBookName() { + setupSyncRepo(repoType) + testUtils.setupBook("a", "") + testUtils.setupBook("b", "") + assertEquals(2, dataRepository.getBooks().size) + dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") + assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) + } + + @Test + fun testIgnoreRulePreventsLoadingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) // .orgzlyignore not supported below API 26 + val ignoreRules = """ + ignoredbook.org + ignored-*.org + """.trimIndent() + setupSyncRepo(repoType, ignoreRules) + // Add multiple files to repo + for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + testUtils.sync() + assertEquals(1, syncRepo.books.size) + assertEquals(1, dataRepository.getBooks().size) + assertEquals("notignored", dataRepository.getBooks()[0].book.name) + } + + @Test + fun testUnIgnoredFilesInRepoAreLoaded() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + val ignoreFileContents = """ + *.org + !notignored.org + """.trimIndent() + setupSyncRepo(repoType, ignoreFileContents) + // Add multiple files to repo + for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + testUtils.sync() + assertEquals(1, syncRepo.books.size) + assertEquals(1, dataRepository.getBooks().size) + assertEquals("notignored", dataRepository.getBooks()[0].book.name) + } + + // TODO: Move to DataRepository tests (check happens there) + @Test + fun testIgnoreRulePreventsRenamingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(repoType,"bad name*") + + // Create book and sync it + testUtils.setupBook("good name", "") + testUtils.sync() + var bookView: BookView? = dataRepository.getBookView("good name") + dataRepository.renameBook(bookView!!, "bad name") + bookView = dataRepository.getBooks()[0] + assertTrue( + bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") + ) + } + + // TODO: Move to DataRepository tests (check happens there) + @Test + @Throws(java.lang.Exception::class) + fun testIgnoreRulePreventsLinkingBook() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(repoType, "*.org") + testUtils.setupBook("booky", "") + exceptionRule.expect(IOException::class.java) + exceptionRule.expectMessage("matches a rule in .orgzlyignore") + testUtils.syncOrThrow() + } + + @Test + fun testStoreBookInSubfolder() { + setupSyncRepo(repoType) + testUtils.setupBook("a folder/a book", "") + testUtils.sync() + assertEquals(1, syncRepo.books.size) + val expectedRookUri = when (repoType) { + GIT -> "/a folder/a book.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org" + else -> { repo.url + "/a%20folder/a%20book.org" } + } + assertEquals(expectedRookUri, dataRepository.getBooks()[0].syncedTo!!.uri.toString()) + assertEquals("a folder/a book", dataRepository.getBooks()[0].book.name) + } + + @Test + @Throws(IOException::class) + fun testLoadBookFromSubfolder() { + setupSyncRepo(repoType) + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("...", tmpFile) + syncRepo.storeBook(tmpFile, "a folder/a book.org") + } finally { + tmpFile.delete() + } + val repoBooks = syncRepo.books + assertEquals(1, repoBooks.size.toLong()) + assertEquals(repo.url, repoBooks[0].repoUri.toString()) + testUtils.sync() + val books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the resulting notebook gets the right name + assertEquals("a folder/a book", books[0].book.name) + } + + /** + * Ensures that file names and book names are not parsed/created differently during + * force-loading. + * + * TODO: Move - tests code in DataRepository, not SyncRepo + */ + @Test + @Throws(IOException::class) + fun testForceLoadBookInSubfolder() { + setupSyncRepo(repoType) + val bookView = testUtils.setupBook("a folder/a book", "content") + testUtils.sync() + var books = dataRepository.getBooks() + assertEquals(1, books.size) + assertEquals("a folder/a book", books[0].book.name) + dataRepository.forceLoadBook(bookView.book.id) + books = dataRepository.getBooks() + assertEquals(1, books.size) + // Check that the name has not changed + assertEquals("a folder/a book", books[0].book.name) + } + + @Test + fun testIgnoreFileInSubfolder() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(repoType, "subfolder1/book1.org") + // Write 2 org files to subfolder in repo + for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book2", books[0].book.name) + } + + @Test + fun testUnIgnoreSingleFileInSubfolder() { + Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) + setupSyncRepo(repoType, "subfolder1/**\n!subfolder1/book2.org") + // Write 2 org files to subfolder in repo + for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { + val tmpFile = File.createTempFile("orgzlytest", null) + MiscUtils.writeStringToFile("book content", tmpFile) + syncRepo.storeBook(tmpFile, fileName) + tmpFile.delete() + } + + testUtils.sync() + + val books = dataRepository.getBooks() + assertEquals(1, books.size.toLong()) + assertEquals("subfolder1/book2", books[0].book.name) + } + + @Test + fun testStoreBookAndRetrieveBookProducesSameRookUri() { + setupSyncRepo(repoType) + + val repoFilePath = "folder one/book one.org" + + // Upload file to repo + val storedBook: VersionedRook? + var tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("content", tmpFile) + storedBook = syncRepo.storeBook(tmpFile, repoFilePath) + } finally { + tmpFile.delete() + } + + // Download file from repo + tmpFile = dataRepository.getTempBookFile() + val retrievedBook: VersionedRook? + try { + retrievedBook = syncRepo.retrieveBook(repoFilePath, tmpFile) + } finally { + tmpFile.delete() + } + + assertEquals(storedBook!!.uri, retrievedBook!!.uri!!) + } + + // TODO: Move - does not test SyncRepo code + @Test + fun testUpdateBookInSubfolder() { + setupSyncRepo(repoType) + // Create org file in subfolder + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("* DONE Heading 1", tmpFile) + syncRepo.storeBook(tmpFile, "folder one/book one.org") + } finally { + tmpFile.delete() + } + + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + + ActivityScenario.launch(MainActivity::class.java).use { + // Modify book + EspressoUtils.onBook(0).perform(ViewActions.click()) + EspressoUtils.onNoteInBook(1).perform(ViewActions.longClick()) + Espresso.onView(ViewMatchers.withId(R.id.toggle_state)).perform(ViewActions.click()) + Espresso.pressBack() + Espresso.pressBack() + EspressoUtils.sync() + EspressoUtils.onBook(0, R.id.item_book_last_action).check(matches(ViewMatchers.withText(containsString("Saved to ")))) + // Delete notebook from Orgzly and reload it to verify that our change was successfully written + EspressoUtils.onBook(0).perform(ViewActions.longClick()) + EspressoUtils.contextualToolbarOverflowMenu().perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withText(R.string.delete)).perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withText(R.string.delete)).perform(ViewActions.click()) + } + + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") + } + + @Test + fun testRenameBookFromRootToSubfolder() { + setupSyncRepo(repoType) + testUtils.setupBook("booky", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") + assertTrue(dataRepository.getBookView("a/b")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("a/b") + assertEquals(BookSyncStatus.NO_CHANGE.toString(), bookView!!.book.syncStatus) + val expectedRookUri = when (repoType) { + GIT -> "/a/b.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%2Fb.org" + else -> { repo.url + "/a/b.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookFromSubfolderToRoot() { + setupSyncRepo(repoType) + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") + assertTrue(dataRepository.getBookView("booky")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("booky") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (repoType) { + GIT -> "/booky.org" + DOCUMENT -> repo.url + documentTreeSegment + "booky.org" + else -> { repo.url + "/booky.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookNewSubfolderSameLeafName() { + setupSyncRepo(repoType) + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") + assertTrue(dataRepository.getBookView("b/b")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("b/b") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (repoType) { + GIT -> "/b/b.org" + DOCUMENT -> repo.url + documentTreeSegment + "b%2Fb.org" + else -> { repo.url + "/b/b.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookNewSubfolderAndLeafName() { + setupSyncRepo(repoType) + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") + assertTrue(dataRepository.getBookView("b/c")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("b/c") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (repoType) { + GIT -> "/b/c.org" + DOCUMENT -> repo.url + documentTreeSegment + "b%2Fc.org" + else -> { repo.url + "/b/c.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + fun testRenameBookSameSubfolderNewLeafName() { + setupSyncRepo(repoType) + testUtils.setupBook("a/b", "") + testUtils.sync() + dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") + assertTrue(dataRepository.getBookView("a/c")!!.book.lastAction!!.message.contains("Renamed from ")) + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + val bookView = dataRepository.getBookView("a/c") + assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) + val expectedRookUri = when (repoType) { + GIT -> "/a/c.org" + DOCUMENT -> repo.url + documentTreeSegment + "a%2Fc.org" + else -> { repo.url + "/a/c.org" } + } + assertEquals( + expectedRookUri, + bookView.syncedTo!!.uri.toString() + ) + } + + @Test + @Throws(FileNotFoundException::class) + fun testSyncWithDirectoryWithSpaceInName() { + Assume.assumeTrue(repoType != GIT) // Git repo URLs will never contain a space + topDirName = "space separated" + if (repoType == DOCUMENT) { + setupDocumentRepo(topDirName) + } else { + setupSyncRepo(repoType) + } + val tmpFile = dataRepository.getTempBookFile() + try { + MiscUtils.writeStringToFile("content", tmpFile) + syncRepo.storeBook(tmpFile, "notebook.org") + } finally { + tmpFile.delete() + } + testUtils.sync() + assertEquals(1, dataRepository.getBooks().size.toLong()) + if (repoType == DOCUMENT) { + assertTrue(syncRepo.uri.toString().contains("space%20separated")) + } else { + assertTrue(syncRepo.uri.toString().contains("space separated")) + } + } + + private fun setupSyncRepo(repoType: RepoType, ignoreRules: String? = null) { + when (repoType) { + GIT -> setupGitRepo() + MOCK -> TODO() + DROPBOX -> setupDropboxRepo() + DIRECTORY -> TODO() + DOCUMENT -> setupDocumentRepo() + WEBDAV -> TODO() + } + if (ignoreRules != null) { + val tmpFile = File.createTempFile("orgzly-test", null) + MiscUtils.writeStringToFile(ignoreRules, tmpFile) + syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) + tmpFile.delete() + } + } + + private fun setupDropboxRepo() { + testUtils.dropboxTestPreflight() + repo = testUtils.setupRepo(DROPBOX, "dropbox:/$permanentRepoTestDir/$topDirName") + syncRepo = testUtils.repoInstance(DROPBOX, repo.url, repo.id) + } + + private fun tearDownDropboxRepo() { + val dropboxRepo = syncRepo as DropboxRepo + try { + dropboxRepo.deleteDirectory(syncRepo.uri) + } catch (_: IOException) {} + } + + private fun setupDocumentRepo(extraDir: String? = null) { + documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { + "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$permanentRepoTestDir%2F" + } else { + "/document/primary%3A$permanentRepoTestDir%2F" + } + var treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { + "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$permanentRepoTestDir" + } else { + "content://com.android.externalstorage.documents/tree/primary%3A$permanentRepoTestDir" + } + if (extraDir != null) { + treeDocumentFileUrl = "$treeDocumentFileUrl%2F" + Uri.encode(extraDir) + } + val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) + repo = if (repoDirDocumentFile?.exists() == false) { + if (extraDir != null) { + setupDocumentRepoInUi(extraDir) + } else { + setupDocumentRepoInUi(permanentRepoTestDir) + } + dataRepository.getRepos()[0] + } else { + testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) + } + syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) + assertEquals(treeDocumentFileUrl, repo.url) + } + + /** + * Note that this solution only works the first time the tests are run on any given virtual + * device. On the second run, the file picker will start in a different folder, resulting in + * a different repo URL, making some tests fail. If you are running locally, you must work + * around this by wiping the device's data between test suite runs. + */ + private fun setupDocumentRepoInUi(repoDirName: String) { + ActivityScenario.launch(ReposActivity::class.java).use { + Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) + .perform(ViewActions.click()) + SystemClock.sleep(500) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + if (Build.VERSION.SDK_INT < 30) { + // Older system file picker UI + mDevice.findObject(UiSelector().description("More options")).click() + SystemClock.sleep(300) + mDevice.findObject(UiSelector().text("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } else { + mDevice.findObject(UiSelector().description("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } + // Back in Orgzly: + SystemClock.sleep(500) + Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) + Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } + } + + private fun tearDownDocumentRepo() { + val repoDirectory = DocumentFile.fromTreeUri(context, repo.url.toUri()) + for (file in repoDirectory!!.listFiles()) { + file.delete() + } + } + + private fun setupGitRepo() { + gitBareRepoPath = createTempDirectory() + Git.init().setBare(true).setDirectory(gitBareRepoPath.toFile()).call() + AppPreferences.gitIsEnabled(context, true) + repo = testUtils.setupRepo(GIT, gitBareRepoPath.toFile().toUri().toString()) + val repoPreferences = RepoPreferences(context, repo.id, repo.url.toUri()) + val gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) + gitWorkingTree = File(gitPreferences.repositoryFilepath()) + gitWorkingTree.mkdirs() + val git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) + gitFileSynchronizer = GitFileSynchronizer(git, gitPreferences) + syncRepo = dataRepository.getRepoInstance(repo.id, GIT, repo.url) + } + + private fun tearDownGitRepo() { + testUtils.deleteRepo(repo.url) + gitWorkingTree.deleteRecursively() + gitBareRepoPath.toFile()!!.deleteRecursively() + } +} diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt index 1c94e9db0..d785616f4 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -1,74 +1,19 @@ package com.orgzly.android.repos -import android.net.Uri -import android.os.Build -import android.os.SystemClock -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiSelector -import com.orgzly.R -import com.orgzly.android.BookName import com.orgzly.android.OrgzlyTest -import com.orgzly.android.db.entity.BookView import com.orgzly.android.db.entity.Repo -import com.orgzly.android.espresso.util.EspressoUtils -import com.orgzly.android.git.GitFileSynchronizer -import com.orgzly.android.git.GitPreferencesFromRepoPrefs -import com.orgzly.android.prefs.AppPreferences -import com.orgzly.android.prefs.RepoPreferences -import com.orgzly.android.repos.RepoType.* -import com.orgzly.android.sync.BookSyncStatus -import com.orgzly.android.ui.main.MainActivity -import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.util.MiscUtils -import io.github.atetzner.webdav.server.MiltonWebDAVFileServer -import org.eclipse.jgit.api.Git -import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.core.AllOf -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assume -import org.junit.Rule -import org.junit.Test -import org.junit.rules.ExpectedException import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.io.File -import java.io.FileNotFoundException -import java.io.IOException -import java.nio.file.Path import java.util.UUID -import kotlin.io.path.createTempDirectory @RunWith(value = Parameterized::class) class SyncRepoTest(private val repoType: RepoType) : OrgzlyTest() { - private val permanentRepoTestDir = "orgzly-android-tests" - private var topDirName = RANDOM_UUID private lateinit var repo: Repo private lateinit var syncRepo: SyncRepo - // Used by GitRepo - private lateinit var gitWorkingTree: File - private lateinit var gitBareRepoPath: Path - private lateinit var gitFileSynchronizer: GitFileSynchronizer - - // Used by DocumentRepo - private lateinit var documentTreeSegment: String - - // Used by WebdavRepo - private val webDavServerUrl = "http://localhost:8081/" - private lateinit var serverRootDir: File - private lateinit var localServer: MiltonWebDAVFileServer - private lateinit var tmpFile: File - companion object { @JvmStatic @Parameterized.Parameters(name = "{0}") @@ -77,7 +22,7 @@ class SyncRepoTest(private val repoType: RepoType) : OrgzlyTest() { // GIT, // DOCUMENT, // DROPBOX, - WEBDAV, + RepoType.WEBDAV, ) } @@ -91,595 +36,24 @@ class SyncRepoTest(private val repoType: RepoType) : OrgzlyTest() { super.tearDown() if (this::repo.isInitialized) { when (repo.type) { - GIT -> tearDownGitRepo() - MOCK -> TODO() - DROPBOX -> tearDownDropboxRepo() - DIRECTORY -> TODO() - DOCUMENT -> tearDownDocumentRepo() - WEBDAV -> tearDownWebdavRepo() + RepoType.WEBDAV -> TODO() + RepoType.MOCK -> TODO() + RepoType.DROPBOX -> TODO() + RepoType.DIRECTORY -> TODO() + RepoType.DOCUMENT -> TODO() + RepoType.GIT -> TODO() } } } - @JvmField - @Rule - var exceptionRule: ExpectedException = ExpectedException.none() - - // TODO: Move to DataRepository tests - @Test - @Throws(IOException::class) - fun testLoadBook() { - setupSyncRepo(repoType) - val tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("...", tmpFile) - syncRepo.storeBook(tmpFile, "booky.org") - } finally { - tmpFile.delete() - } - val repoBooks = syncRepo.books - assertEquals(1, repoBooks.size.toLong()) - assertEquals(repo.url, repoBooks[0].repoUri.toString()) - testUtils.sync() - val books = dataRepository.getBooks() - assertEquals(1, books.size) - // Check that the resulting notebook gets the right name - assertEquals("booky", books[0].book.name) - } - - // TODO: Move to DataRepository tests - @Test - @Throws(IOException::class) - fun testForceLoadBook() { - setupSyncRepo(repoType) - val bookView = testUtils.setupBook("booky", "content") - testUtils.sync() - var books = dataRepository.getBooks() - assertEquals(1, books.size) - assertEquals("booky", books[0].book.name) - dataRepository.forceLoadBook(bookView.book.id) - books = dataRepository.getBooks() - assertEquals(1, books.size) - // Check that the name has not changed - assertEquals("booky", books[0].book.name) - } - - @Test - fun testLoadBookWithSpaceInName() { - setupSyncRepo(repoType) - val tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("...", tmpFile) - syncRepo.storeBook(tmpFile, "book one.org") - } finally { - tmpFile.delete() - } - val repoBooks = syncRepo.books - assertEquals(1, repoBooks.size.toLong()) - assertEquals(repo.url, repoBooks[0].repoUri.toString()) - // Check that the notebook gets the right name based on the repository file's name - assertEquals("book one", BookName.getInstance(context, repoBooks[0]).name) - // Check that the remote filename is parsed and stored correctly - assertEquals("book one.org", BookName.getInstance(context, repoBooks[0]).fileName) - // Check that the resulting local book gets the right name - testUtils.sync() - val books = dataRepository.getBooks() - assertEquals(1, books.size) - assertEquals("book one", books[0].book.name) - } - - @Test - @Throws(IOException::class) - fun testExtension() { - setupSyncRepo(repoType) - // Add multiple files to repo - for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { - val tmpFile = File.createTempFile("orgzly-test", null) - MiscUtils.writeStringToFile("book content", tmpFile) - syncRepo.storeBook(tmpFile, fileName) - tmpFile.delete() - } - val books = syncRepo.books - assertEquals(1, books.size.toLong()) - assertEquals("file three", BookName.getInstance(context, books[0]).name) - assertEquals("file three.org", BookName.getInstance(context, books[0]).fileName) - assertEquals(repo.id, books[0].repoId) - assertEquals(repo.url, books[0].repoUri.toString()) - } - - // TODO: Move to DataRepository tests - @Test - fun testSyncNewBookWithoutLinkAndOneRepo() { - setupSyncRepo(repoType) - testUtils.setupBook("Book 1", "content") - testUtils.sync() - val bookView = dataRepository.getBooks()[0] - assertEquals(repo.url, bookView.linkRepo?.url) - assertEquals(1, syncRepo.books.size) - assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) - assertEquals( - context.getString(R.string.sync_status_saved, repo.url), - bookView.book.lastAction!!.message - ) - } - - // TODO: Move to DataRepository tests - @Test - fun testRenameBook() { - setupSyncRepo(repoType) - testUtils.setupBook("oldname", "") - testUtils.sync() - var bookView = dataRepository.getBookView("oldname") - assertEquals(repo.url, bookView!!.linkRepo!!.url) - assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - assertTrue(bookView.syncedTo!!.uri.toString().contains("oldname.org")) - - dataRepository.renameBook(bookView, "newname") - - assertEquals(1, syncRepo.books.size.toLong()) - assertEquals( - "newname.org", - BookName.getInstance(context, syncRepo.books[0]).fileName - ) - bookView = dataRepository.getBookView("newname") - assertEquals(repo.url, bookView!!.linkRepo!!.url) - assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - assertTrue(bookView.syncedTo!!.uri.toString().contains("newname.org")) - } - - // TODO: Move to DataRepository tests - @Test - fun testRenameBookToNameWithSpace() { - setupSyncRepo(repoType) - testUtils.setupBook("oldname", "") - testUtils.sync() - var bookView = dataRepository.getBookView("oldname") - assertEquals(repo.url, bookView!!.linkRepo!!.url) - assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - assertTrue(bookView.syncedTo!!.uri.toString().contains("oldname.org")) - - dataRepository.renameBook(bookView, "new name") - - assertEquals(1, syncRepo.books.size.toLong()) - assertEquals( - "new name.org", - BookName.getInstance(context, syncRepo.books[0]).fileName - ) - bookView = dataRepository.getBookView("new name") - assertEquals(repo.url, bookView!!.linkRepo!!.url) - assertEquals(repo.url, bookView.syncedTo!!.repoUri.toString()) - val expectedRookUriName = when (repoType) { - GIT -> "new name.org" - else -> { "new%20name.org" } - } - assertTrue(bookView.syncedTo!!.uri.toString().endsWith(expectedRookUriName)) - } - - @Test - fun testRenameBookToExistingRepoFileName() { - setupSyncRepo(repoType) - testUtils.setupBook("a", "") - testUtils.sync() - - // Create "unsynced" file in repo - val tmpFile = File.createTempFile("orgzly-test", null) - MiscUtils.writeStringToFile("bla bla", tmpFile) - syncRepo.storeBook(tmpFile, "b.org") - tmpFile.delete() - assertEquals(2, syncRepo.books.size) // The remote repo should now contain 2 books - - dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") - - // The remote repo should still contain 2 books - otherwise the existing b.org has been - // overwritten. - assertEquals(2, syncRepo.books.size) - assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed:")) - } - - // TODO: Move to DataRepository tests (check happens there) - @Test - fun testRenameBookToExistingBookName() { - setupSyncRepo(repoType) - testUtils.setupBook("a", "") - testUtils.setupBook("b", "") - assertEquals(2, dataRepository.getBooks().size) - dataRepository.renameBook(dataRepository.getBookView("a")!!, "b") - assertTrue(dataRepository.getBook("a")!!.lastAction!!.message.contains("Renaming failed: Notebook b already exists")) - } - - @Test - fun testIgnoreRulePreventsLoadingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) // .orgzlyignore not supported below API 26 - val ignoreRules = """ - ignoredbook.org - ignored-*.org - """.trimIndent() - setupSyncRepo(repoType, ignoreRules) - // Add multiple files to repo - for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { - val tmpFile = File.createTempFile("orgzly-test", null) - MiscUtils.writeStringToFile("book content", tmpFile) - syncRepo.storeBook(tmpFile, fileName) - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertEquals(1, dataRepository.getBooks().size) - assertEquals("notignored", dataRepository.getBooks()[0].book.name) - } - - @Test - fun testUnIgnoredFilesInRepoAreLoaded() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - val ignoreFileContents = """ - *.org - !notignored.org - """.trimIndent() - setupSyncRepo(repoType, ignoreFileContents) - // Add multiple files to repo - for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - syncRepo.storeBook(tmpFile, fileName) - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertEquals(1, dataRepository.getBooks().size) - assertEquals("notignored", dataRepository.getBooks()[0].book.name) - } - - // TODO: Move to DataRepository tests (check happens there) - @Test - fun testIgnoreRulePreventsRenamingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(repoType,"bad name*") - - // Create book and sync it - testUtils.setupBook("good name", "") - testUtils.sync() - var bookView: BookView? = dataRepository.getBookView("good name") - dataRepository.renameBook(bookView!!, "bad name") - bookView = dataRepository.getBooks()[0] - assertTrue( - bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") - ) - } - - // TODO: Move to DataRepository tests (check happens there) - @Test - @Throws(java.lang.Exception::class) - fun testIgnoreRulePreventsLinkingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(repoType, "*.org") - testUtils.setupBook("booky", "") - exceptionRule.expect(IOException::class.java) - exceptionRule.expectMessage("matches a rule in .orgzlyignore") - testUtils.syncOrThrow() - } - - @Test - fun testStoreBookInSubfolder() { - setupSyncRepo(repoType) - testUtils.setupBook("a folder/a book", "") - testUtils.sync() - assertEquals(1, syncRepo.books.size) - val expectedRookUri = when (repoType) { - GIT -> "/a folder/a book.org" - DOCUMENT -> repo.url + documentTreeSegment + "a%20folder%2Fa%20book.org" - else -> { repo.url + "/a%20folder/a%20book.org" } - } - assertEquals(expectedRookUri, dataRepository.getBooks()[0].syncedTo!!.uri.toString()) - assertEquals("a folder/a book", dataRepository.getBooks()[0].book.name) - } - - @Test - @Throws(IOException::class) - fun testLoadBookFromSubfolder() { - setupSyncRepo(repoType) - val tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("...", tmpFile) - syncRepo.storeBook(tmpFile, "a folder/a book.org") - } finally { - tmpFile.delete() - } - val repoBooks = syncRepo.books - assertEquals(1, repoBooks.size.toLong()) - assertEquals(repo.url, repoBooks[0].repoUri.toString()) - testUtils.sync() - val books = dataRepository.getBooks() - assertEquals(1, books.size) - // Check that the resulting notebook gets the right name - assertEquals("a folder/a book", books[0].book.name) - } - - /** - * Ensures that file names and book names are not parsed/created differently during - * force-loading. - * - * TODO: Move - tests code in DataRepository, not SyncRepo - */ - @Test - @Throws(IOException::class) - fun testForceLoadBookInSubfolder() { - setupSyncRepo(repoType) - val bookView = testUtils.setupBook("a folder/a book", "content") - testUtils.sync() - var books = dataRepository.getBooks() - assertEquals(1, books.size) - assertEquals("a folder/a book", books[0].book.name) - dataRepository.forceLoadBook(bookView.book.id) - books = dataRepository.getBooks() - assertEquals(1, books.size) - // Check that the name has not changed - assertEquals("a folder/a book", books[0].book.name) - } - - @Test - fun testIgnoreFileInSubfolder() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(repoType, "subfolder1/book1.org") - // Write 2 org files to subfolder in repo - for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - syncRepo.storeBook(tmpFile, fileName) - tmpFile.delete() - } - - testUtils.sync() - - val books = dataRepository.getBooks() - assertEquals(1, books.size.toLong()) - assertEquals("subfolder1/book2", books[0].book.name) - } - - @Test - fun testUnIgnoreSingleFileInSubfolder() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= 26) - setupSyncRepo(repoType, "subfolder1/**\n!subfolder1/book2.org") - // Write 2 org files to subfolder in repo - for (fileName in arrayOf("subfolder1/book1.org", "subfolder1/book2.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - syncRepo.storeBook(tmpFile, fileName) - tmpFile.delete() - } - - testUtils.sync() - - val books = dataRepository.getBooks() - assertEquals(1, books.size.toLong()) - assertEquals("subfolder1/book2", books[0].book.name) - } - - @Test - fun testStoreBookAndRetrieveBookProducesSameRookUri() { - setupSyncRepo(repoType) - - val repoFilePath = "folder one/book one.org" - - // Upload file to repo - val storedBook: VersionedRook? - var tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("content", tmpFile) - storedBook = syncRepo.storeBook(tmpFile, repoFilePath) - } finally { - tmpFile.delete() - } - - // Download file from repo - tmpFile = dataRepository.getTempBookFile() - val retrievedBook: VersionedRook? - try { - retrievedBook = syncRepo.retrieveBook(repoFilePath, tmpFile) - } finally { - tmpFile.delete() - } - - assertEquals(storedBook!!.uri, retrievedBook!!.uri!!) - } - - // TODO: Move - does not test SyncRepo code - @Test - fun testUpdateBookInSubfolder() { - setupSyncRepo(repoType) - // Create org file in subfolder - val tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("* DONE Heading 1", tmpFile) - syncRepo.storeBook(tmpFile, "folder one/book one.org") - } finally { - tmpFile.delete() - } - - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - - ActivityScenario.launch(MainActivity::class.java).use { - // Modify book - EspressoUtils.onBook(0).perform(ViewActions.click()) - EspressoUtils.onNoteInBook(1).perform(ViewActions.longClick()) - Espresso.onView(ViewMatchers.withId(R.id.toggle_state)).perform(ViewActions.click()) - Espresso.pressBack() - Espresso.pressBack() - EspressoUtils.sync() - EspressoUtils.onBook(0, R.id.item_book_last_action).check(matches(ViewMatchers.withText(containsString("Saved to ")))) - // Delete notebook from Orgzly and reload it to verify that our change was successfully written - EspressoUtils.onBook(0).perform(ViewActions.longClick()) - EspressoUtils.contextualToolbarOverflowMenu().perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withText(R.string.delete)).perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withText(R.string.delete)).perform(ViewActions.click()) - } - - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - testUtils.assertBook("folder one/book one", "* TODO Heading 1\n") - } - - @Test - fun testRenameBookFromRootToSubfolder() { - setupSyncRepo(repoType) - testUtils.setupBook("booky", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("booky")!!, "a/b") - assertTrue(dataRepository.getBookView("a/b")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - val bookView = dataRepository.getBookView("a/b") - assertEquals(BookSyncStatus.NO_CHANGE.toString(), bookView!!.book.syncStatus) - val expectedRookUri = when (repoType) { - GIT -> "/a/b.org" - DOCUMENT -> repo.url + documentTreeSegment + "a%2Fb.org" - else -> { repo.url + "/a/b.org" } - } - assertEquals( - expectedRookUri, - bookView.syncedTo!!.uri.toString() - ) - } - - @Test - fun testRenameBookFromSubfolderToRoot() { - setupSyncRepo(repoType) - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "booky") - assertTrue(dataRepository.getBookView("booky")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - val bookView = dataRepository.getBookView("booky") - assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (repoType) { - GIT -> "/booky.org" - DOCUMENT -> repo.url + documentTreeSegment + "booky.org" - else -> { repo.url + "/booky.org" } - } - assertEquals( - expectedRookUri, - bookView.syncedTo!!.uri.toString() - ) - } - - @Test - fun testRenameBookNewSubfolderSameLeafName() { - setupSyncRepo(repoType) - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/b") - assertTrue(dataRepository.getBookView("b/b")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - val bookView = dataRepository.getBookView("b/b") - assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (repoType) { - GIT -> "/b/b.org" - DOCUMENT -> repo.url + documentTreeSegment + "b%2Fb.org" - else -> { repo.url + "/b/b.org" } - } - assertEquals( - expectedRookUri, - bookView.syncedTo!!.uri.toString() - ) - } - - @Test - fun testRenameBookNewSubfolderAndLeafName() { - setupSyncRepo(repoType) - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "b/c") - assertTrue(dataRepository.getBookView("b/c")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - val bookView = dataRepository.getBookView("b/c") - assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (repoType) { - GIT -> "/b/c.org" - DOCUMENT -> repo.url + documentTreeSegment + "b%2Fc.org" - else -> { repo.url + "/b/c.org" } - } - assertEquals( - expectedRookUri, - bookView.syncedTo!!.uri.toString() - ) - } - - @Test - fun testRenameBookSameSubfolderNewLeafName() { - setupSyncRepo(repoType) - testUtils.setupBook("a/b", "") - testUtils.sync() - dataRepository.renameBook(dataRepository.getBookView("a/b")!!, "a/c") - assertTrue(dataRepository.getBookView("a/c")!!.book.lastAction!!.message.contains("Renamed from ")) - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - val bookView = dataRepository.getBookView("a/c") - assertEquals(bookView!!.book.syncStatus, BookSyncStatus.NO_CHANGE.toString()) - val expectedRookUri = when (repoType) { - GIT -> "/a/c.org" - DOCUMENT -> repo.url + documentTreeSegment + "a%2Fc.org" - else -> { repo.url + "/a/c.org" } - } - assertEquals( - expectedRookUri, - bookView.syncedTo!!.uri.toString() - ) - } - - @Test - @Throws(FileNotFoundException::class) - fun testSyncWithDirectoryWithSpaceInName() { - Assume.assumeTrue(repoType != GIT) // Git repo URLs will never contain a space - topDirName = "space separated" - if (repoType == DOCUMENT) { - setupDocumentRepo(topDirName) - } else { - setupSyncRepo(repoType) - } - val tmpFile = dataRepository.getTempBookFile() - try { - MiscUtils.writeStringToFile("content", tmpFile) - syncRepo.storeBook(tmpFile, "notebook.org") - } finally { - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, dataRepository.getBooks().size.toLong()) - if (repoType == DOCUMENT) { - assertTrue(syncRepo.uri.toString().contains("space%20separated")) - } else { - assertTrue(syncRepo.uri.toString().contains("space separated")) - } - } - - @Test - fun testGetBooks_singleOrgFile() { - // N.B. Expected book name contains space - val remoteBookFile = File(serverRootDir.absolutePath + "/book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val books = syncRepo.books - assertEquals(1, books.size) - assertEquals(webDavServerUrl + "book%20one.org", books[0].uri.toString()) - val retrievedBookFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook("book one.org", retrievedBookFile) - // Assert that the two files are identical - assertEquals(remoteBookFile.readText(), retrievedBookFile.readText()) - // Assert reported file name - val rookFileName = BookName.getFileName(syncRepo.uri, books[0].uri) - assertEquals("book one.org", rookFileName) - } - private fun setupSyncRepo(repoType: RepoType, ignoreRules: String? = null) { when (repoType) { - GIT -> setupGitRepo() - MOCK -> TODO() - DROPBOX -> setupDropboxRepo() - DIRECTORY -> TODO() - DOCUMENT -> setupDocumentRepo() - WEBDAV -> setupWebdavRepo() + RepoType.WEBDAV -> TODO() + RepoType.MOCK -> TODO() + RepoType.DROPBOX -> TODO() + RepoType.DIRECTORY -> TODO() + RepoType.DOCUMENT -> TODO() + RepoType.GIT -> TODO() } if (ignoreRules != null) { val tmpFile = File.createTempFile("orgzly-test", null) @@ -688,139 +62,4 @@ class SyncRepoTest(private val repoType: RepoType) : OrgzlyTest() { tmpFile.delete() } } - - private fun setupWebdavRepo() { - serverRootDir = java.nio.file.Files.createTempDirectory("orgzly-webdav-test-").toFile() - localServer = MiltonWebDAVFileServer(serverRootDir) - localServer.userCredentials["user"] = "secret" - localServer.start() - val repo = Repo(0, WEBDAV, webDavServerUrl) - val repoPropsMap = HashMap() - repoPropsMap[WebdavRepo.USERNAME_PREF_KEY] = "user" - repoPropsMap[WebdavRepo.PASSWORD_PREF_KEY] = "secret" - val repoWithProps = RepoWithProps(repo, repoPropsMap) - syncRepo = WebdavRepo.getInstance(repoWithProps) - assertEquals(webDavServerUrl, repo.url) - tmpFile = kotlin.io.path.createTempFile().toFile() - } - - private fun tearDownWebdavRepo() { - tmpFile.delete() - if (this::localServer.isInitialized) { - localServer.stop() - } - if (this::serverRootDir.isInitialized) { - serverRootDir.deleteRecursively() - } - } - - private fun setupDropboxRepo() { - testUtils.dropboxTestPreflight() - repo = testUtils.setupRepo(DROPBOX, "dropbox:/$permanentRepoTestDir/$topDirName") - syncRepo = testUtils.repoInstance(DROPBOX, repo.url, repo.id) - } - - private fun tearDownDropboxRepo() { - val dropboxRepo = syncRepo as DropboxRepo - try { - dropboxRepo.deleteDirectory(syncRepo.uri) - } catch (_: IOException) {} - } - - private fun setupDocumentRepo(extraDir: String? = null) { - documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { - "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$permanentRepoTestDir%2F" - } else { - "/document/primary%3A$permanentRepoTestDir%2F" - } - var treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { - "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$permanentRepoTestDir" - } else { - "content://com.android.externalstorage.documents/tree/primary%3A$permanentRepoTestDir" - } - if (extraDir != null) { - treeDocumentFileUrl = "$treeDocumentFileUrl%2F" + Uri.encode(extraDir) - } - val repoDirDocumentFile = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri()) - repo = if (repoDirDocumentFile?.exists() == false) { - if (extraDir != null) { - setupDocumentRepoInUi(extraDir) - } else { - setupDocumentRepoInUi(permanentRepoTestDir) - } - dataRepository.getRepos()[0] - } else { - testUtils.setupRepo(DOCUMENT, treeDocumentFileUrl) - } - syncRepo = testUtils.repoInstance(DOCUMENT, repo.url, repo.id) - assertEquals(treeDocumentFileUrl, repo.url) - } - - /** - * Note that this solution only works the first time the tests are run on any given virtual - * device. On the second run, the file picker will start in a different folder, resulting in - * a different repo URL, making some tests fail. If you are running locally, you must work - * around this by wiping the device's data between test suite runs. - */ - private fun setupDocumentRepoInUi(repoDirName: String) { - ActivityScenario.launch(ReposActivity::class.java).use { - Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) - .perform(ViewActions.click()) - Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) - .perform(ViewActions.click()) - SystemClock.sleep(500) - // In Android file browser (Espresso cannot be used): - val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - if (Build.VERSION.SDK_INT < 30) { - // Older system file picker UI - mDevice.findObject(UiSelector().description("More options")).click() - SystemClock.sleep(300) - mDevice.findObject(UiSelector().text("New folder")).click() - SystemClock.sleep(500) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - } else { - mDevice.findObject(UiSelector().description("New folder")).click() - SystemClock.sleep(500) - mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName - mDevice.findObject(UiSelector().text("OK")).click() - mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() - mDevice.findObject(UiSelector().text("ALLOW")).click() - } - // Back in Orgzly: - SystemClock.sleep(500) - Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) - Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) - } - } - - private fun tearDownDocumentRepo() { - val repoDirectory = DocumentFile.fromTreeUri(context, repo.url.toUri()) - for (file in repoDirectory!!.listFiles()) { - file.delete() - } - } - - private fun setupGitRepo() { - gitBareRepoPath = createTempDirectory() - Git.init().setBare(true).setDirectory(gitBareRepoPath.toFile()).call() - AppPreferences.gitIsEnabled(context, true) - repo = testUtils.setupRepo(GIT, gitBareRepoPath.toFile().toUri().toString()) - val repoPreferences = RepoPreferences(context, repo.id, repo.url.toUri()) - val gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) - gitWorkingTree = File(gitPreferences.repositoryFilepath()) - gitWorkingTree.mkdirs() - val git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) - gitFileSynchronizer = GitFileSynchronizer(git, gitPreferences) - syncRepo = dataRepository.getRepoInstance(repo.id, GIT, repo.url) - } - - private fun tearDownGitRepo() { - testUtils.deleteRepo(repo.url) - gitWorkingTree.deleteRecursively() - gitBareRepoPath.toFile()!!.deleteRecursively() - } -} +} \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/WebdavTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavTest.kt similarity index 83% rename from app/src/test/java/com/orgzly/android/WebdavTest.kt rename to app/src/test/java/com/orgzly/android/repos/WebdavTest.kt index a121f5043..734a128ba 100644 --- a/app/src/test/java/com/orgzly/android/WebdavTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/WebdavTest.kt @@ -6,6 +6,7 @@ import com.orgzly.android.repos.RepoIgnoreNode import com.orgzly.android.repos.RepoType import com.orgzly.android.repos.RepoWithProps import com.orgzly.android.repos.SyncRepo +import com.orgzly.android.repos.SyncRepoTests import com.orgzly.android.repos.WebdavRepo import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY @@ -22,9 +23,9 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) -class WebdavTest { +class WebdavTest : SyncRepoTests { - private val serverUrl = "http://localhost:8081/" + private val serverUrl = "http://localhost:8081" private lateinit var serverRootDir: File private lateinit var localServer: MiltonWebDAVFileServer @@ -59,38 +60,13 @@ class WebdavTest { } @Test - fun testGetBooks_singleOrgFile() { - // N.B. Expected book name contains space - val remoteBookFile = File(serverRootDir.absolutePath + "/book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val books = syncRepo.books - assertEquals(1, books.size) - assertEquals(serverUrl + "book%20one.org", books[0].uri.toString()) - val retrievedBookFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook("book one.org", retrievedBookFile) - // Assert that the two files are identical - assertEquals(remoteBookFile.readText(), retrievedBookFile.readText()) - // Assert reported file name - val rookFileName = BookName.getFileName(syncRepo.uri, books[0].uri) - assertEquals("book one.org", rookFileName) + override fun testGetBooks_singleOrgFile() { + SyncRepoTests.testGetBooks_singleOrgFile(serverRootDir, syncRepo) } @Test - fun testGetBooks_singleFileInSubfolder() { - val subFolder = File(serverRootDir.absolutePath + "/folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath + "/book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val books = syncRepo.books - assertEquals(1, books.size) - assertEquals(serverUrl + "folder/book%20one.org", books[0].uri.toString()) - // Assert reported file name - val rookFileName = BookName.getFileName(syncRepo.uri, books[0].uri) - assertEquals("folder/book one.org", rookFileName) - // Assert that the two files are identical - val retrievedBookFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook(rookFileName, retrievedBookFile) - assertEquals(remoteBookFile.readText(), retrievedBookFile.readText()) + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTests.testGetBooks_singleFileInSubfolder(serverRootDir, syncRepo) } @Test diff --git a/settings.gradle b/settings.gradle index 2a35ddd25..6af49b8d1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,3 +34,4 @@ if (gradle.ext.appProperties.org_java_directory?.trim()) { } include ':app' +include ':shared-test' diff --git a/shared-test/.gitignore b/shared-test/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/shared-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/shared-test/build.gradle b/shared-test/build.gradle new file mode 100644 index 000000000..cce028275 --- /dev/null +++ b/shared-test/build.gradle @@ -0,0 +1,45 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' + +android { + namespace = "com.orgzly.shared.test" + compileSdk 33 + minSdk 21 + + defaultConfig {} + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + + } + } + flavorDimensions "store" + productFlavors { + premium { + dimension "store" + } + + fdroid { + dimension "store" + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = 17 + } +} + +dependencies { + implementation project(":app") + implementation "junit:junit:$versions.junit" + implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "org.eclipse.jgit:org.eclipse.jgit:$versions.jgit" +} diff --git a/shared-test/proguard-rules.pro b/shared-test/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/shared-test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/shared-test/src/main/AndroidManifest.xml b/shared-test/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a522a4c23 --- /dev/null +++ b/shared-test/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt new file mode 100644 index 000000000..ae1cd8ab6 --- /dev/null +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt @@ -0,0 +1,82 @@ +package com.orgzly.android.repos + +import android.annotation.SuppressLint +import androidx.documentfile.provider.DocumentFile +import com.orgzly.android.BookName +import com.orgzly.android.util.MiscUtils +import org.junit.Assert.assertEquals +import java.io.File + +@SuppressLint("NewApi") +interface SyncRepoTests { + + fun testGetBooks_singleOrgFile() + fun testGetBooks_singleFileInSubfolder() + + companion object { + + const val repoDirName = "orgzly-android-test" + private const val treeDocumentFileExtraSegment = "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" + + fun testGetBooks_singleOrgFile(remoteDir: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "\n\n...\n\n" + val expectedRookUri: String + when (remoteDir) { + // N.B. Expected book name contains space + is File -> { + MiscUtils.writeStringToFile(testBookContent, File(remoteDir.absolutePath + "/Book one.org")) + expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" + } + is DocumentFile -> { + MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", remoteDir.uri) + expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Book%20one.org" + } + else -> expectedRookUri = "" + } + + // When + val books = syncRepo.books + val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook("Book one.org", retrieveBookDestinationFile) + + // Then + assertEquals(1, books.size) + assertEquals(expectedRookUri, books[0].uri.toString()) + assertEquals(testBookContent, retrieveBookDestinationFile.readText()) + assertEquals("Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) + } + + fun testGetBooks_singleFileInSubfolder(remoteDir: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "\n\n...\n\n" + val expectedRookUri: String + when (remoteDir) { + is File -> { + val subFolder = File(remoteDir.absolutePath + "/Folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath + "/Book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + expectedRookUri = syncRepo.uri.toString() + "/Folder/Book%20one.org" + } + is DocumentFile -> { + val subFolder = remoteDir.createDirectory("Folder") + MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", subFolder!!.uri) + expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Folder%2FBook%20one.org" + } + else -> expectedRookUri = "" + } + + // When + val books = syncRepo.books + val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook("Folder/Book one.org", retrieveBookDestinationFile) + + // Then + assertEquals(1, books.size) + assertEquals(expectedRookUri, books[0].uri.toString()) + assertEquals("Folder/Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) + assertEquals(testBookContent, retrieveBookDestinationFile.readText()) + } + } +} \ No newline at end of file From 54adb44aa4aac73f13b667fdb8b8d47c2d915ccb Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 12 Aug 2024 01:27:04 +0200 Subject: [PATCH 63/73] Shared library coming along well --- .../java/com/orgzly/android/TestUtils.java | 6 - .../orgzly/android/repos/DocumentRepoTest.kt | 13 +- .../com/orgzly/android/repos/SyncRepoTest.kt | 65 ------ .../com/orgzly/android/data/DataRepository.kt | 3 +- .../orgzly/android/repos/DropboxRepoTest.kt | 69 +++++++ .../com/orgzly/android/repos/GitRepoTest.kt | 65 ++++++ .../{WebdavTest.kt => WebdavRepoTest.kt} | 19 +- .../com/orgzly/android/repos/SyncRepoTest.kt | 194 ++++++++++++++++++ .../com/orgzly/android/repos/SyncRepoTests.kt | 82 -------- 9 files changed, 345 insertions(+), 171 deletions(-) delete mode 100644 app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt create mode 100644 app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt create mode 100644 app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt rename app/src/test/java/com/orgzly/android/repos/{WebdavTest.kt => WebdavRepoTest.kt} (92%) create mode 100644 shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt delete mode 100644 shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt diff --git a/app/src/androidTest/java/com/orgzly/android/TestUtils.java b/app/src/androidTest/java/com/orgzly/android/TestUtils.java index 5dac0138b..0da43deb3 100644 --- a/app/src/androidTest/java/com/orgzly/android/TestUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/TestUtils.java @@ -189,10 +189,4 @@ public void dropboxTestPreflight() throws JSONException { mockSerializedDbxCredential.put("app_key", BuildConfig.DROPBOX_APP_KEY); AppPreferences.dropboxSerializedCredential(App.getAppContext(), mockSerializedDbxCredential.toString()); } - - public void webdavTestPreflight() { - Assume.assumeTrue(BuildConfig.WEBDAV_REPO_URL.length() > 0); - Assume.assumeTrue(BuildConfig.WEBDAV_USERNAME.length() > 0); - Assume.assumeTrue(BuildConfig.WEBDAV_PASSWORD.length() > 0); - } } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt index 40be239e2..ad5573d77 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -23,7 +23,7 @@ import org.junit.Assert import org.junit.Before import org.junit.Test -class DocumentRepoTest : SyncRepoTests, OrgzlyTest() { +class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { private lateinit var documentTreeSegment: String private lateinit var repo: Repo @@ -46,16 +46,21 @@ class DocumentRepoTest : SyncRepoTests, OrgzlyTest() { @Test override fun testGetBooks_singleOrgFile() { - SyncRepoTests.testGetBooks_singleOrgFile(repoDirectory, syncRepo) + SyncRepoTest.testGetBooks_singleOrgFile(repoDirectory, syncRepo) } @Test override fun testGetBooks_singleFileInSubfolder() { - SyncRepoTests.testGetBooks_singleFileInSubfolder(repoDirectory, syncRepo) + SyncRepoTest.testGetBooks_singleFileInSubfolder(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(repoDirectory, syncRepo) } private fun setupDocumentRepo(extraDir: String? = null) { - val repoDirName = SyncRepoTests.repoDirName + val repoDirName = SyncRepoTest.repoDirName documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" } else { diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt deleted file mode 100644 index d785616f4..000000000 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncRepoTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.orgzly.android.repos - -import com.orgzly.android.OrgzlyTest -import com.orgzly.android.db.entity.Repo -import com.orgzly.android.util.MiscUtils -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import java.io.File -import java.util.UUID - -@RunWith(value = Parameterized::class) -class SyncRepoTest(private val repoType: RepoType) : OrgzlyTest() { - - private lateinit var repo: Repo - private lateinit var syncRepo: SyncRepo - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{0}") - fun data(): Array { - return arrayOf( -// GIT, -// DOCUMENT, -// DROPBOX, - RepoType.WEBDAV, - ) - } - - /* For creating a unique directory per test suite instance for tests which interact with - the cloud (Dropbox), to avoid collisions when they are run simultaneously on - different devices. */ - val RANDOM_UUID = UUID.randomUUID().toString() - } - - override fun tearDown() { - super.tearDown() - if (this::repo.isInitialized) { - when (repo.type) { - RepoType.WEBDAV -> TODO() - RepoType.MOCK -> TODO() - RepoType.DROPBOX -> TODO() - RepoType.DIRECTORY -> TODO() - RepoType.DOCUMENT -> TODO() - RepoType.GIT -> TODO() - } - } - } - - private fun setupSyncRepo(repoType: RepoType, ignoreRules: String? = null) { - when (repoType) { - RepoType.WEBDAV -> TODO() - RepoType.MOCK -> TODO() - RepoType.DROPBOX -> TODO() - RepoType.DIRECTORY -> TODO() - RepoType.DOCUMENT -> TODO() - RepoType.GIT -> TODO() - } - if (ignoreRules != null) { - val tmpFile = File.createTempFile("orgzly-test", null) - MiscUtils.writeStringToFile(ignoreRules, tmpFile) - syncRepo.storeBook(tmpFile, RepoIgnoreNode.IGNORE_FILE) - tmpFile.delete() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index 0861b2007..7792231f6 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -2356,7 +2356,8 @@ class DataRepository @Inject constructor( val repoWithProps = RepoWithProps(Repo(id, type, url), props) - return repoFactory.getInstance(repoWithProps) + return repoFactory. + getInstance(repoWithProps) } fun getRepoPropsMap(id: Long): Map { diff --git a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt new file mode 100644 index 000000000..90a6c4cc6 --- /dev/null +++ b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt @@ -0,0 +1,69 @@ +package com.orgzly.android.repos + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.orgzly.BuildConfig +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.prefs.AppPreferences +import org.json.JSONObject +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class DropboxRepoTest : SyncRepoTest { + + private lateinit var syncRepo: SyncRepo + private lateinit var client: DropboxClient + + companion object { + val RANDOM_UUID = UUID.randomUUID().toString() + } + + @Before + fun setup() { + assumeTrue(BuildConfig.DROPBOX_APP_KEY.length > 0) + assumeTrue(BuildConfig.DROPBOX_REFRESH_TOKEN.length > 0) + val mockSerializedDbxCredential = JSONObject() + mockSerializedDbxCredential.put("access_token", "dummy") + mockSerializedDbxCredential.put("expires_at", System.currentTimeMillis()) + mockSerializedDbxCredential.put("refresh_token", BuildConfig.DROPBOX_REFRESH_TOKEN) + mockSerializedDbxCredential.put("app_key", BuildConfig.DROPBOX_APP_KEY) + AppPreferences.dropboxSerializedCredential( + ApplicationProvider.getApplicationContext(), + mockSerializedDbxCredential.toString() + ) + val repo = Repo(0, RepoType.DROPBOX, "dropbox:/${SyncRepoTest.repoDirName}/$RANDOM_UUID") + val repoPropsMap = HashMap() + val repoWithProps = RepoWithProps(repo, repoPropsMap) + syncRepo = DropboxRepo(repoWithProps, ApplicationProvider.getApplicationContext()) + client = DropboxClient(ApplicationProvider.getApplicationContext(), repo.id) + } + + @After + fun tearDown() { + val dropboxRepo = syncRepo as DropboxRepo + try { + dropboxRepo.deleteDirectory(syncRepo.uri) + } catch (_: IOException) {} + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTest.testGetBooks_singleOrgFile(client, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTest.testGetBooks_singleFileInSubfolder(client, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(client, syncRepo) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt new file mode 100644 index 000000000..f02f99441 --- /dev/null +++ b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt @@ -0,0 +1,65 @@ +package com.orgzly.android.repos + +import android.content.Context +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.git.GitFileSynchronizer +import com.orgzly.android.git.GitPreferencesFromRepoPrefs +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.prefs.RepoPreferences +import org.eclipse.jgit.api.Git +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.io.path.createTempDirectory + +@RunWith(AndroidJUnit4::class) +class GitRepoTest : SyncRepoTest { + + private lateinit var gitWorkingTree: File + private lateinit var bareRepoDir: File + private lateinit var gitFileSynchronizer: GitFileSynchronizer + private lateinit var syncRepo: SyncRepo + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setup() { + bareRepoDir = createTempDirectory().toFile() + Git.init().setBare(true).setDirectory(bareRepoDir).call() + AppPreferences.gitIsEnabled(context, true) + val repo = Repo(0, RepoType.GIT, "file://$bareRepoDir") + val repoPreferences = RepoPreferences(context, repo.id, repo.url.toUri()) + val gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) + gitWorkingTree = File(gitPreferences.repositoryFilepath()) + gitWorkingTree.mkdirs() + val git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) + gitFileSynchronizer = GitFileSynchronizer(git, gitPreferences) + val repoPropsMap = HashMap() + val repoWithProps = RepoWithProps(repo, repoPropsMap) + syncRepo = GitRepo.getInstance(repoWithProps, context) + } + + @After + fun tearDown() { + bareRepoDir.deleteRecursively() + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTest.testGetBooks_singleOrgFile(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTest.testGetBooks_singleFileInSubfolder(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(gitWorkingTree, syncRepo) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/WebdavTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt similarity index 92% rename from app/src/test/java/com/orgzly/android/repos/WebdavTest.kt rename to app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt index 734a128ba..eb2517e20 100644 --- a/app/src/test/java/com/orgzly/android/repos/WebdavTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -6,7 +6,7 @@ import com.orgzly.android.repos.RepoIgnoreNode import com.orgzly.android.repos.RepoType import com.orgzly.android.repos.RepoWithProps import com.orgzly.android.repos.SyncRepo -import com.orgzly.android.repos.SyncRepoTests +import com.orgzly.android.repos.SyncRepoTest import com.orgzly.android.repos.WebdavRepo import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY @@ -23,7 +23,7 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) -class WebdavTest : SyncRepoTests { +class WebdavRepoTest : SyncRepoTest { private val serverUrl = "http://localhost:8081" @@ -61,24 +61,17 @@ class WebdavTest : SyncRepoTests { @Test override fun testGetBooks_singleOrgFile() { - SyncRepoTests.testGetBooks_singleOrgFile(serverRootDir, syncRepo) + SyncRepoTest.testGetBooks_singleOrgFile(serverRootDir, syncRepo) } @Test override fun testGetBooks_singleFileInSubfolder() { - SyncRepoTests.testGetBooks_singleFileInSubfolder(serverRootDir, syncRepo) + SyncRepoTest.testGetBooks_singleFileInSubfolder(serverRootDir, syncRepo) } @Test - fun testGetBooks_allFilesAreIgnored() { - val subFolder = File(serverRootDir.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val ignoreFile = File(serverRootDir.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile("*\n", ignoreFile) - val books = syncRepo.books - assertEquals(0, books.size) + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(serverRootDir, syncRepo) } @Test diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt new file mode 100644 index 000000000..77fbaa084 --- /dev/null +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -0,0 +1,194 @@ +package com.orgzly.android.repos + +import android.annotation.SuppressLint +import androidx.documentfile.provider.DocumentFile +import com.orgzly.android.BookName +import com.orgzly.android.util.MiscUtils +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.junit.Assert.assertEquals +import java.io.File + +@SuppressLint("NewApi") +interface SyncRepoTest { + + fun testGetBooks_singleOrgFile() + fun testGetBooks_singleFileInSubfolder() + fun testGetBooks_allFilesAreIgnored() + fun testGetBooks_specificFileInSubfolderIsIgnored() + + companion object { + + const val repoDirName = "orgzly-android-test" + private const val treeDocumentFileExtraSegment = "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" + + fun testGetBooks_singleOrgFile(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "\n\n...\n\n" + var expectedRookUri = "" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + MiscUtils.writeStringToFile( + testBookContent, + File(repoManipulationPoint.absolutePath + "/Book one.org") + ) + expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" + } + is GitRepo -> { + repoManipulationPoint as File + expectedRookUri = "/Book one.org" + MiscUtils.writeStringToFile( + testBookContent, + File(repoManipulationPoint.absolutePath + expectedRookUri) + ) + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern("Book one.org").call() + git.commit().setMessage("").call() + git.push().call() + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", repoManipulationPoint.uri) + expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Book%20one.org" + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" + val tmpFile = File.createTempFile("orgzly-test-", "") + MiscUtils.writeStringToFile(testBookContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, "Book one.org") + tmpFile.delete() + } + } + + // When + val books = syncRepo.books + val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook("Book one.org", retrieveBookDestinationFile) + + // Then + assertEquals(1, books.size) + assertEquals(expectedRookUri, books[0].uri.toString()) + assertEquals(testBookContent, retrieveBookDestinationFile.readText()) + assertEquals("Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) + } + + fun testGetBooks_singleFileInSubfolder(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "\n\n...\n\n" + var expectedRookUri = "" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath + "/Folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath + "/Book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + expectedRookUri = syncRepo.uri.toString() + "/Folder/Book%20one.org" + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.createDirectory("Folder") + MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", subFolder!!.uri) + expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Folder%2FBook%20one.org" + } + is GitRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath + "/Folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath + "/Book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + expectedRookUri = "/Folder/Book one.org" + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern("Folder/Book one.org").call() + git.commit().setMessage("").call() + git.push().call() + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + expectedRookUri = syncRepo.uri.toString() + "/Folder/Book%20one.org" + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile(testBookContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, "Folder/Book one.org") + tmpFile.delete() + } + } + + // When + val books = syncRepo.books + val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook("Folder/Book one.org", retrieveBookDestinationFile) + + // Then + assertEquals(1, books.size) + assertEquals(expectedRookUri, books[0].uri.toString()) + assertEquals("Folder/Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) + assertEquals(testBookContent, retrieveBookDestinationFile.readText()) + } + + fun testGetBooks_allFilesAreIgnored(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "..." + val ignoreFileContent = "*\n" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.createDirectory("folder") + MiscUtils.writeStringToDocumentFile(testBookContent, "book one.org", subFolder!!.uri) + MiscUtils.writeStringToDocumentFile(ignoreFileContent, RepoIgnoreNode.IGNORE_FILE, repoManipulationPoint.uri) + } + is GitRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern("folder/book one.org").call() + git.add().addFilepattern(".orgzlyignore").call() + git.commit().setMessage("").call() + git.push().call() + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile(testBookContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, "folder/book one.org") + MiscUtils.writeStringToFile(ignoreFileContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, ".orgzlyignore") + } + } + // When + val books = syncRepo.books + // Then + assertEquals(0, books.size) + } + } +} \ No newline at end of file diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt deleted file mode 100644 index ae1cd8ab6..000000000 --- a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTests.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.orgzly.android.repos - -import android.annotation.SuppressLint -import androidx.documentfile.provider.DocumentFile -import com.orgzly.android.BookName -import com.orgzly.android.util.MiscUtils -import org.junit.Assert.assertEquals -import java.io.File - -@SuppressLint("NewApi") -interface SyncRepoTests { - - fun testGetBooks_singleOrgFile() - fun testGetBooks_singleFileInSubfolder() - - companion object { - - const val repoDirName = "orgzly-android-test" - private const val treeDocumentFileExtraSegment = "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" - - fun testGetBooks_singleOrgFile(remoteDir: Any, syncRepo: SyncRepo) { - // Given - val testBookContent = "\n\n...\n\n" - val expectedRookUri: String - when (remoteDir) { - // N.B. Expected book name contains space - is File -> { - MiscUtils.writeStringToFile(testBookContent, File(remoteDir.absolutePath + "/Book one.org")) - expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" - } - is DocumentFile -> { - MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", remoteDir.uri) - expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Book%20one.org" - } - else -> expectedRookUri = "" - } - - // When - val books = syncRepo.books - val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook("Book one.org", retrieveBookDestinationFile) - - // Then - assertEquals(1, books.size) - assertEquals(expectedRookUri, books[0].uri.toString()) - assertEquals(testBookContent, retrieveBookDestinationFile.readText()) - assertEquals("Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) - } - - fun testGetBooks_singleFileInSubfolder(remoteDir: Any, syncRepo: SyncRepo) { - // Given - val testBookContent = "\n\n...\n\n" - val expectedRookUri: String - when (remoteDir) { - is File -> { - val subFolder = File(remoteDir.absolutePath + "/Folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath + "/Book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - expectedRookUri = syncRepo.uri.toString() + "/Folder/Book%20one.org" - } - is DocumentFile -> { - val subFolder = remoteDir.createDirectory("Folder") - MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", subFolder!!.uri) - expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Folder%2FBook%20one.org" - } - else -> expectedRookUri = "" - } - - // When - val books = syncRepo.books - val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook("Folder/Book one.org", retrieveBookDestinationFile) - - // Then - assertEquals(1, books.size) - assertEquals(expectedRookUri, books[0].uri.toString()) - assertEquals("Folder/Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) - assertEquals(testBookContent, retrieveBookDestinationFile.readText()) - } - } -} \ No newline at end of file From 6b2a6e3cccb5807046a2b3eade89da3bc52f3941 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 12 Aug 2024 11:06:41 +0200 Subject: [PATCH 64/73] Making progress on shared test code --- .../orgzly/android/repos/DocumentRepoTest.kt | 10 ++ .../orgzly/android/repos/DropboxRepoTest.kt | 14 ++- .../com/orgzly/android/repos/GitRepoTest.kt | 10 ++ .../orgzly/android/repos/WebdavRepoTest.kt | 23 +--- .../com/orgzly/android/repos/SyncRepoTest.kt | 110 ++++++++++++++++++ 5 files changed, 146 insertions(+), 21 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt index ad5573d77..70fa9ff74 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -59,6 +59,16 @@ class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { SyncRepoTest.testGetBooks_allFilesAreIgnored(repoDirectory, syncRepo) } + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(repoDirectory, syncRepo) + } + private fun setupDocumentRepo(extraDir: String? = null) { val repoDirName = SyncRepoTest.repoDirName documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { diff --git a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt index 90a6c4cc6..2a892714f 100644 --- a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt @@ -26,8 +26,8 @@ class DropboxRepoTest : SyncRepoTest { @Before fun setup() { - assumeTrue(BuildConfig.DROPBOX_APP_KEY.length > 0) - assumeTrue(BuildConfig.DROPBOX_REFRESH_TOKEN.length > 0) + assumeTrue(BuildConfig.DROPBOX_APP_KEY.isNotEmpty()) + assumeTrue(BuildConfig.DROPBOX_REFRESH_TOKEN.isNotEmpty()) val mockSerializedDbxCredential = JSONObject() mockSerializedDbxCredential.put("access_token", "dummy") mockSerializedDbxCredential.put("expires_at", System.currentTimeMillis()) @@ -66,4 +66,14 @@ class DropboxRepoTest : SyncRepoTest { override fun testGetBooks_allFilesAreIgnored() { SyncRepoTest.testGetBooks_allFilesAreIgnored(client, syncRepo) } + + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(client, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(client, syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt index f02f99441..ddb29200c 100644 --- a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt @@ -62,4 +62,14 @@ class GitRepoTest : SyncRepoTest { override fun testGetBooks_allFilesAreIgnored() { SyncRepoTest.testGetBooks_allFilesAreIgnored(gitWorkingTree, syncRepo) } + + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(gitWorkingTree, syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt index eb2517e20..bc7a11861 100644 --- a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -2,7 +2,6 @@ package com.orgzly.android import androidx.test.ext.junit.runners.AndroidJUnit4 import com.orgzly.android.db.entity.Repo -import com.orgzly.android.repos.RepoIgnoreNode import com.orgzly.android.repos.RepoType import com.orgzly.android.repos.RepoWithProps import com.orgzly.android.repos.SyncRepo @@ -75,27 +74,13 @@ class WebdavRepoTest : SyncRepoTest { } @Test - fun testGetBooks_specificFileInSubfolderIsIgnored() { - val subFolder = File(serverRootDir.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val ignoreFile = File(serverRootDir.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile("folder/book one.org\n", ignoreFile) - val books = syncRepo.books - assertEquals(0, books.size) + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(serverRootDir, syncRepo) } @Test - fun testGetBooks_specificFileIsUnignored() { - val subFolder = File(serverRootDir.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val ignoreFile = File(serverRootDir.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile("*\n!folder/book one.org", ignoreFile) - val books = syncRepo.books - assertEquals(1, books.size) + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(serverRootDir, syncRepo) } @Test diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt index 77fbaa084..7eb2420ce 100644 --- a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -16,6 +16,7 @@ interface SyncRepoTest { fun testGetBooks_singleFileInSubfolder() fun testGetBooks_allFilesAreIgnored() fun testGetBooks_specificFileInSubfolderIsIgnored() + fun testGetBooks_specificFileIsUnignored() companion object { @@ -190,5 +191,114 @@ interface SyncRepoTest { // Then assertEquals(0, books.size) } + + fun testGetBooks_specificFileInSubfolderIsIgnored(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "..." + val ignoreFileContent = "folder/book one.org\n" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.createDirectory("folder") + MiscUtils.writeStringToDocumentFile(testBookContent, "book one.org", subFolder!!.uri) + MiscUtils.writeStringToDocumentFile(ignoreFileContent, RepoIgnoreNode.IGNORE_FILE, repoManipulationPoint.uri) + } + is GitRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern("folder/book one.org").call() + git.add().addFilepattern(".orgzlyignore").call() + git.commit().setMessage("").call() + git.push().call() + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile(testBookContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, "folder/book one.org") + MiscUtils.writeStringToFile(ignoreFileContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, ".orgzlyignore") + } + } + // When + val books = syncRepo.books + // Then + assertEquals(0, books.size) + } + fun testGetBooks_specificFileIsUnignored(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookRelativePath = "folder/book one.org" + val testBookContent = "..." + val ignoreFileContent = "folder/**\n!$testBookRelativePath\n" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.createDirectory("folder") + MiscUtils.writeStringToDocumentFile(testBookContent, "book one.org", subFolder!!.uri) + MiscUtils.writeStringToDocumentFile(ignoreFileContent, RepoIgnoreNode.IGNORE_FILE, repoManipulationPoint.uri) + } + is GitRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint.absolutePath, "folder") + subFolder.mkdir() + val remoteBookFile = File(subFolder.absolutePath, "book one.org") + MiscUtils.writeStringToFile(testBookContent, remoteBookFile) + val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) + MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern(testBookRelativePath).call() + git.add().addFilepattern(RepoIgnoreNode.IGNORE_FILE).call() + git.commit().setMessage("").call() + git.push().call() + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile(testBookContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, testBookRelativePath) + MiscUtils.writeStringToFile(ignoreFileContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, RepoIgnoreNode.IGNORE_FILE) + tmpFile.delete() + } + } + // When + val books = syncRepo.books + // Then + assertEquals(1, books.size) + } } } \ No newline at end of file From e26e0902a085ec0dfbf0325d49d90ad780dcce90 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 12 Aug 2024 12:51:43 +0200 Subject: [PATCH 65/73] Making progress on shared test code --- .../orgzly/android/repos/DocumentRepoTest.kt | 30 +++ .../com/orgzly/android/util/MiscUtils.java | 16 ++ .../orgzly/android/repos/DropboxRepoTest.kt | 30 +++ .../com/orgzly/android/repos/GitRepoTest.kt | 31 +++ .../orgzly/android/repos/WebdavRepoTest.kt | 57 +---- .../com/orgzly/android/repos/SyncRepoTest.kt | 220 +++++++++++++++++- 6 files changed, 330 insertions(+), 54 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt index 70fa9ff74..5eda0a9ec 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -69,6 +69,36 @@ class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { SyncRepoTest.testGetBooks_specificFileIsUnignored(repoDirectory, syncRepo) } + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(repoDirectory, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(repoDirectory, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(repoDirectory, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } + private fun setupDocumentRepo(extraDir: String? = null) { val repoDirName = SyncRepoTest.repoDirName documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { diff --git a/app/src/main/java/com/orgzly/android/util/MiscUtils.java b/app/src/main/java/com/orgzly/android/util/MiscUtils.java index 8546cb7d8..0c5f5c539 100644 --- a/app/src/main/java/com/orgzly/android/util/MiscUtils.java +++ b/app/src/main/java/com/orgzly/android/util/MiscUtils.java @@ -66,6 +66,22 @@ public static String readStringFromFile(File file) throws IOException { return fileData.toString(); } + public static String readStringFromDocumentFile(DocumentFile file) throws IOException { + ContentResolver contentResolver = App.getAppContext().getContentResolver(); + StringBuilder fileData = new StringBuilder(); + try (InputStream inputStream = contentResolver.openInputStream(file.getUri())) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + char[] buf = new char[1024]; + int numRead; + while ((numRead = reader.read(buf)) != -1) { + String readData = String.valueOf(buf, 0, numRead); + fileData.append(readData); + } + } + } + return fileData.toString(); + } + public static void writeStringToFile(String str, File file) throws FileNotFoundException { try (PrintWriter out = new PrintWriter(file)) { out.write(str); diff --git a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt index 2a892714f..b11f20c33 100644 --- a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt @@ -76,4 +76,34 @@ class DropboxRepoTest : SyncRepoTest { override fun testGetBooks_specificFileIsUnignored() { SyncRepoTest.testGetBooks_specificFileIsUnignored(client, syncRepo) } + + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(client, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(client, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(client, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt index ddb29200c..5d2ae1acf 100644 --- a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt @@ -45,6 +45,7 @@ class GitRepoTest : SyncRepoTest { @After fun tearDown() { + gitWorkingTree.deleteRecursively() bareRepoDir.deleteRecursively() } @@ -72,4 +73,34 @@ class GitRepoTest : SyncRepoTest { override fun testGetBooks_specificFileIsUnignored() { SyncRepoTest.testGetBooks_specificFileIsUnignored(gitWorkingTree, syncRepo) } + + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(gitWorkingTree, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(gitWorkingTree, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(gitWorkingTree, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt index bc7a11861..700d65183 100644 --- a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -84,64 +84,33 @@ class WebdavRepoTest : SyncRepoTest { } @Test - fun testGetBooks_ignoredExtensions() { - for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { - val remoteBookFile = File(serverRootDir.absolutePath, fileName) - MiscUtils.writeStringToFile("...", remoteBookFile) - } - val books = syncRepo.books - assertEquals(1, books.size.toLong()) - assertEquals("file three", BookName.fromFileName(BookName.getFileName(syncRepo.uri, books[0].uri)).name) + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(serverRootDir, syncRepo) } @Test - fun testStoreBook_expectedUri() { - MiscUtils.writeStringToFile("...", tmpFile) - val vrook = syncRepo.storeBook(tmpFile, "Book one.org") - assertEquals(syncRepo.uri.toString() + "Book%20one.org", vrook.uri.toString()) + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) } @Test - fun testStoreBook_producesSameUriAsRetrieveBook() { - val repositoryPath = "a folder/a book.org" - MiscUtils.writeStringToFile("...", tmpFile) - val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) - val retrievedBook = syncRepo.retrieveBook(repositoryPath, tmpFile) - assertEquals(retrievedBook.uri, storedRook.uri) + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) } @Test - fun testStoreBook_producesSameUriAsGetBooks() { - val repositoryPath = "a folder/a book.org" - val repoSubDir = File(serverRootDir.absolutePath, "a folder") - repoSubDir.mkdir() - val repoBookFile = File(repoSubDir, "a book.org") - MiscUtils.writeStringToFile("...", repoBookFile) - val getBook = syncRepo.books[0] - MiscUtils.writeStringToFile(".......", tmpFile) - val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) - assertEquals(getBook.uri, storedRook.uri) + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(serverRootDir, syncRepo) } @Test - fun testStoreBook_inSubfolder() { - MiscUtils.writeStringToFile("...", tmpFile) - syncRepo.storeBook(tmpFile, "a folder/a book.org") - val subFolder = File(serverRootDir, "a folder") - assertTrue(subFolder.exists()) - val bookFile = File(subFolder, "a book.org") - assertTrue(bookFile.exists()) - assertEquals("...", bookFile.readText()) + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(serverRootDir, syncRepo) } @Test - fun testRenameBook_expectedUri() { - val remoteBookFile = File(serverRootDir.absolutePath + "/Book one.org") - MiscUtils.writeStringToFile("...", remoteBookFile) - val originalVrook = syncRepo.books[0] - assertEquals(syncRepo.uri.toString() + "Book%20one.org", originalVrook.uri.toString()) - val renamedVrook = syncRepo.renameBook(originalVrook.uri, "Renamed book") - assertEquals(syncRepo.uri.toString() + "Renamed%20book.org", renamedVrook.uri.toString()) + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) } @Test(expected = IOException::class) @@ -151,8 +120,6 @@ class WebdavRepoTest : SyncRepoTest { MiscUtils.writeStringToFile("...", remoteBookFile) } val originalRook = syncRepo.retrieveBook("Original.org", tmpFile) -// exceptionRule.expect(IOException::class.java) -// exceptionRule.expectMessage("File at " + syncRepo.uri.toString() + "Renamed.org already exists") try { syncRepo.renameBook(originalRook.uri, "Renamed") } catch (e: IOException) { diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt index 7eb2420ce..5aa7a9425 100644 --- a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -7,6 +7,7 @@ import com.orgzly.android.util.MiscUtils import org.eclipse.jgit.api.Git import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import java.io.File @SuppressLint("NewApi") @@ -17,6 +18,12 @@ interface SyncRepoTest { fun testGetBooks_allFilesAreIgnored() fun testGetBooks_specificFileInSubfolderIsIgnored() fun testGetBooks_specificFileIsUnignored() + fun testGetBooks_ignoredExtensions() + fun testStoreBook_expectedUri() + fun testStoreBook_producesSameUriAsRetrieveBook() + fun testStoreBook_producesSameUriAsGetBooks() + fun testStoreBook_inSubfolder() + fun testRenameBook_expectedUri() companion object { @@ -49,7 +56,7 @@ interface SyncRepoTest { .findGitDir(repoManipulationPoint) .build() ) - git.add().addFilepattern("Book one.org").call() + git.add().addFilepattern(".").call() git.commit().setMessage("").call() git.push().call() } @@ -61,7 +68,7 @@ interface SyncRepoTest { is DropboxRepo -> { repoManipulationPoint as DropboxClient expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" - val tmpFile = File.createTempFile("orgzly-test-", "") + val tmpFile = kotlin.io.path.createTempFile().toFile() MiscUtils.writeStringToFile(testBookContent, tmpFile) repoManipulationPoint.upload(tmpFile, syncRepo.uri, "Book one.org") tmpFile.delete() @@ -112,7 +119,7 @@ interface SyncRepoTest { .findGitDir(repoManipulationPoint) .build() ) - git.add().addFilepattern("Folder/Book one.org").call() + git.add().addFilepattern(".").call() git.commit().setMessage("").call() git.push().call() } @@ -172,8 +179,7 @@ interface SyncRepoTest { .findGitDir(repoManipulationPoint) .build() ) - git.add().addFilepattern("folder/book one.org").call() - git.add().addFilepattern(".orgzlyignore").call() + git.add().addFilepattern(".").call() git.commit().setMessage("").call() git.push().call() } @@ -226,8 +232,7 @@ interface SyncRepoTest { .findGitDir(repoManipulationPoint) .build() ) - git.add().addFilepattern("folder/book one.org").call() - git.add().addFilepattern(".orgzlyignore").call() + git.add().addFilepattern(".").call() git.commit().setMessage("").call() git.push().call() } @@ -280,8 +285,7 @@ interface SyncRepoTest { .findGitDir(repoManipulationPoint) .build() ) - git.add().addFilepattern(testBookRelativePath).call() - git.add().addFilepattern(RepoIgnoreNode.IGNORE_FILE).call() + git.add().addFilepattern(".").call() git.commit().setMessage("").call() git.push().call() } @@ -300,5 +304,203 @@ interface SyncRepoTest { // Then assertEquals(1, books.size) } + + fun testGetBooks_ignoredExtensions(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "\n\n...\n\n" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + MiscUtils.writeStringToFile(testBookContent, File(repoManipulationPoint.absolutePath, fileName)) + } + } + is GitRepo -> { + repoManipulationPoint as File + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + MiscUtils.writeStringToFile(testBookContent, File(repoManipulationPoint.absolutePath, fileName)) + } + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern(".").call() + git.commit().setMessage("").call() + git.push().call() + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + MiscUtils.writeStringToDocumentFile(testBookContent, fileName, repoManipulationPoint.uri) + } + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + MiscUtils.writeStringToFile(testBookContent, tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, fileName) + } + tmpFile.delete() + } + } + // When + val books = syncRepo.books + // Then + assertEquals(1, books.size.toLong()) + assertEquals("file three", BookName.fromFileName(BookName.getFileName(syncRepo.uri, books[0].uri)).name) + } + + fun testStoreBook_expectedUri(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val vrook = syncRepo.storeBook(tmpFile, "Book one.org") + tmpFile.delete() + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/Book one.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Book%20one.org" + else -> syncRepo.uri.toString() + "/Book%20one.org" + } + assertEquals(expectedRookUri, vrook.uri.toString()) + } + + fun testStoreBook_producesSameUriAsRetrieveBook(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val repositoryPath = "a folder/a book.org" + MiscUtils.writeStringToFile("...", tmpFile) + // When + val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) + val retrievedBook = syncRepo.retrieveBook(repositoryPath, tmpFile) + tmpFile.delete() + // Then + assertEquals(retrievedBook.uri, storedRook.uri) + } + + fun testStoreBook_producesSameUriAsGetBooks(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val repositoryPath = "a folder/a book.org" + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val repoSubDir = File(repoManipulationPoint.absolutePath, "a folder") + repoSubDir.mkdir() + MiscUtils.writeStringToFile("...", File(repoSubDir, "a book.org")) + } + is GitRepo -> { + repoManipulationPoint as File + val repoSubDir = File(repoManipulationPoint.absolutePath, "a folder") + repoSubDir.mkdir() + MiscUtils.writeStringToFile("...", File(repoSubDir, "a book.org")) + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern(".").call() + git.commit().setMessage("").call() + git.push().call() + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + MiscUtils.writeStringToFile("...", tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, repositoryPath) + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.createDirectory("a folder") + MiscUtils.writeStringToDocumentFile("...", "a book.org", subFolder!!.uri) + } + } + // When + val gottenBook = syncRepo.books[0] + MiscUtils.writeStringToFile("......", tmpFile) // N.B. Different content to ensure the repo file is actually changed + val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) + tmpFile.delete() + // Then + assertEquals(gottenBook.uri, storedRook.uri) + } + + fun testStoreBook_inSubfolder(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val repositoryPath = "A folder/A book.org" + val testBookContent = "\n\n...\n\n" + MiscUtils.writeStringToFile(testBookContent, tmpFile) + // When + syncRepo.storeBook(tmpFile, repositoryPath) + tmpFile.delete() + // Then + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint, "A folder") + assertTrue(subFolder.exists()) + val bookFile = File(subFolder, "A book.org") + assertTrue(bookFile.exists()) + assertEquals(testBookContent, bookFile.readText()) + } + is GitRepo -> { + repoManipulationPoint as File + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.pull().call() + val subFolder = File(repoManipulationPoint, "A folder") + assertTrue(subFolder.exists()) + val bookFile = File(subFolder, "A book.org") + assertTrue(bookFile.exists()) + assertEquals(testBookContent, bookFile.readText()) + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.findFile("A folder") + assertTrue(subFolder!!.exists()) + assertTrue(subFolder.isDirectory) + val bookFile = subFolder.findFile("A book.org") + assertTrue(bookFile!!.exists()) + assertEquals(testBookContent, MiscUtils.readStringFromDocumentFile(bookFile)) + } + is DropboxRepo -> { + // Not really much to assert here; we don't really care how Dropbox implements things, + // as long as URLs work as expected. + repoManipulationPoint as DropboxClient + val retrievedFile = kotlin.io.path.createTempFile().toFile() + repoManipulationPoint.download(syncRepo.uri, repositoryPath, retrievedFile) + assertEquals(testBookContent, retrievedFile.readText()) + } + } + } + + fun testRenameBook_expectedUri(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val oldFileName = "Original book.org" + val newBookName = "Renamed book" + val testBookContent = "\n\n...\n\n" + MiscUtils.writeStringToFile(testBookContent, tmpFile) + // When + val originalVrook = syncRepo.storeBook(tmpFile, oldFileName) + tmpFile.delete() + syncRepo.renameBook(originalVrook.uri, newBookName) + // Then + val renamedVrook = syncRepo.books[0] + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/Renamed book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Renamed%20book.org" + else -> syncRepo.uri.toString() + "/Renamed%20book.org" + } + assertEquals(expectedRookUri, renamedVrook.uri.toString()) + } } } \ No newline at end of file From 301b5ff248f5da73203321bfc3b1e4e006d8b966 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 12 Aug 2024 21:09:24 +0200 Subject: [PATCH 66/73] Making progress on shared test code --- .../orgzly/android/repos/DocumentRepoTest.kt | 11 +++ .../orgzly/android/repos/DropboxClient.java | 7 ++ .../com/orgzly/android/repos/DropboxRepo.java | 2 +- .../orgzly/android/repos/DropboxRepoTest.kt | 20 +++-- .../com/orgzly/android/repos/GitRepoTest.kt | 11 +++ .../orgzly/android/repos/WebdavRepoTest.kt | 29 ++----- .../com/orgzly/android/repos/SyncRepoTest.kt | 85 +++++++++++++++++-- 7 files changed, 125 insertions(+), 40 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt index 5eda0a9ec..af06900e0 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -22,6 +22,7 @@ import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test +import java.io.IOException class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { @@ -99,6 +100,16 @@ class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { SyncRepoTest.testRenameBook_expectedUri(syncRepo) } + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(repoDirectory, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } + private fun setupDocumentRepo(extraDir: String? = null) { val repoDirName = SyncRepoTest.repoDirName documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index fb80c6ef6..4f6e46ee6 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -12,6 +12,7 @@ import com.dropbox.core.json.JsonReadException; import com.dropbox.core.oauth.DbxCredential; import com.dropbox.core.v2.DbxClientV2; +import com.dropbox.core.v2.files.DeleteResult; import com.dropbox.core.v2.files.FileMetadata; import com.dropbox.core.v2.files.FolderMetadata; import com.dropbox.core.v2.files.GetMetadataErrorException; @@ -343,6 +344,12 @@ public void delete(String path) throws IOException { public VersionedRook move(Uri repoUri, Uri from, Uri to) throws IOException { linkedOrThrow(); + /* Abort if destination file already exists. */ + try { + if (dbxClient.files().getMetadata(to.getPath()) instanceof FileMetadata) + throw new IOException("File at " + to.getPath() + " already exists"); + } catch (DbxException ignored) {} + try { RelocationResult relocationRes = dbxClient.files().moveV2(from.getPath(), to.getPath()); Metadata metadata = relocationRes.getMetadata(); diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index 4164cd544..d4a9acbe2 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -74,7 +74,7 @@ public void delete(Uri uri) throws IOException { } /** - * Intended for tests. The delete() method does not allow deleting directories. + * Only used by tests. The delete() method does not allow deleting directories. */ public void deleteDirectory(Uri uri) throws IOException { client.deleteFolder(uri.getPath()); diff --git a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt index b11f20c33..4bfc737e1 100644 --- a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt @@ -20,10 +20,6 @@ class DropboxRepoTest : SyncRepoTest { private lateinit var syncRepo: SyncRepo private lateinit var client: DropboxClient - companion object { - val RANDOM_UUID = UUID.randomUUID().toString() - } - @Before fun setup() { assumeTrue(BuildConfig.DROPBOX_APP_KEY.isNotEmpty()) @@ -37,7 +33,7 @@ class DropboxRepoTest : SyncRepoTest { ApplicationProvider.getApplicationContext(), mockSerializedDbxCredential.toString() ) - val repo = Repo(0, RepoType.DROPBOX, "dropbox:/${SyncRepoTest.repoDirName}/$RANDOM_UUID") + val repo = Repo(0, RepoType.DROPBOX, "dropbox:/${SyncRepoTest.repoDirName}/" + UUID.randomUUID().toString()) val repoPropsMap = HashMap() val repoWithProps = RepoWithProps(repo, repoPropsMap) syncRepo = DropboxRepo(repoWithProps, ApplicationProvider.getApplicationContext()) @@ -47,9 +43,7 @@ class DropboxRepoTest : SyncRepoTest { @After fun tearDown() { val dropboxRepo = syncRepo as DropboxRepo - try { - dropboxRepo.deleteDirectory(syncRepo.uri) - } catch (_: IOException) {} + dropboxRepo.deleteDirectory(syncRepo.uri) } @Test @@ -106,4 +100,14 @@ class DropboxRepoTest : SyncRepoTest { override fun testRenameBook_expectedUri() { SyncRepoTest.testRenameBook_expectedUri(syncRepo) } + + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(client, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt index 5d2ae1acf..cc5cb756e 100644 --- a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.io.File +import java.io.IOException import kotlin.io.path.createTempDirectory @RunWith(AndroidJUnit4::class) @@ -103,4 +104,14 @@ class GitRepoTest : SyncRepoTest { override fun testRenameBook_expectedUri() { SyncRepoTest.testRenameBook_expectedUri(syncRepo) } + + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(gitWorkingTree, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt index 700d65183..c9f448761 100644 --- a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -1,19 +1,13 @@ -package com.orgzly.android +package com.orgzly.android.repos import androidx.test.ext.junit.runners.AndroidJUnit4 import com.orgzly.android.db.entity.Repo -import com.orgzly.android.repos.RepoType -import com.orgzly.android.repos.RepoWithProps -import com.orgzly.android.repos.SyncRepo -import com.orgzly.android.repos.SyncRepoTest -import com.orgzly.android.repos.WebdavRepo import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY import com.orgzly.android.util.MiscUtils import io.github.atetzner.webdav.server.MiltonWebDAVFileServer import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -114,26 +108,13 @@ class WebdavRepoTest : SyncRepoTest { } @Test(expected = IOException::class) - fun testRenameBook_repoFileAlreadyExists() { - for (bookName in arrayOf("Original", "Renamed")) { - val remoteBookFile = File(serverRootDir.absolutePath + "/" + bookName + ".org") - MiscUtils.writeStringToFile("...", remoteBookFile) - } - val originalRook = syncRepo.retrieveBook("Original.org", tmpFile) - try { - syncRepo.renameBook(originalRook.uri, "Renamed") - } catch (e: IOException) { - assertTrue(e.message!!.contains("File at " + syncRepo.uri.toString() + "Renamed.org already exists")) - throw e - } + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(serverRootDir, syncRepo) } @Test - fun testRenameBook_fromRootToSubfolder() { - MiscUtils.writeStringToFile("...", tmpFile) - val originalRook = syncRepo.storeBook(tmpFile, "Original.org") - val renamedRook = syncRepo.renameBook(originalRook.uri, "a folder/Renamed") - assertEquals(syncRepo.uri.toString() + "a%20folder/Renamed.org", renamedRook.uri.toString()) + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) } @Test diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt index 5aa7a9425..d4b3f938b 100644 --- a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -9,6 +9,7 @@ import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.io.File +import java.io.IOException @SuppressLint("NewApi") interface SyncRepoTest { @@ -24,6 +25,9 @@ interface SyncRepoTest { fun testStoreBook_producesSameUriAsGetBooks() fun testStoreBook_inSubfolder() fun testRenameBook_expectedUri() + fun testRenameBook_repoFileAlreadyExists() + fun testRenameBook_fromRootToSubfolder() + companion object { @@ -385,19 +389,19 @@ interface SyncRepoTest { fun testStoreBook_producesSameUriAsGetBooks(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given val tmpFile = kotlin.io.path.createTempFile().toFile() - val repositoryPath = "a folder/a book.org" + val repositoryPath = "A folder/A book.org" when (syncRepo) { is WebdavRepo -> { repoManipulationPoint as File - val repoSubDir = File(repoManipulationPoint.absolutePath, "a folder") + val repoSubDir = File(repoManipulationPoint.absolutePath, "A folder") repoSubDir.mkdir() - MiscUtils.writeStringToFile("...", File(repoSubDir, "a book.org")) + MiscUtils.writeStringToFile("...", File(repoSubDir, "A book.org")) } is GitRepo -> { repoManipulationPoint as File - val repoSubDir = File(repoManipulationPoint.absolutePath, "a folder") + val repoSubDir = File(repoManipulationPoint.absolutePath, "A folder") repoSubDir.mkdir() - MiscUtils.writeStringToFile("...", File(repoSubDir, "a book.org")) + MiscUtils.writeStringToFile("...", File(repoSubDir, "A book.org")) val git = Git( FileRepositoryBuilder() .addCeilingDirectory(repoManipulationPoint) @@ -415,8 +419,8 @@ interface SyncRepoTest { } is DocumentRepo -> { repoManipulationPoint as DocumentFile - val subFolder = repoManipulationPoint.createDirectory("a folder") - MiscUtils.writeStringToDocumentFile("...", "a book.org", subFolder!!.uri) + val subFolder = repoManipulationPoint.createDirectory("A folder") + MiscUtils.writeStringToDocumentFile("...", "A book.org", subFolder!!.uri) } } // When @@ -502,5 +506,72 @@ interface SyncRepoTest { } assertEquals(expectedRookUri, renamedVrook.uri.toString()) } + + fun testRenameBook_repoFileAlreadyExists(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + for (rookRepoPath in arrayOf("Original.org", "Renamed.org")) { + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val remoteBookFile = File(repoManipulationPoint.absolutePath + "/" + rookRepoPath) + MiscUtils.writeStringToFile("...", remoteBookFile) + } + is GitRepo -> { + repoManipulationPoint as File + val remoteBookFile = File(repoManipulationPoint.absolutePath + "/" + rookRepoPath) + MiscUtils.writeStringToFile("...", remoteBookFile) + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.add().addFilepattern(".").call() + git.commit().setMessage("").call() + git.push().call() + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + MiscUtils.writeStringToDocumentFile("...", rookRepoPath, repoManipulationPoint.uri) + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + repoManipulationPoint.upload(tmpFile, syncRepo.uri, rookRepoPath) + tmpFile.delete() + } + } + } + val retrievedBookFile = kotlin.io.path.createTempFile().toFile() + // When + val originalRook = syncRepo.retrieveBook("Original.org", retrievedBookFile) + try { + syncRepo.renameBook(originalRook.uri, "Renamed") + } catch (e: IOException) { + // Then + assertTrue(e.message!!.contains("Renamed.org already exists")) + throw e + } finally { + retrievedBookFile.delete() + } + } + + fun testRenameBook_fromRootToSubfolder(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + val originalRook = syncRepo.storeBook(tmpFile, "Original book.org") + tmpFile.delete() + // When + val renamedRook = syncRepo.renameBook(originalRook.uri, "A folder/Renamed book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/A folder/Renamed book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "A%20folder%2FRenamed%20book.org" + else -> syncRepo.uri.toString() + "/A%20folder/Renamed%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } } } \ No newline at end of file From cbfaac29f6a0d6a8dea4a0e89c0cc25eab91aa2c Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 12 Aug 2024 21:32:59 +0200 Subject: [PATCH 67/73] Making progress on shared test code --- .../orgzly/android/repos/DocumentRepoTest.kt | 20 +++++ .../orgzly/android/repos/DropboxRepoTest.kt | 20 +++++ .../com/orgzly/android/repos/GitRepoTest.kt | 20 +++++ .../orgzly/android/repos/WebdavRepoTest.kt | 29 ++----- .../com/orgzly/android/repos/SyncRepoTest.kt | 77 ++++++++++++++++++- 5 files changed, 142 insertions(+), 24 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt index af06900e0..6ee38a981 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -110,6 +110,26 @@ class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) } + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } + private fun setupDocumentRepo(extraDir: String? = null) { val repoDirName = SyncRepoTest.repoDirName documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { diff --git a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt index 4bfc737e1..0077e334c 100644 --- a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt @@ -110,4 +110,24 @@ class DropboxRepoTest : SyncRepoTest { override fun testRenameBook_fromRootToSubfolder() { SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) } + + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt index cc5cb756e..883be5b03 100644 --- a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt @@ -114,4 +114,24 @@ class GitRepoTest : SyncRepoTest { override fun testRenameBook_fromRootToSubfolder() { SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) } + + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } } \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt index c9f448761..273e1b752 100644 --- a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt +++ b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -4,7 +4,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.orgzly.android.db.entity.Repo import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY -import com.orgzly.android.util.MiscUtils import io.github.atetzner.webdav.server.MiltonWebDAVFileServer import org.junit.After import org.junit.Assert.assertEquals @@ -118,34 +117,22 @@ class WebdavRepoTest : SyncRepoTest { } @Test - fun testRenameBook_fromSubfolderToRoot() { - MiscUtils.writeStringToFile("...", tmpFile) - val originalRook = syncRepo.storeBook(tmpFile, "a folder/Original.org") - val renamedRook = syncRepo.renameBook(originalRook.uri, "Renamed") - assertEquals(syncRepo.uri.toString() + "Renamed.org", renamedRook.uri.toString()) + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) } @Test - fun testRenameBook_newSubfolderSameLeafName() { - MiscUtils.writeStringToFile("...", tmpFile) - val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original.org") - val renamedRook = syncRepo.renameBook(originalRook.uri, "new folder/Original") - assertEquals(syncRepo.uri.toString() + "new%20folder/Original.org", renamedRook.uri.toString()) + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) } @Test - fun testRenameBook_newSubfolderAndLeafName() { - MiscUtils.writeStringToFile("...", tmpFile) - val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") - val renamedRook = syncRepo.renameBook(originalRook.uri, "new folder/New book") - assertEquals(syncRepo.uri.toString() + "new%20folder/New%20book.org", renamedRook.uri.toString()) + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) } @Test - fun testRenameBook_sameSubfolderNewLeafName() { - MiscUtils.writeStringToFile("...", tmpFile) - val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") - val renamedRook = syncRepo.renameBook(originalRook.uri, "old folder/New book") - assertEquals(syncRepo.uri.toString() + "old%20folder/New%20book.org", renamedRook.uri.toString()) + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) } } \ No newline at end of file diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt index d4b3f938b..3ac4a1a35 100644 --- a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -27,9 +27,12 @@ interface SyncRepoTest { fun testRenameBook_expectedUri() fun testRenameBook_repoFileAlreadyExists() fun testRenameBook_fromRootToSubfolder() + fun testRenameBook_fromSubfolderToRoot() + fun testRenameBook_newSubfolderSameLeafName() + fun testRenameBook_newSubfolderAndLeafName() + fun testRenameBook_sameSubfolderNewLeafName() - - companion object { + companion object { const val repoDirName = "orgzly-android-test" private const val treeDocumentFileExtraSegment = "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" @@ -561,9 +564,9 @@ interface SyncRepoTest { // Given val tmpFile = kotlin.io.path.createTempFile().toFile() MiscUtils.writeStringToFile("...", tmpFile) + // When val originalRook = syncRepo.storeBook(tmpFile, "Original book.org") tmpFile.delete() - // When val renamedRook = syncRepo.renameBook(originalRook.uri, "A folder/Renamed book") // Then val expectedRookUri = when (syncRepo) { @@ -573,5 +576,73 @@ interface SyncRepoTest { } assertEquals(expectedRookUri, renamedRook.uri.toString()) } + + fun testRenameBook_fromSubfolderToRoot(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "A folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "Renamed book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/Renamed book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Renamed%20book.org" + else -> syncRepo.uri.toString() + "/Renamed%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_newSubfolderSameLeafName(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "Old folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "New folder/Original book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/New folder/Original book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "New%20folder%2FOriginal%20book.org" + else -> syncRepo.uri.toString() + "/New%20folder/Original%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_newSubfolderAndLeafName(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "new folder/New book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/new folder/New book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "new%20folder%2FNew%20book.org" + else -> syncRepo.uri.toString() + "/new%20folder/New%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_sameSubfolderNewLeafName(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "old folder/New book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/old folder/New book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "old%20folder%2FNew%20book.org" + else -> syncRepo.uri.toString() + "/old%20folder/New%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } } } \ No newline at end of file From 7a29e3d1bdb547709bb40ff9fa6671fb489a703f Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 12 Aug 2024 23:58:34 +0200 Subject: [PATCH 68/73] DRY up SyncRepoTest --- .../com/orgzly/android/repos/SyncRepoTest.kt | 431 ++++-------------- 1 file changed, 100 insertions(+), 331 deletions(-) diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt index 3ac4a1a35..4babf2f7e 100644 --- a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -1,6 +1,7 @@ package com.orgzly.android.repos import android.annotation.SuppressLint +import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.orgzly.android.BookName import com.orgzly.android.util.MiscUtils @@ -39,166 +40,45 @@ interface SyncRepoTest { fun testGetBooks_singleOrgFile(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given - val testBookContent = "\n\n...\n\n" - var expectedRookUri = "" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - MiscUtils.writeStringToFile( - testBookContent, - File(repoManipulationPoint.absolutePath + "/Book one.org") - ) - expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" - } - is GitRepo -> { - repoManipulationPoint as File - expectedRookUri = "/Book one.org" - MiscUtils.writeStringToFile( - testBookContent, - File(repoManipulationPoint.absolutePath + expectedRookUri) - ) - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", repoManipulationPoint.uri) - expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Book%20one.org" - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - expectedRookUri = syncRepo.uri.toString() + "/Book%20one.org" - val tmpFile = kotlin.io.path.createTempFile().toFile() - MiscUtils.writeStringToFile(testBookContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, "Book one.org") - tmpFile.delete() - } - } + val fileContent = "\n\n...\n\n" + val fileName = "Book one.org" + val expectedRookUri = writeFileToRepo(fileContent, syncRepo, repoManipulationPoint, fileName) // When val books = syncRepo.books val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook("Book one.org", retrieveBookDestinationFile) + syncRepo.retrieveBook(fileName, retrieveBookDestinationFile) // Then assertEquals(1, books.size) assertEquals(expectedRookUri, books[0].uri.toString()) - assertEquals(testBookContent, retrieveBookDestinationFile.readText()) - assertEquals("Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) + assertEquals(fileContent, retrieveBookDestinationFile.readText()) + assertEquals(fileName, BookName.getFileName(syncRepo.uri, books[0].uri)) } fun testGetBooks_singleFileInSubfolder(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given - val testBookContent = "\n\n...\n\n" - var expectedRookUri = "" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath + "/Folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath + "/Book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - expectedRookUri = syncRepo.uri.toString() + "/Folder/Book%20one.org" - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - val subFolder = repoManipulationPoint.createDirectory("Folder") - MiscUtils.writeStringToDocumentFile(testBookContent, "Book one.org", subFolder!!.uri) - expectedRookUri = syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Folder%2FBook%20one.org" - } - is GitRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath + "/Folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath + "/Book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - expectedRookUri = "/Folder/Book one.org" - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - expectedRookUri = syncRepo.uri.toString() + "/Folder/Book%20one.org" - val tmpFile = kotlin.io.path.createTempFile().toFile() - MiscUtils.writeStringToFile(testBookContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, "Folder/Book one.org") - tmpFile.delete() - } - } + val repoFilePath = "Folder/Book one.org" + val fileContent = "\n\n...\n\n" + val expectedRookUri = writeFileToRepo(fileContent, syncRepo, repoManipulationPoint, "Book one.org", "Folder") // When val books = syncRepo.books val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() - syncRepo.retrieveBook("Folder/Book one.org", retrieveBookDestinationFile) + syncRepo.retrieveBook(repoFilePath, retrieveBookDestinationFile) // Then assertEquals(1, books.size) assertEquals(expectedRookUri, books[0].uri.toString()) - assertEquals("Folder/Book one.org", BookName.getFileName(syncRepo.uri, books[0].uri)) - assertEquals(testBookContent, retrieveBookDestinationFile.readText()) + assertEquals(repoFilePath, BookName.getFileName(syncRepo.uri, books[0].uri)) + assertEquals(fileContent, retrieveBookDestinationFile.readText()) } fun testGetBooks_allFilesAreIgnored(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given - val testBookContent = "..." val ignoreFileContent = "*\n" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - val subFolder = repoManipulationPoint.createDirectory("folder") - MiscUtils.writeStringToDocumentFile(testBookContent, "book one.org", subFolder!!.uri) - MiscUtils.writeStringToDocumentFile(ignoreFileContent, RepoIgnoreNode.IGNORE_FILE, repoManipulationPoint.uri) - } - is GitRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - val tmpFile = kotlin.io.path.createTempFile().toFile() - MiscUtils.writeStringToFile(testBookContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, "folder/book one.org") - MiscUtils.writeStringToFile(ignoreFileContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, ".orgzlyignore") - } - } + writeFileToRepo("...", syncRepo, repoManipulationPoint, "book one.org", "folder") + writeFileToRepo(ignoreFileContent, syncRepo, repoManipulationPoint, RepoIgnoreNode.IGNORE_FILE) // When val books = syncRepo.books // Then @@ -207,51 +87,9 @@ interface SyncRepoTest { fun testGetBooks_specificFileInSubfolderIsIgnored(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given - val testBookContent = "..." val ignoreFileContent = "folder/book one.org\n" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - val subFolder = repoManipulationPoint.createDirectory("folder") - MiscUtils.writeStringToDocumentFile(testBookContent, "book one.org", subFolder!!.uri) - MiscUtils.writeStringToDocumentFile(ignoreFileContent, RepoIgnoreNode.IGNORE_FILE, repoManipulationPoint.uri) - } - is GitRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - val tmpFile = kotlin.io.path.createTempFile().toFile() - MiscUtils.writeStringToFile(testBookContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, "folder/book one.org") - MiscUtils.writeStringToFile(ignoreFileContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, ".orgzlyignore") - } - } + writeFileToRepo("...", syncRepo, repoManipulationPoint, "book one.org", "folder") + writeFileToRepo(ignoreFileContent, syncRepo, repoManipulationPoint, RepoIgnoreNode.IGNORE_FILE) // When val books = syncRepo.books // Then @@ -259,53 +97,11 @@ interface SyncRepoTest { } fun testGetBooks_specificFileIsUnignored(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given - val testBookRelativePath = "folder/book one.org" - val testBookContent = "..." - val ignoreFileContent = "folder/**\n!$testBookRelativePath\n" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - val subFolder = repoManipulationPoint.createDirectory("folder") - MiscUtils.writeStringToDocumentFile(testBookContent, "book one.org", subFolder!!.uri) - MiscUtils.writeStringToDocumentFile(ignoreFileContent, RepoIgnoreNode.IGNORE_FILE, repoManipulationPoint.uri) - } - is GitRepo -> { - repoManipulationPoint as File - val subFolder = File(repoManipulationPoint.absolutePath, "folder") - subFolder.mkdir() - val remoteBookFile = File(subFolder.absolutePath, "book one.org") - MiscUtils.writeStringToFile(testBookContent, remoteBookFile) - val ignoreFile = File(repoManipulationPoint.absolutePath, RepoIgnoreNode.IGNORE_FILE) - MiscUtils.writeStringToFile(ignoreFileContent, ignoreFile) - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - val tmpFile = kotlin.io.path.createTempFile().toFile() - MiscUtils.writeStringToFile(testBookContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, testBookRelativePath) - MiscUtils.writeStringToFile(ignoreFileContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, RepoIgnoreNode.IGNORE_FILE) - tmpFile.delete() - } - } + val folderName = "My Folder" + val fileName = "My file.org" + val ignoreFileContent = "folder/**\n!$folderName/$fileName\n" + writeFileToRepo("...", syncRepo, repoManipulationPoint, fileName, folderName) + writeFileToRepo(ignoreFileContent, syncRepo, repoManipulationPoint, RepoIgnoreNode.IGNORE_FILE) // When val books = syncRepo.books // Then @@ -315,43 +111,8 @@ interface SyncRepoTest { fun testGetBooks_ignoredExtensions(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given val testBookContent = "\n\n...\n\n" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { - MiscUtils.writeStringToFile(testBookContent, File(repoManipulationPoint.absolutePath, fileName)) - } - } - is GitRepo -> { - repoManipulationPoint as File - for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { - MiscUtils.writeStringToFile(testBookContent, File(repoManipulationPoint.absolutePath, fileName)) - } - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { - MiscUtils.writeStringToDocumentFile(testBookContent, fileName, repoManipulationPoint.uri) - } - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - val tmpFile = kotlin.io.path.createTempFile().toFile() - for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { - MiscUtils.writeStringToFile(testBookContent, tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, fileName) - } - tmpFile.delete() - } + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + writeFileToRepo(testBookContent, syncRepo, repoManipulationPoint, fileName) } // When val books = syncRepo.books @@ -392,44 +153,13 @@ interface SyncRepoTest { fun testStoreBook_producesSameUriAsGetBooks(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given val tmpFile = kotlin.io.path.createTempFile().toFile() - val repositoryPath = "A folder/A book.org" - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - val repoSubDir = File(repoManipulationPoint.absolutePath, "A folder") - repoSubDir.mkdir() - MiscUtils.writeStringToFile("...", File(repoSubDir, "A book.org")) - } - is GitRepo -> { - repoManipulationPoint as File - val repoSubDir = File(repoManipulationPoint.absolutePath, "A folder") - repoSubDir.mkdir() - MiscUtils.writeStringToFile("...", File(repoSubDir, "A book.org")) - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - MiscUtils.writeStringToFile("...", tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, repositoryPath) - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - val subFolder = repoManipulationPoint.createDirectory("A folder") - MiscUtils.writeStringToDocumentFile("...", "A book.org", subFolder!!.uri) - } - } + val folderName = "A folder" + val fileName = "A book.org" + writeFileToRepo("...", syncRepo, repoManipulationPoint, fileName, folderName) // When val gottenBook = syncRepo.books[0] MiscUtils.writeStringToFile("......", tmpFile) // N.B. Different content to ensure the repo file is actually changed - val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) + val storedRook = syncRepo.storeBook(tmpFile, "$folderName/$fileName") tmpFile.delete() // Then assertEquals(gottenBook.uri, storedRook.uri) @@ -512,39 +242,8 @@ interface SyncRepoTest { fun testRenameBook_repoFileAlreadyExists(repoManipulationPoint: Any, syncRepo: SyncRepo) { // Given - for (rookRepoPath in arrayOf("Original.org", "Renamed.org")) { - when (syncRepo) { - is WebdavRepo -> { - repoManipulationPoint as File - val remoteBookFile = File(repoManipulationPoint.absolutePath + "/" + rookRepoPath) - MiscUtils.writeStringToFile("...", remoteBookFile) - } - is GitRepo -> { - repoManipulationPoint as File - val remoteBookFile = File(repoManipulationPoint.absolutePath + "/" + rookRepoPath) - MiscUtils.writeStringToFile("...", remoteBookFile) - val git = Git( - FileRepositoryBuilder() - .addCeilingDirectory(repoManipulationPoint) - .findGitDir(repoManipulationPoint) - .build() - ) - git.add().addFilepattern(".").call() - git.commit().setMessage("").call() - git.push().call() - } - is DocumentRepo -> { - repoManipulationPoint as DocumentFile - MiscUtils.writeStringToDocumentFile("...", rookRepoPath, repoManipulationPoint.uri) - } - is DropboxRepo -> { - repoManipulationPoint as DropboxClient - val tmpFile = kotlin.io.path.createTempFile().toFile() - MiscUtils.writeStringToFile("...", tmpFile) - repoManipulationPoint.upload(tmpFile, syncRepo.uri, rookRepoPath) - tmpFile.delete() - } - } + for (fileName in arrayOf("Original.org", "Renamed.org")) { + writeFileToRepo("...", syncRepo, repoManipulationPoint, fileName) } val retrievedBookFile = kotlin.io.path.createTempFile().toFile() // When @@ -644,5 +343,75 @@ interface SyncRepoTest { } assertEquals(expectedRookUri, renamedRook.uri.toString()) } + + private fun writeFileToRepo( + content: String, + repo: SyncRepo, + repoManipulationPoint: Any, + fileName: String, + folderName: String? = null + ): String { + var expectedRookUri = repo.uri.toString() + "/" + Uri.encode(fileName) + when (repo) { + is WebdavRepo -> { + var targetDir = repoManipulationPoint as File + if (folderName != null) { + targetDir = File(targetDir.absolutePath + "/$folderName") + targetDir.mkdir() + expectedRookUri = repo.uri.toString() + "/" + Uri.encode("$folderName/$fileName", "/") + } + val remoteBookFile = File(targetDir.absolutePath + "/$fileName") + MiscUtils.writeStringToFile(content, remoteBookFile) + } + is GitRepo -> { + expectedRookUri = "/$fileName" + var targetDir = repoManipulationPoint as File + if (folderName != null) { + expectedRookUri = "/$folderName/$fileName" + targetDir = File(targetDir.absolutePath + "/$folderName") + targetDir.mkdir() + } + MiscUtils.writeStringToFile( + content, + File(targetDir.absolutePath + "/$fileName") + ) + updateGitRepo(repoManipulationPoint) + } + is DocumentRepo -> { + expectedRookUri = repo.uri.toString() + treeDocumentFileExtraSegment + Uri.encode(fileName) + var targetDir = repoManipulationPoint as DocumentFile + if (folderName != null) { + targetDir = targetDir.createDirectory(folderName)!! + expectedRookUri = repo.uri.toString() + treeDocumentFileExtraSegment + Uri.encode("$folderName/$fileName") + } + MiscUtils.writeStringToDocumentFile(content, fileName, targetDir.uri) + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile(content, tmpFile) + var targetPath = fileName + if (folderName != null) { + targetPath = "$folderName/$fileName" + expectedRookUri = repo.uri.toString() + "/" + Uri.encode("$folderName/$fileName", "/") + } + repoManipulationPoint.upload(tmpFile, repo.uri, targetPath) + tmpFile.delete() + } + } + return expectedRookUri + } + + private fun updateGitRepo(workdir: File) { + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(workdir) + .findGitDir(workdir) + .build() + ) + git.add().addFilepattern(".").call() + git.commit().setMessage("").call() + git.push().call() + } } } \ No newline at end of file From c450b72e9927431112c5938c95b4bf928e8f6468 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 13 Aug 2024 00:11:28 +0200 Subject: [PATCH 69/73] Add local unit tests to Github workflow --- .github/workflows/test.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ef2a7ed63..498a7a8a8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -93,7 +93,10 @@ jobs: echo "webdav.username = \"${{ secrets.WEBDAV_TEST_USERNAME }}\"" >> app.properties echo "webdav.password = \"${{ secrets.WEBDAV_TEST_PASSWORD }}\"" >> app.properties - - name: Run tests + - name: Run local unit tests + run: ./gradlew test + + - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} From 53c49b59ddb419ee3a4eb440585b49ef7f1fa1a2 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 13 Aug 2024 00:17:10 +0200 Subject: [PATCH 70/73] fixup! Started moving SyncRepo test cases to a shared library --- shared-test/build.gradle | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shared-test/build.gradle b/shared-test/build.gradle index cce028275..10f972424 100644 --- a/shared-test/build.gradle +++ b/shared-test/build.gradle @@ -5,9 +5,10 @@ apply plugin: 'kotlin-kapt' android { namespace = "com.orgzly.shared.test" compileSdk 33 - minSdk 21 - defaultConfig {} + defaultConfig { + minSdk 21 + } buildTypes { release { From d6a624bb66226b734a9f54b5d0ca1e2806b6ff57 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 13 Aug 2024 00:23:02 +0200 Subject: [PATCH 71/73] Run unit test in separate step, not matrix --- .github/workflows/test.yaml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 498a7a8a8..c22b753a4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,7 +22,26 @@ on: workflow_dispatch: jobs: - test: + + localUnitTests: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run local unit tests + run: ./gradlew test + + instrumentedTests: runs-on: ubuntu-latest strategy: matrix: @@ -93,9 +112,6 @@ jobs: echo "webdav.username = \"${{ secrets.WEBDAV_TEST_USERNAME }}\"" >> app.properties echo "webdav.password = \"${{ secrets.WEBDAV_TEST_PASSWORD }}\"" >> app.properties - - name: Run local unit tests - run: ./gradlew test - - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: From 01fc9bd42e08fc3867cf03425ed95a899cffe3d1 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 13 Aug 2024 00:33:37 +0200 Subject: [PATCH 72/73] gradle build --- .github/workflows/test.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c22b753a4..c5f822493 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -38,7 +38,10 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - - name: Run local unit tests + - name: Gradle Build + run: ./gradlew build + + - name: Gradle Test run: ./gradlew test instrumentedTests: From b5d5f097a0bc27591ed10c41863b1269586b283b Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 13 Aug 2024 00:48:07 +0200 Subject: [PATCH 73/73] Update build.gradle --- shared-test/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared-test/build.gradle b/shared-test/build.gradle index 10f972424..69bc51a7b 100644 --- a/shared-test/build.gradle +++ b/shared-test/build.gradle @@ -36,6 +36,10 @@ android { kotlinOptions { jvmTarget = 17 } + + packagingOptions { + resources.merges.add("plugin.properties") + } } dependencies {