diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ShareActivityTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ShareActivityTest.kt index 720070e89..743809700 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ShareActivityTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ShareActivityTest.kt @@ -13,6 +13,7 @@ import com.orgzly.R import com.orgzly.android.AppIntent import com.orgzly.android.OrgzlyTest import com.orgzly.android.espresso.util.EspressoUtils.* +import com.orgzly.android.prefs.AppPreferences import com.orgzly.android.ui.share.ShareActivity import org.hamcrest.Matchers.startsWith import org.junit.Assert.assertTrue @@ -156,17 +157,25 @@ class ShareActivityTest : OrgzlyTest() { extraStreamUri = "content://uri") onView(withId(R.id.title_view)).check(matches(withText("content://uri"))) - onView(withId(R.id.content_view)).check(matches(withText("Cannot find image using this URI."))) + onView(withId(R.id.content_view)).check(matches(withText("content://uri\n" + + "\n" + + "Cannot determine fileName to this content."))) onView(withId(R.id.done)).perform(click()); // Note done } @Test - fun testNoMatchingType() { - startActivityWithIntent(action = Intent.ACTION_SEND, type = "application/octet-stream") + fun testFileCopy() { + AppPreferences.attachMethod(context, ShareActivity.ATTACH_METHOD_COPY_DIR); + startActivityWithIntent( + action = Intent.ACTION_SEND, + type = "application/pdf", + extraStreamUri = "content://uri") - onView(withId(R.id.title_view)).check(matches(withText(""))) - onSnackbar().check(matches(withText(context.getString(R.string.share_type_not_supported, "application/octet-stream")))) + onView(withId(R.id.title_view)).check(matches(withText("content://uri"))) + onView(withId(R.id.content_view)).check(matches(withText("content://uri\n\nCannot determine fileName to this content."))) + + onView(withId(R.id.done)).perform(click()) // Note done } @Test diff --git a/app/src/androidTest/java/com/orgzly/android/util/OrgFormatterLinkTest.kt b/app/src/androidTest/java/com/orgzly/android/util/OrgFormatterLinkTest.kt index dcfe67e01..f729df672 100644 --- a/app/src/androidTest/java/com/orgzly/android/util/OrgFormatterLinkTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/util/OrgFormatterLinkTest.kt @@ -1,5 +1,7 @@ package com.orgzly.android.util +import android.text.style.URLSpan +import com.orgzly.android.ui.views.style.AttachmentLinkSpan import com.orgzly.android.ui.views.style.FileLinkSpan import com.orgzly.android.ui.views.style.FileOrNotLinkSpan import com.orgzly.android.ui.views.style.IdLinkSpan @@ -67,6 +69,10 @@ class OrgFormatterLinkTest(private val param: Parameter) : OrgFormatterTest() { Parameter("[[file:orgzly-tests/document.txt]]", "file:orgzly-tests/document.txt", listOf(Span(0, 30, FileLinkSpan::class.java))), Parameter("[[file:orgzly-tests/document.txt][Document]]", "Document", listOf(Span(0, 8, FileLinkSpan::class.java))), + Parameter("attachment:orgzly-tests/document.txt", "attachment:orgzly-tests/document.txt", listOf(Span(0, 36, AttachmentLinkSpan::class.java))), + Parameter("[[attachment:orgzly-tests/document.txt]]", "attachment:orgzly-tests/document.txt", listOf(Span(0, 36, AttachmentLinkSpan::class.java))), + Parameter("[[attachment:orgzly-tests/document.txt][Document]]", "Document", listOf(Span(0, 8, AttachmentLinkSpan::class.java))), + Parameter("id:45DFE015-255E-4B86-B957-F7FD77364DCA", "id:45DFE015-255E-4B86-B957-F7FD77364DCA", listOf(Span(0, 39, IdLinkSpan::class.java))), Parameter("[[id:45DFE015-255E-4B86-B957-F7FD77364DCA]]", "id:45DFE015-255E-4B86-B957-F7FD77364DCA", listOf(Span(0, 39, IdLinkSpan::class.java))), Parameter("id:foo", "id:foo", listOf(Span(0, 6, IdLinkSpan::class.java))), diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f272521df..2f6ffaabf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,7 +79,7 @@ - + + tempFile = getTempBookFile() + MiscUtils.writeStreamToFile(inputStream, tempFile) + LogUtils.d(TAG, "Wrote to file $tempFile") + } + + repo.storeFile(tempFile, attachDir, fileName) + LogUtils.d(TAG, "Stored file to repo") + tempFile.delete() + } + /** * Loads book from resource. */ 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 6324fd1a2..e557ef683 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -696,6 +696,29 @@ public static String fileRelativeRoot(Context context) { ); } + public static String attachMethod(Context context) { + return getDefaultSharedPreferences(context).getString( + context.getResources().getString(R.string.pref_key_attach_method), + context.getResources().getString(R.string.pref_default_attach_method)); + } + + public static void attachMethod(Context context, String value) { + String key = context.getResources().getString(R.string.pref_key_attach_method); + getDefaultSharedPreferences(context).edit().putString(key, value).apply(); + } + + /** + * When attachMethod is `link`, this pref is not used for saving attachment. + * When attachMethod is `copy_dir`, this pref is the target for saving attachment. + * When attachMethod is `copy_id`, this pref is used as a prefix for saving attachment, used + * together with ID subdirectory. + */ + public static String attachDirDefaultPath(Context context) { + return getDefaultSharedPreferences(context).getString( + context.getResources().getString(R.string.pref_key_attach_dir_default_path), + "data"); + } + /* * Note's metadata visibility */ 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..cffde35ee 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -128,18 +128,25 @@ public VersionedRook retrieveBook(String fileName, File destinationFile) throws @Override public VersionedRook storeBook(File file, String fileName) throws IOException { + return storeFile(file, "", fileName); + } + + @Override + public VersionedRook storeFile(File file, String pathInRepo, String fileName) throws IOException { if (!file.exists()) { throw new FileNotFoundException("File " + file + " does not exist"); } + DocumentFile documentFile = createRecursive(repoDocumentFile, pathInRepo); + /* Delete existing file. */ - DocumentFile existingFile = repoDocumentFile.findFile(fileName); + DocumentFile existingFile = documentFile.findFile(fileName); if (existingFile != null) { existingFile.delete(); } /* Create new file. */ - DocumentFile destinationFile = repoDocumentFile.createFile("text/*", fileName); + DocumentFile destinationFile = documentFile.createFile("text/*", fileName); if (destinationFile == null) { throw new IOException("Failed creating " + fileName + " in " + repoUri); @@ -163,6 +170,27 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), uri, rev, mtime); } + private DocumentFile createRecursive(DocumentFile parent, String path) { + if (".".equals(path) || "".equals(path)) { + return parent; + } + int l = path.lastIndexOf('/'); + DocumentFile p; + if (l >= 0) { + p = createRecursive(parent, path.substring(0, l)); + } else { + p = parent; + } + String subdir = path.substring(l+1); + DocumentFile f = p.findFile(subdir); + if (f != null) { + // already exist, return it + return f; + } + // Otherwise, create the directory + return p.createDirectory(subdir); + } + @Override public VersionedRook renameBook(Uri from, String name) throws IOException { DocumentFile fromDocFile = DocumentFile.fromSingleUri(context, from); 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..15eabe610 100644 --- a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java @@ -66,6 +66,11 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return dbRepo.createBook(repoId, vrook, content); } + @Override + public VersionedRook storeFile(File file, String pathInRepo, String fileName) throws IOException { + throw new UnsupportedOperationException(); + } + @Override public VersionedRook renameBook(Uri fromUri, String name) { Uri toUri = UriUtils.getUriForNewName(fromUri, name); 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..8d5f4d224 100644 --- a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java @@ -158,6 +158,11 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return new VersionedRook(repoId, RepoType.DIRECTORY, repoUri, uri, rev, mtime); } + @Override + public VersionedRook storeFile(File file, String pathInRepo, String fileName) throws IOException { + return storeBook(file, pathInRepo + File.separator + fileName); + } + @Override public VersionedRook renameBook(Uri fromUri, String name) throws IOException { String fromFilePath = fromUri.getPath(); 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 b69949ba2..090258f8a 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -19,6 +19,7 @@ import com.orgzly.BuildConfig; import com.orgzly.android.BookName; import com.orgzly.android.prefs.AppPreferences; +import com.orgzly.android.util.LogUtils; import java.io.BufferedOutputStream; import java.io.File; @@ -190,7 +191,7 @@ public List getBooks(Uri repoUri) throws IOException { } } catch (DbxException e) { - e.printStackTrace(); + LogUtils.d(TAG, e.toString()); /* If we get NOT_FOUND from Dropbox, just return the empty list. */ if (e instanceof GetMetadataErrorException) { @@ -235,6 +236,7 @@ public VersionedRook download(Uri repoUri, String fileName, File localFile) thro } } catch (DbxException e) { + LogUtils.d(TAG, e.toString()); if (e.getMessage() != null) { throw new IOException("Failed downloading Dropbox file " + uri + ": " + e.getMessage()); } else { @@ -266,6 +268,7 @@ public VersionedRook upload(File file, Uri repoUri, String fileName) throws IOEx .uploadAndFinish(in); } catch (DbxException e) { + LogUtils.d(TAG, e.toString()); if (e.getMessage() != null) { throw new IOException("Failed overwriting " + bookUri.getPath() + " on Dropbox: " + e.getMessage()); } else { @@ -290,7 +293,7 @@ public void delete(String path) throws IOException { } } catch (DbxException e) { - e.printStackTrace(); + LogUtils.d(TAG, e.toString()); if (e.getMessage() != null) { throw new IOException("Failed deleting " + path + " on Dropbox: " + e.getMessage()); @@ -319,7 +322,7 @@ public VersionedRook move(Uri repoUri, Uri from, Uri to) throws IOException { return new VersionedRook(repoId, RepoType.DROPBOX, repoUri, to, rev, mtime); } catch (Exception e) { - e.printStackTrace(); + LogUtils.d(TAG, e.toString()); if (e.getMessage() != null) { // TODO: Move this throwing to utils throw new IOException("Failed moving " + from + " to " + to + ": " + e.getMessage(), 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 deleted file mode 100644 index 484da45a6..000000000 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.orgzly.android.repos; - -import android.content.Context; -import android.net.Uri; - -import com.orgzly.android.util.UriUtils; - -import java.io.File; -import java.io.IOException; - -import java.util.List; - -public class DropboxRepo implements SyncRepo { - public static final String SCHEME = "dropbox"; - - private final Uri repoUri; - private final DropboxClient client; - - public DropboxRepo(RepoWithProps repoWithProps, Context context) { - this.repoUri = Uri.parse(repoWithProps.getRepo().getUrl()); - this.client = new DropboxClient(context, repoWithProps.getRepo().getId()); - } - - @Override - public boolean isConnectionRequired() { - return true; - } - - @Override - public boolean isAutoSyncSupported() { - return false; - } - - @Override - public Uri getUri() { - return repoUri; - } - - @Override - public List getBooks() throws IOException { - return client.getBooks(repoUri); - } - - @Override - public VersionedRook retrieveBook(String fileName, File file) throws IOException { - return client.download(repoUri, fileName, file); - } - - @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { - return client.upload(file, repoUri, fileName); - } - - @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { - Uri toUri = UriUtils.getUriForNewName(fromUri, name); - return client.move(repoUri, fromUri, toUri); - } - - @Override - public void delete(Uri uri) throws IOException { - client.delete(uri.getPath()); - } - - @Override - public String toString() { - return repoUri.toString(); - } -} diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.kt b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.kt new file mode 100644 index 000000000..6184e20d2 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.kt @@ -0,0 +1,74 @@ +package com.orgzly.android.repos + +import android.content.Context +import android.net.Uri +import kotlin.Throws +import com.orgzly.android.util.UriUtils +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException + +class DropboxRepo(repoWithProps: RepoWithProps, context: Context?) : SyncRepo { + private val repoUri: Uri + private val client: DropboxClient + override fun isConnectionRequired(): Boolean { + return true + } + + override fun isAutoSyncSupported(): Boolean { + return false + } + + override fun getUri(): Uri { + return repoUri + } + + @Throws(IOException::class) + override fun getBooks(): List { + return client.getBooks(repoUri) + } + + @Throws(IOException::class) + override fun retrieveBook(fileName: String, file: File): VersionedRook { + return client.download(repoUri, fileName, file) + } + + @Throws(IOException::class) + override fun storeBook(file: File, fileName: String): VersionedRook { + return client.upload(file, repoUri, fileName) + } + + @Throws(IOException::class) + override fun storeFile(file: File, pathInRepo: String, fileName: String): VersionedRook { + if (file == null || !file.exists()) { + throw FileNotFoundException("File $file does not exist") + } + + val folderUri = Uri.withAppendedPath(uri, pathInRepo) + return client.upload(file, folderUri, fileName) + } + + @Throws(IOException::class) + override fun renameBook(fromUri: Uri, name: String): VersionedRook { + val toUri = UriUtils.getUriForNewName(fromUri, name) + return client.move(repoUri, fromUri, toUri) + } + + @Throws(IOException::class) + override fun delete(uri: Uri) { + client.delete(uri.path) + } + + override fun toString(): String { + return repoUri.toString() + } + + companion object { + const val SCHEME = "dropbox" + } + + init { + repoUri = Uri.parse(repoWithProps.repo.url) + client = DropboxClient(context, repoWithProps.repo.id) + } +} \ No newline at end of file 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 b7f66efcf..1315d2f3a 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -195,6 +195,11 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(fileName).build()); } + @Override + public VersionedRook storeFile(File file, String pathInRepo, String fileName) throws IOException { + throw new UnsupportedOperationException(); + } + private RevWalk walk() { return new RevWalk(git.getRepository()); } 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..7b295b850 100644 --- a/app/src/main/java/com/orgzly/android/repos/MockRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/MockRepo.java @@ -62,6 +62,12 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { return databaseRepo.storeBook(file, fileName); } + @Override + public VersionedRook storeFile(File file, String pathInRepo, String fileName) throws IOException { + SystemClock.sleep(SLEEP_FOR_STORE_BOOK); + return databaseRepo.storeFile(file, pathInRepo, fileName); + } + @Override public VersionedRook renameBook(Uri fromUri, String name) throws IOException { SystemClock.sleep(SLEEP_FOR_STORE_BOOK); 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..1fc824d40 100644 --- a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java @@ -39,6 +39,16 @@ public interface SyncRepo { */ VersionedRook storeBook(File file, String fileName) throws IOException; + /** + * Uploads file storing it under directory (pathInRepo) under repo's url. + * @param file The contents of this file should be stored at the remote location/repo + * @param pathInRepo The "/" separated path within the remote location/repo, create it if it doesn't exist + * @param fileName The contents ({@code file}) should be stored under this name + * @return {@code VersionedRook} + * @throws IOException + */ + VersionedRook storeFile(File file, String pathInRepo, String fileName) throws IOException; + VersionedRook renameBook(Uri from, String name) 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 e8bcd01e5..a774be92f 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 @@ -194,6 +195,39 @@ class WebdavRepo( return sardine.list(fileUrl).first().toVersionedRook() } + override fun storeFile(file: File?, pathInRepo: String?, fileName: String?): VersionedRook { + if (file == null || !file.exists()) { + throw FileNotFoundException("File $file does not exist") + } + + val folderUri = Uri.withAppendedPath(uri, pathInRepo) + val fileUrl = Uri.withAppendedPath(folderUri, fileName).toUrl() + + createRecursive(uri.toUrl(), pathInRepo!!) + + sardine.put(fileUrl, file, null) + + return sardine.list(fileUrl).first().toVersionedRook() + } + + private fun createRecursive(parent: String, path: String): String { + if ("." == path || "" == path) { + return parent + } + val l = path.lastIndexOf('/') + val p = if (l >= 0) { + createRecursive(parent, path.substring(0, l)) + } else { + parent + } + val subdir = path.substring(l + 1) + val folder = p + "/" + subdir + if (!sardine.exists(folder)) { + sardine.createDirectory(folder) + } + return folder + } + override fun renameBook(from: Uri, name: String?): VersionedRook { val destUrl = UriUtils.getUriForNewName(from, name).toUrl() sardine.move(from.toUrl(), destUrl) diff --git a/app/src/main/java/com/orgzly/android/ui/AttachmentSpanLoader.kt b/app/src/main/java/com/orgzly/android/ui/AttachmentSpanLoader.kt new file mode 100644 index 000000000..982ead381 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/AttachmentSpanLoader.kt @@ -0,0 +1,19 @@ +package com.orgzly.android.ui + +import android.text.Spannable +import com.orgzly.android.ui.views.richtext.RichText +import com.orgzly.android.ui.views.style.AttachmentLinkSpan +import com.orgzly.android.usecase.FindAttachmentPath +import com.orgzly.android.usecase.UseCaseRunner + +object AttachmentSpanLoader { + /** Find all `attachment:` links and set up the prefix directory based on `ID` property. */ + fun loadAttachmentPaths(noteId: Long, richText: RichText) { + SpanUtils.forEachSpan(richText.getVisibleText() as Spannable, AttachmentLinkSpan::class.java) { span, _, _ -> + val prefix = UseCaseRunner.run(FindAttachmentPath(noteId)).userData + if (prefix != null) { + span.prefix = prefix as String + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/ImageLoader.kt b/app/src/main/java/com/orgzly/android/ui/ImageLoader.kt index 536d5a3b5..9ed1b0c61 100644 --- a/app/src/main/java/com/orgzly/android/ui/ImageLoader.kt +++ b/app/src/main/java/com/orgzly/android/ui/ImageLoader.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.text.Spannable +import android.text.style.ClickableSpan import android.text.style.ImageSpan import android.view.View import android.widget.TextView @@ -21,6 +22,7 @@ import com.orgzly.R import com.orgzly.android.App import com.orgzly.android.prefs.AppPreferences import com.orgzly.android.ui.views.style.FileLinkSpan +import com.orgzly.android.ui.views.style.AttachmentLinkSpan import com.orgzly.android.usecase.LinkFindTarget import com.orgzly.android.usecase.UseCaseRunner import com.orgzly.android.util.AppPermissions @@ -39,14 +41,16 @@ object ImageLoader { && AppPermissions.isGranted(context, AppPermissions.Usage.EXTERNAL_FILES_ACCESS)) { // Load the associated image for each FileLinkSpan SpanUtils.forEachSpan(textWithMarkup.text as Spannable, FileLinkSpan::class.java) { span, _, _ -> - loadImage(textWithMarkup, span) + loadImage(textWithMarkup, span, span.path) + } + // Load the associated image for each AttachmentLinkSpan + SpanUtils.forEachSpan(textWithMarkup.text as Spannable, AttachmentLinkSpan::class.java) { span, _, _ -> + loadImage(textWithMarkup, span, span.getPrefixedPath()) } } } - private fun loadImage(textWithMarkup: TextView, fileLinkSpan: FileLinkSpan) { - val path = fileLinkSpan.path - + private fun loadImage(textWithMarkup: TextView, fileLinkSpan: ClickableSpan, path: String) { if (hasSupportedExtension(path)) { val text = textWithMarkup.text as Spannable // Get the current context diff --git a/app/src/main/java/com/orgzly/android/ui/note/NoteBuilder.kt b/app/src/main/java/com/orgzly/android/ui/note/NoteBuilder.kt index 16bd1b888..a21db4fa2 100644 --- a/app/src/main/java/com/orgzly/android/ui/note/NoteBuilder.kt +++ b/app/src/main/java/com/orgzly/android/ui/note/NoteBuilder.kt @@ -1,6 +1,7 @@ package com.orgzly.android.ui.note import android.content.Context +import android.net.Uri import com.orgzly.android.db.entity.NoteProperty import com.orgzly.android.db.entity.NoteView import com.orgzly.android.prefs.AppPreferences @@ -99,7 +100,7 @@ class NoteBuilder { } @JvmStatic - fun newPayload(context: Context, title: String, content: String?): NotePayload { + fun newPayload(context: Context, title: String, content: String?, attachmentUri: Uri?): NotePayload { val scheduled = initialScheduledTime(context) @@ -109,7 +110,8 @@ class NoteBuilder { title = title, content = content, state = state, - scheduled = scheduled + scheduled = scheduled, + attachmentUri = attachmentUri ) } diff --git a/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt b/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt index aaf4cf65b..b59bbd645 100644 --- a/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/note/NoteFragment.kt @@ -3,6 +3,7 @@ package com.orgzly.android.ui.note import android.content.Context import android.content.Intent import android.graphics.Typeface +import android.net.Uri import android.os.Bundle import android.text.Editable import android.text.TextUtils @@ -106,8 +107,9 @@ class NoteFragment : Fragment(), View.OnClickListener, TimestampDialogFragment.O // Initial values when sharing val title = args.getString(ARG_TITLE) val content = args.getString(ARG_CONTENT) + val attachmentUri = args.getString(ARG_ATTACHMENT_URI)?.let { Uri.parse(it) } - return NoteInitialData(bookId, noteId, place, title, content) + return NoteInitialData(bookId, noteId, place, title, content, attachmentUri) } } @@ -451,6 +453,7 @@ class NoteFragment : Fragment(), View.OnClickListener, TimestampDialogFragment.O addPropertyToList(null, null) // Content + binding.content.noteId = viewModel.noteId binding.content.setSourceText(payload.content) } @@ -1083,21 +1086,24 @@ class NoteFragment : Fragment(), View.OnClickListener, TimestampDialogFragment.O private const val ARG_PLACE = "place" private const val ARG_TITLE = "title" private const val ARG_CONTENT = "content" + private const val ARG_ATTACHMENT_URI = "attachment_uri" @JvmStatic @JvmOverloads fun forNewNote( - notePlace: NotePlace, - initialTitle: String? = null, - initialContent: String? = null): NoteFragment? { + notePlace: NotePlace, + initialTitle: String? = null, + initialContent: String? = null, + attachmentUri: Uri? = null): NoteFragment? { return if (notePlace.bookId > 0) { getInstance( - notePlace.bookId, - notePlace.noteId, - notePlace.place, - initialTitle, - initialContent) + notePlace.bookId, + notePlace.noteId, + notePlace.place, + initialTitle, + initialContent, + attachmentUri) } else { Log.e(TAG, "Invalid book id ${notePlace.bookId}") null @@ -1116,11 +1122,12 @@ class NoteFragment : Fragment(), View.OnClickListener, TimestampDialogFragment.O @JvmStatic private fun getInstance( - bookId: Long, - noteId: Long, - place: Place? = null, - initialTitle: String? = null, - initialContent: String? = null): NoteFragment { + bookId: Long, + noteId: Long, + place: Place? = null, + initialTitle: String? = null, + initialContent: String? = null, + attachmentUri: Uri? = null): NoteFragment { val fragment = NoteFragment() @@ -1144,6 +1151,10 @@ class NoteFragment : Fragment(), View.OnClickListener, TimestampDialogFragment.O args.putString(ARG_CONTENT, initialContent) } + if (attachmentUri != null) { + args.putString(ARG_ATTACHMENT_URI, attachmentUri.toString()) + } + fragment.arguments = args return fragment diff --git a/app/src/main/java/com/orgzly/android/ui/note/NotePayload.kt b/app/src/main/java/com/orgzly/android/ui/note/NotePayload.kt index dbd07f01f..1796ba574 100644 --- a/app/src/main/java/com/orgzly/android/ui/note/NotePayload.kt +++ b/app/src/main/java/com/orgzly/android/ui/note/NotePayload.kt @@ -1,7 +1,12 @@ package com.orgzly.android.ui.note +import android.content.Context +import android.net.Uri import android.os.Parcel import android.os.Parcelable +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.share.ShareActivity +import com.orgzly.android.util.AttachmentUtils import com.orgzly.org.OrgProperties data class NotePayload @JvmOverloads constructor( @@ -13,7 +18,8 @@ data class NotePayload @JvmOverloads constructor( val deadline: String? = null, val closed: String? = null, val tags: List = emptyList(), - val properties: OrgProperties = OrgProperties() + val properties: OrgProperties = OrgProperties(), + val attachmentUri: Uri? = null ) : Parcelable { override fun describeContents(): Int { @@ -40,6 +46,22 @@ data class NotePayload @JvmOverloads constructor( out.writeString(property.value) } } + + out.writeString(attachmentUri.toString()) + } + + /** Returns the path to store the attachment. */ + fun attachDir(context: Context): String { + val idStr = properties.get("ID") + // TODO idStr could be null. Throw a warning exception, show a toast, don't attach anything + when(AppPreferences.attachMethod(context)) { + ShareActivity.ATTACH_METHOD_LINK -> return "" + ShareActivity.ATTACH_METHOD_COPY_DIR -> return AppPreferences.attachDirDefaultPath(context) + ShareActivity.ATTACH_METHOD_COPY_ID -> { + return AttachmentUtils.getAttachDir(context, idStr) + } + } + return "" } companion object { @@ -69,6 +91,8 @@ data class NotePayload @JvmOverloads constructor( properties.put(name!!, value!!) } + val attachmentUri: Uri? = parcel.readString()?.let { Uri.parse(it) } + return NotePayload( title!!, content, @@ -78,7 +102,8 @@ data class NotePayload @JvmOverloads constructor( deadline, closed, tags, - properties + properties, + attachmentUri ) } diff --git a/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt b/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt index 362023848..a233d9de8 100644 --- a/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt +++ b/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt @@ -1,5 +1,6 @@ package com.orgzly.android.ui.note +import android.net.Uri import android.os.Bundle import android.text.TextUtils import androidx.lifecycle.LiveData @@ -17,18 +18,21 @@ import com.orgzly.android.ui.NotePlace import com.orgzly.android.ui.Place import com.orgzly.android.ui.SingleLiveEvent import com.orgzly.android.ui.main.MainActivity +import com.orgzly.android.ui.share.ShareActivity import com.orgzly.android.usecase.* import com.orgzly.android.util.MiscUtils import com.orgzly.org.OrgProperties import com.orgzly.org.datetime.OrgRange import com.orgzly.org.parser.OrgParserWriter +import java.util.* data class NoteInitialData( val bookId: Long, val noteId: Long, // Could be 0 if new note is being created val place: Place? = null, // Relative location, used for new notes val title: String? = null, // Initial title, used for when sharing - val content: String? = null // Initial content, used for when sharing + val content: String? = null, // Initial content, used for when sharing + val attachmentUri: Uri? = null // Initial attachment Uri, used for when sharing ) class NoteViewModel( @@ -40,6 +44,7 @@ class NoteViewModel( private val place = initialData.place private val title = initialData.title private val content = initialData.content + private val attachmentUri = initialData.attachmentUri val bookView: MutableLiveData = MutableLiveData() @@ -75,10 +80,14 @@ class NoteViewModel( dataRepository.getNoteAncestors(noteId) } - notePayload = if (isNew()) { - NoteBuilder.newPayload(App.getAppContext(), title.orEmpty(), content) + if (isNew()) { + notePayload = NoteBuilder.newPayload(App.getAppContext(), title.orEmpty(), content, attachmentUri) + // Auto generate ID property if it has attachment. + if (attachmentUri != null && AppPreferences.attachMethod(App.getAppContext()) == ShareActivity.ATTACH_METHOD_COPY_ID) { + updatePayloadCreateIdProperty() + } } else { - dataRepository.getNotePayload(noteId) + notePayload = dataRepository.getNotePayload(noteId) } // Calculate payload's hash once for the original note @@ -164,6 +173,15 @@ class NoteViewModel( notePayload = notePayload?.copy(closed = range?.toString()) } + private fun updatePayloadCreateIdProperty() { + if (notePayload?.properties!!.containsKey("ID")) { + return + } + notePayload = notePayload?.copy() + val idStr = UUID.randomUUID().toString().uppercase() + notePayload?.properties!!.put("ID", idStr) + } + private fun createNote(postSave: ((note: Note) -> Unit)?) { val notePlace = if (place != Place.UNSPECIFIED) NotePlace(bookId, noteId, place) diff --git a/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt b/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt index 9391f8263..bae67c49e 100644 --- a/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt +++ b/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt @@ -12,6 +12,7 @@ import com.orgzly.android.App import com.orgzly.android.db.entity.Note import com.orgzly.android.db.entity.NoteView import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.AttachmentSpanLoader import com.orgzly.android.ui.TimeType import com.orgzly.android.ui.util.TitleGenerator import com.orgzly.android.ui.util.styledAttributes @@ -102,6 +103,7 @@ class NoteItemViewBinder(private val context: Context, private val inBook: Boole holder.binding.itemHeadContent.setTypeface(Typeface.MONOSPACE) } + holder.binding.itemHeadContent.noteId = note.id holder.binding.itemHeadContent.setSourceText(note.content) /* If content changes (for example by toggling the checkbox), update the note. */ diff --git a/app/src/main/java/com/orgzly/android/ui/share/ShareActivity.java b/app/src/main/java/com/orgzly/android/ui/share/ShareActivity.java index c796f0e65..168882e03 100644 --- a/app/src/main/java/com/orgzly/android/ui/share/ShareActivity.java +++ b/app/src/main/java/com/orgzly/android/ui/share/ShareActivity.java @@ -13,6 +13,7 @@ import androidx.core.app.TaskStackBuilder; import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.documentfile.provider.DocumentFile; import com.orgzly.BuildConfig; import com.orgzly.R; @@ -22,6 +23,7 @@ import com.orgzly.android.db.entity.Book; import com.orgzly.android.db.entity.Note; import com.orgzly.android.db.entity.SavedSearch; +import com.orgzly.android.prefs.AppPreferences; import com.orgzly.android.query.Query; import com.orgzly.android.query.QueryUtils; import com.orgzly.android.query.user.DottedQueryParser; @@ -37,6 +39,7 @@ import com.orgzly.android.usecase.UseCaseResult; import com.orgzly.android.util.LogUtils; import com.orgzly.android.util.MiscUtils; +import com.orgzly.org.OrgStringUtils; import java.io.File; import java.io.IOException; @@ -58,6 +61,10 @@ public class ShareActivity extends CommonActivity public static final String TAG = ShareActivity.class.getName(); + public static final String ATTACH_METHOD_LINK = "link"; + public static final String ATTACH_METHOD_COPY_DIR = "copy_dir"; + public static final String ATTACH_METHOD_COPY_ID = "copy_id"; + /** Shared text files are read and their content is stored as note content. */ private static final long MAX_TEXT_FILE_LENGTH_FOR_CONTENT = 1024 * 1024 * 2; // 2 MB @@ -169,11 +176,13 @@ public Data getDataFromIntent(Intent intent) { data.bookId = SharingShortcutsManager.bookIdFromShortcutId(shortcutId); } - } else if (type.startsWith("image/")) { - handleSendImage(intent, data); // Handle single image being sent - + } else if (ATTACH_METHOD_COPY_DIR.equals(AppPreferences.attachMethod(this))) { + handleCopyFile(intent, data, "file:"); + } else if (ATTACH_METHOD_COPY_ID.equals(AppPreferences.attachMethod(this))) { + handleCopyFile(intent, data, "attachment:"); } else { - mError = getString(R.string.share_type_not_supported, type); + // Link method. + handleLinkFile(intent, data); } } else if (action.equals("com.google.android.gm.action.AUTO_SEND")) { @@ -190,6 +199,9 @@ public Data getDataFromIntent(Intent intent) { data.title = ""; } + if (BuildConfig.LOG_DEBUG) + LogUtils.d(TAG, "Data title: " + data.title + " attachmentUri: " + data.attachmentUri); + return data; } @@ -214,7 +226,7 @@ private void setupFragments(Bundle savedInstanceState, Data data) { } noteFragment = NoteFragment.forNewNote( - new NotePlace(bookId), data.title, data.content); + new NotePlace(bookId), data.title, data.content, data.attachmentUri); getSupportFragmentManager() .beginTransaction() @@ -310,14 +322,15 @@ public void onError(UseCase action, Throwable throwable) { private class Data { String title; String content; + public Uri attachmentUri; Long bookId = null; } /** - * Get file path from image shared with Orgzly + * Get file path shared with Orgzly * and put it as a file link in the note's content. */ - private void handleSendImage(Intent intent, Data data) { + private void handleLinkFile(Intent intent, Data data) { // Get file uri from intent which probably looks like this: // content://media/external/images/... Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); @@ -344,8 +357,7 @@ private void handleSendImage(Intent intent, Data data) { if (data.content == null) { data.content = uri.toString() - + "\n\nCannot determine path to this image " - + "and only linking to an image is currently supported."; + + "\n\nCannot determine a local path to this file."; Log.e(TAG, DatabaseUtils.dumpCursorToString(cursor)); } @@ -362,7 +374,29 @@ private void handleSendImage(Intent intent, Data data) { if (data.title == null) { data.title = uri.toString(); - data.content = "Cannot find image using this URI."; + data.content = "Cannot find filename using this URI."; + } + } + + private void handleCopyFile(Intent intent, Data data, String linkPrefix) { + Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + + // Get the file name of the content. + DocumentFile documentFile = DocumentFile.fromSingleUri(this, uri); + String fileName = null; + if (documentFile != null) { + fileName = documentFile.getName(); + } + if (!OrgStringUtils.isEmpty(fileName)) { + data.title = fileName; + data.content = "[[" + linkPrefix + fileName + "]]"; + } else { + data.title = uri.toString(); + data.content = uri.toString() + "\n\nCannot determine fileName to this content."; } + + // Don't copy the file here, only copy it when a note is saved. + // Let's pass the Uri to NoteFragment. + data.attachmentUri = uri; } } diff --git a/app/src/main/java/com/orgzly/android/ui/views/richtext/RichText.kt b/app/src/main/java/com/orgzly/android/ui/views/richtext/RichText.kt index f40ed8a57..ad4693a15 100644 --- a/app/src/main/java/com/orgzly/android/ui/views/richtext/RichText.kt +++ b/app/src/main/java/com/orgzly/android/ui/views/richtext/RichText.kt @@ -16,6 +16,7 @@ import android.widget.TextView import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.AttachmentSpanLoader import com.orgzly.android.ui.ImageLoader import com.orgzly.android.ui.main.MainActivity import com.orgzly.android.ui.util.styledAttributes @@ -65,6 +66,7 @@ class RichText(context: Context, attrs: AttributeSet?) : private val richTextEdit: RichTextEdit private val richTextView: RichTextView + var noteId: Long = 0 init { parseAttrs(attrs) @@ -182,6 +184,10 @@ class RichText(context: Context, attrs: AttributeSet?) : richTextView.text = text } + fun getVisibleText(): CharSequence? { + return richTextView.text + } + fun toEditMode(charOffset: Int) { if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "editable:${attributes.editable}", charOffset) @@ -209,6 +215,7 @@ class RichText(context: Context, attrs: AttributeSet?) : richTextView.setText(parsed, TextView.BufferType.SPANNABLE) + AttachmentSpanLoader.loadAttachmentPaths(noteId, this) ImageLoader.loadImages(richTextView) } else { diff --git a/app/src/main/java/com/orgzly/android/ui/views/style/AttachmentLinkSpan.kt b/app/src/main/java/com/orgzly/android/ui/views/style/AttachmentLinkSpan.kt new file mode 100644 index 000000000..acb8abb51 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/views/style/AttachmentLinkSpan.kt @@ -0,0 +1,30 @@ +package com.orgzly.android.ui.views.style + +import android.os.Handler +import android.text.style.ClickableSpan +import android.view.View +import com.orgzly.android.ui.views.richtext.ActionableRichTextView + +/** + * This [ClickableSpan] corresponds to "attachment:" link. What comes after `:` is `path`. The full + * path also needs a prefix which is derived from `ID` property for example. + */ +class AttachmentLinkSpan(val path: String) : ClickableSpan() { + var prefix: String? = null + + override fun onClick(widget: View) { + if (widget is ActionableRichTextView && prefix != null) { + Handler().post { // Run after onClick to prevent Snackbar from closing immediately + widget.followLinkToFile(getPrefixedPath()) + } + } + } + + fun getPrefixedPath(): String { + return "$prefix/$path" + } + + companion object { + const val PREFIX = "attachment:" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/usecase/FindAttachmentPath.kt b/app/src/main/java/com/orgzly/android/usecase/FindAttachmentPath.kt new file mode 100644 index 000000000..12af13b9a --- /dev/null +++ b/app/src/main/java/com/orgzly/android/usecase/FindAttachmentPath.kt @@ -0,0 +1,40 @@ +package com.orgzly.android.usecase + +import android.content.Context +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.entity.NoteProperty +import com.orgzly.android.util.AttachmentUtils + +/** + * An [UseCase] that finds the attachment directory path with the given [noteId]. + * Corresponds to `org-attach-dir`. Currently checks the ID property of the given node. + * + * Note that this finds the expected directory path, even if the directory doesn't exist. + * + * TODO: Also check DIR property. + * TODO: Also check inherited property, based on a preference as in `org-attach-use-inheritance` + */ +class FindAttachmentPath(val noteId: Long) : UseCase() { + val context: Context = App.getAppContext(); + + override fun run(dataRepository: DataRepository): UseCaseResult { + val noteProperties = dataRepository.getNoteProperties(noteId) + val idStr = getProperty(noteProperties, "ID") + + val path = if (idStr == null) null else AttachmentUtils.getAttachDir(context, idStr) + + return UseCaseResult( + userData = path + ) + } + + private fun getProperty(noteProperties: List, propertyName: String): String? { + for (property: NoteProperty in noteProperties) { + if (property.name == propertyName) { + return property.value + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/usecase/NoteCreate.kt b/app/src/main/java/com/orgzly/android/usecase/NoteCreate.kt index 8dac5a4cc..d6e3b661d 100644 --- a/app/src/main/java/com/orgzly/android/usecase/NoteCreate.kt +++ b/app/src/main/java/com/orgzly/android/usecase/NoteCreate.kt @@ -8,6 +8,12 @@ class NoteCreate(val notePayload: NotePayload, val notePlace: NotePlace) : UseCa override fun run(dataRepository: DataRepository): UseCaseResult { val note = dataRepository.createNote(notePayload, notePlace) + if (notePayload.attachmentUri != null) { + dataRepository.storeAttachment( + notePlace.bookId, + notePayload) + } + return UseCaseResult( modifiesLocalData = true, triggersSync = SYNC_NOTE_CREATED, diff --git a/app/src/main/java/com/orgzly/android/util/AttachmentUtils.kt b/app/src/main/java/com/orgzly/android/util/AttachmentUtils.kt new file mode 100644 index 000000000..c8e682dfc --- /dev/null +++ b/app/src/main/java/com/orgzly/android/util/AttachmentUtils.kt @@ -0,0 +1,15 @@ +package com.orgzly.android.util + +import android.content.Context +import com.orgzly.android.prefs.AppPreferences + +object AttachmentUtils { + /** Returns the attachment directory based on ID property. */ + fun getAttachDir(context: Context, idStr: String) : String { + return if (idStr.length <= 2) { + AppPreferences.attachDirDefaultPath(context) + "/" + idStr.substring(0, 2) + } else { + AppPreferences.attachDirDefaultPath(context) + "/" + idStr.substring(0, 2) + "/" + idStr.substring(2) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/util/OrgFormatter.kt b/app/src/main/java/com/orgzly/android/util/OrgFormatter.kt index 66cb3ba36..fc96ef2b4 100644 --- a/app/src/main/java/com/orgzly/android/util/OrgFormatter.kt +++ b/app/src/main/java/com/orgzly/android/util/OrgFormatter.kt @@ -23,7 +23,7 @@ object OrgFormatter { private const val SYSTEM_LINK_SCHEMES = "https?|mailto|tel|voicemail|geo|sms|smsto|mms|mmsto" - private const val CUSTOM_LINK_SCHEMES = "id|file" + private const val CUSTOM_LINK_SCHEMES = "id|file|attachment" // Supported link schemas for plain links private const val LINK_SCHEMES = "(?:$SYSTEM_LINK_SCHEMES|$CUSTOM_LINK_SCHEMES)" @@ -191,6 +191,9 @@ object OrgFormatter { link.startsWith(CustomIdLinkSpan.PREFIX) -> CustomIdLinkSpan(linkType, link, name) + link.startsWith(AttachmentLinkSpan.PREFIX) -> + AttachmentLinkSpan(link.substring(11)) + link.matches("^(?:$SYSTEM_LINK_SCHEMES):.+".toRegex()) -> UrlLinkSpan(linkType, link, name) diff --git a/app/src/main/res/values/prefs_keys.xml b/app/src/main/res/values/prefs_keys.xml index c55b37180..34e555e03 100644 --- a/app/src/main/res/values/prefs_keys.xml +++ b/app/src/main/res/values/prefs_keys.xml @@ -462,6 +462,22 @@ pref_key_file_relative_root + pref_key_attach_method + copy_id + + @string/attach_method_link + @string/attach_method_copy_dir + @string/attach_method_copy_id + + + link + copy_dir + copy_id + + + pref_key_attach_dir_default_path + . + pref_key_separate_notes_with_new_line @string/pref_value_separate_notes_with_new_line_multi_line_notes_only diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b395b37c..effed02aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -707,6 +707,13 @@ Prepend Insert new note at beginning + Method used to add attachments + Link + Copy to the attachment directory root + Copy to ID property based directory under attachment directory + + Attachment directory, a relative path under the repository + Developer options Git repository type In development diff --git a/app/src/main/res/xml/prefs_screen_notebooks.xml b/app/src/main/res/xml/prefs_screen_notebooks.xml index 931bbdd4c..c71787224 100644 --- a/app/src/main/res/xml/prefs_screen_notebooks.xml +++ b/app/src/main/res/xml/prefs_screen_notebooks.xml @@ -193,6 +193,23 @@ android:maxLines="1" app:useSimpleSummaryProvider="true"/> + + + +