diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java index 4197451..61de9c7 100644 --- a/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java @@ -518,16 +518,7 @@ public void writeMarkdownDocument(PluginCall call) { } private void handleIncomingIntent(Intent intent) { - if (intent == null) { - return; - } - - String action = intent.getAction(); - if ( - !Intent.ACTION_VIEW.equals(action) && - !Intent.ACTION_SEND.equals(action) && - !Intent.ACTION_SEND_MULTIPLE.equals(action) - ) { + if (intent == null || !IncomingIntentParser.isIncomingAction(intent.getAction())) { return; } @@ -535,19 +526,24 @@ private void handleIncomingIntent(Intent intent) { return; } - if (Intent.ACTION_VIEW.equals(action)) { - handleOpenWithIntent(intent); - return; - } - - if (Intent.ACTION_SEND.equals(action)) { - handleShareIntent(intent); - return; - } - - if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - Log.w(TAG, "Rejected Android multi-share intent"); - notifyShareRejected("UNSUPPORTED_SHARE_DOCUMENT", "Share one Markdown file at a time"); + IncomingIntentParser.Result result = IncomingIntentParser.parse(intent); + switch (result.kind) { + case OPEN_WITH_DOCUMENT: + emitOpenWithDocument(result.uri, intent); + return; + case SHARE_STREAM: + emitSharedStream(result.uri, intent); + return; + case SHARE_TEXT: + emitSharedText(result.text, result.mimeType, result.rawTitle); + return; + case REJECTED_OPEN_WITH: + notifyOpenWithRejected(result.code, result.message); + return; + case REJECTED_SHARE: + notifyShareRejected(result.code, result.message); + return; + default: } } @@ -562,29 +558,7 @@ private boolean markIncomingIntentForHandling(Intent intent) { return true; } - private void handleOpenWithIntent(Intent intent) { - if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) { - return; - } - - Uri uri = intent.getData(); - if (uri == null) { - notifyOpenWithRejected("DOCUMENT_URI_MISSING", "Android open-with intent returned no URI"); - return; - } - - if (!hasOnlyAllowedViewCategories(intent)) { - Log.w(TAG, "Rejected Android open-with intent with unsupported categories"); - notifyOpenWithRejected("INVALID_OPEN_WITH_INTENT", "This Android open-with request is not supported"); - return; - } - - if (!"content".equals(uri.getScheme())) { - Log.w(TAG, "Rejected Android open-with URI with unsupported scheme: " + safeForLog(uri.getScheme())); - notifyOpenWithRejected("INVALID_SOURCE_URI", "A valid content URI is required"); - return; - } - + private void emitOpenWithDocument(Uri uri, Intent intent) { try { JSObject document = buildOpenWithDocumentResult(uri, intent); if ((intent.getFlags() & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0) { @@ -611,59 +585,7 @@ private void handleOpenWithIntent(Intent intent) { notifyOpenWithRejected("DOCUMENT_READ_FAILED", "Failed to read Android document"); } } - - private void handleShareIntent(Intent intent) { - if (intent == null || !Intent.ACTION_SEND.equals(intent.getAction())) { - return; - } - - if (!hasOnlyAllowedShareCategories(intent)) { - Log.w(TAG, "Rejected Android share intent with unsupported categories"); - notifyShareRejected("INVALID_SHARE_INTENT", "This Android share request is not supported"); - return; - } - - Uri streamUri; - try { - streamUri = getSharedStreamUri(intent); - } catch (DocumentReadException ex) { - Log.w(TAG, "Android share stream extra rejected: " + ex.getMessage()); - notifyShareRejected(ex.code, ex.getMessage()); - return; - } - - if (streamUri != null) { - handleSharedStream(streamUri, intent); - return; - } - - CharSequence sharedText; - try { - sharedText = getSharedText(intent); - } catch (DocumentReadException ex) { - Log.w(TAG, "Android share text extra rejected: " + ex.getMessage()); - notifyShareRejected(ex.code, ex.getMessage()); - return; - } - - if (sharedText != null && sharedText.length() > 0) { - handleSharedText(sharedText.toString(), intent); - return; - } - - notifyShareRejected("SHARE_CONTENT_MISSING", "This Android share did not include Markdown content"); - } - - private void handleSharedStream(Uri uri, Intent intent) { - if (!"content".equals(uri.getScheme())) { - Log.w(TAG, "Rejected Android shared URI with unsupported scheme: " + safeForLog(uri.getScheme())); - notifyShareRejected( - "INVALID_SHARE_SOURCE_URI", - "This Android share did not provide a supported file URI" - ); - return; - } - + private void emitSharedStream(Uri uri, Intent intent) { try { JSObject document = buildSharedStreamDocumentResult(uri, intent); if ((intent.getFlags() & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0) { @@ -691,39 +613,27 @@ private void handleSharedStream(Uri uri, Intent intent) { } } - private void handleSharedText(String markdown, Intent intent) { - String mimeType = normalizeMimeType(intent.getType()); - if (!isSharedTextMimeType(mimeType)) { - Log.w(TAG, "Rejected Android shared text with unsupported MIME type: " + safeForLog(mimeType)); - notifyShareRejected("UNSUPPORTED_SHARE_DOCUMENT", "Share Markdown text or a Markdown file"); - return; - } - - try { - MarkdownCodec.validateBytes(markdown); - String displayName = getSharedTextDisplayName(intent); - JSObject document = new JSObject(); - document.put("canceled", false); - document.put("sourceUri", JSObject.NULL); - document.put("displayName", displayName); - document.put("providerName", "Android share"); - document.put("pathHint", displayName); - document.put("mimeType", mimeType.length() > 0 ? mimeType : "text/plain"); - document.put("markdown", markdown); - putMarkdownEncodingMetadata(document, defaultMarkdownEncoding, false); - document.put("canWrite", false); - document.put("persisted", false); - document.put("shareKind", "text"); + private void emitSharedText(String markdown, String mimeType, String rawTitle) { + // The parser already gated the MIME type and validated the byte size. + String displayName = normalizeSuggestedMarkdownName(rawTitle); + JSObject document = new JSObject(); + document.put("canceled", false); + document.put("sourceUri", JSObject.NULL); + document.put("displayName", displayName); + document.put("providerName", "Android share"); + document.put("pathHint", displayName); + document.put("mimeType", mimeType.length() > 0 ? mimeType : "text/plain"); + document.put("markdown", markdown); + putMarkdownEncodingMetadata(document, defaultMarkdownEncoding, false); + document.put("canWrite", false); + document.put("persisted", false); + document.put("shareKind", "text"); - JSObject event = new JSObject(); - event.put("document", document); - event.put("source", "share"); - Log.i(TAG, "Received Android shared Markdown text: " + safeForLog(displayName)); - notifyListeners(EVENT_SHARE_DOCUMENT, event, true); - } catch (DocumentReadException ex) { - Log.w(TAG, "Android shared text rejected: " + ex.getMessage()); - notifyShareRejected(ex.code, ex.getMessage()); - } + JSObject event = new JSObject(); + event.put("document", document); + event.put("source", "share"); + Log.i(TAG, "Received Android shared Markdown text: " + safeForLog(displayName)); + notifyListeners(EVENT_SHARE_DOCUMENT, event, true); } @ActivityCallback @@ -859,7 +769,7 @@ private void pickImageDocumentResult(PluginCall call, ActivityResult result) { private JSObject buildDocumentResult(Uri uri, Intent grantIntent) throws IOException, DocumentReadException { String displayName = getDisplayName(uri); String mimeType = getMimeType(uri); - if (!isMarkdownCandidate(displayName, mimeType)) { + if (!IncomingIntentParser.isMarkdownCandidate(displayName, mimeType)) { throw new DocumentReadException( "UNSUPPORTED_DOCUMENT", "Choose a Markdown or plain text document" @@ -935,7 +845,7 @@ private JSObject linkedImageResult(Uri uri, Intent grantIntent) throws IOExcepti private JSObject buildOpenWithDocumentResult(Uri uri, Intent grantIntent) throws IOException, DocumentReadException { String displayName = getDisplayName(uri); String mimeType = getMimeType(uri, grantIntent); - if (!isOpenWithMarkdownCandidate(uri, displayName, mimeType)) { + if (!IncomingIntentParser.isOpenWithMarkdownCandidate(uri, displayName, mimeType)) { throw new DocumentReadException( "UNSUPPORTED_OPEN_WITH_DOCUMENT", "Open a Markdown document" @@ -960,7 +870,7 @@ private JSObject buildOpenWithDocumentResult(Uri uri, Intent grantIntent) throws private JSObject buildSharedStreamDocumentResult(Uri uri, Intent grantIntent) throws IOException, DocumentReadException { String displayName = getDisplayName(uri); String mimeType = getMimeType(uri, grantIntent); - if (!isSharedStreamMarkdownCandidate(uri, displayName, mimeType)) { + if (!IncomingIntentParser.isSharedStreamMarkdownCandidate(uri, displayName, mimeType)) { throw new DocumentReadException( "UNSUPPORTED_SHARE_DOCUMENT", "Share a Markdown file" @@ -1015,7 +925,7 @@ private JSObject writeDocumentResult( ) throws IOException, DocumentReadException { String displayName = getDisplayName(uri); String mimeType = getMimeType(uri); - if (!isMarkdownCandidate(displayName, mimeType)) { + if (!IncomingIntentParser.isMarkdownCandidate(displayName, mimeType)) { throw new DocumentReadException( "UNSUPPORTED_DOCUMENT", "Choose a Markdown or plain text document" @@ -1352,8 +1262,8 @@ private String getMimeType(Uri uri) { private String getMimeType(Uri uri, Intent grantIntent) { String resolverType = getMimeType(uri); String intentType = grantIntent == null ? null : grantIntent.getType(); - String normalizedIntentType = normalizeMimeType(intentType); - if (isMarkdownMimeType(normalizedIntentType)) { + String normalizedIntentType = IncomingIntentParser.normalizeMimeType(intentType); + if (IncomingIntentParser.isMarkdownMimeType(normalizedIntentType)) { return normalizedIntentType; } @@ -1364,23 +1274,6 @@ private String getMimeType(Uri uri, Intent grantIntent) { return normalizedIntentType; } - private String normalizeMimeType(String mimeType) { - return mimeType == null ? "" : mimeType.toLowerCase(Locale.US); - } - - private String getSharedTextDisplayName(Intent intent) { - CharSequence titleValue = getOptionalCharSequenceExtra(intent, Intent.EXTRA_TITLE); - String title = titleValue == null ? "" : titleValue.toString(); - CharSequence subjectValue = getOptionalCharSequenceExtra(intent, Intent.EXTRA_SUBJECT); - if (title.trim().length() == 0 && subjectValue != null) { - title = subjectValue.toString(); - } - if (title == null || title.trim().length() == 0) { - title = "Shared Markdown"; - } - return normalizeSuggestedMarkdownName(title); - } - private String getProviderName(Uri uri) { String authority = uri.getAuthority(); if (authority == null || authority.length() == 0) { @@ -1398,44 +1291,6 @@ private String getProviderName(Uri uri) { return authority; } - private boolean isMarkdownCandidate(String displayName, String mimeType) { - if (hasMarkdownExtension(displayName)) { - return true; - } - - return ( - isMarkdownMimeType(mimeType) || - "text/plain".equals(mimeType) - ); - } - - private boolean isOpenWithMarkdownCandidate(Uri uri, String displayName, String mimeType) { - return ( - hasMarkdownExtension(displayName) || - hasMarkdownExtension(uri.getLastPathSegment()) || - isMarkdownMimeType(mimeType) - ); - } - - private boolean isSharedStreamMarkdownCandidate(Uri uri, String displayName, String mimeType) { - return ( - hasMarkdownExtension(displayName) || - hasMarkdownExtension(uri.getLastPathSegment()) || - isMarkdownMimeType(mimeType) - ); - } - - private boolean hasMarkdownExtension(String value) { - String lowerName = value == null ? "" : value.toLowerCase(Locale.US); - return ( - lowerName.endsWith(".md") || - lowerName.endsWith(".markdown") || - lowerName.endsWith(".mdown") || - lowerName.endsWith(".mkdn") || - lowerName.endsWith(".mkd") - ); - } - private boolean isSupportedImage(String displayName, String mimeType) { if (isSupportedImageMimeType(mimeType)) { return true; @@ -1555,90 +1410,6 @@ private String extensionForImageMimeType(String mimeType) { return ".png"; } - private boolean isMarkdownMimeType(String mimeType) { - return ( - "text/markdown".equals(mimeType) || - "text/x-markdown".equals(mimeType) || - "text/vnd.daringfireball.markdown".equals(mimeType) - ); - } - - private boolean isSharedTextMimeType(String mimeType) { - return ( - mimeType.length() == 0 || - "text/plain".equals(mimeType) || - mimeType.startsWith("text/") || - isMarkdownMimeType(mimeType) - ); - } - - @SuppressWarnings("deprecation") - private CharSequence getSharedText(Intent intent) throws DocumentReadException { - try { - return intent.getCharSequenceExtra(Intent.EXTRA_TEXT); - } catch (RuntimeException ex) { - throw new DocumentReadException( - "INVALID_SHARE_INTENT", - "This Android share request is not supported" - ); - } - } - - private CharSequence getOptionalCharSequenceExtra(Intent intent, String key) { - try { - return intent.getCharSequenceExtra(key); - } catch (RuntimeException ex) { - Log.w(TAG, "Ignored malformed Android share text metadata: " + safeForLog(key)); - return null; - } - } - - private Uri getSharedStreamUri(Intent intent) throws DocumentReadException { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class); - } - return intent.getParcelableExtra(Intent.EXTRA_STREAM); - } catch (RuntimeException ex) { - throw new DocumentReadException( - "INVALID_SHARE_INTENT", - "This Android share request is not supported" - ); - } - } - - private boolean hasOnlyAllowedViewCategories(Intent intent) { - Set categories = intent.getCategories(); - if (categories == null) { - return true; - } - - for (String category : categories) { - if ( - !Intent.CATEGORY_DEFAULT.equals(category) && - !Intent.CATEGORY_BROWSABLE.equals(category) && - !Intent.CATEGORY_OPENABLE.equals(category) - ) { - return false; - } - } - return true; - } - - private boolean hasOnlyAllowedShareCategories(Intent intent) { - Set categories = intent.getCategories(); - if (categories == null) { - return true; - } - - for (String category : categories) { - if (!Intent.CATEGORY_DEFAULT.equals(category)) { - return false; - } - } - return true; - } - private void notifyOpenWithRejected(String code, String message) { JSObject event = new JSObject(); event.put("source", "open-with"); @@ -1654,7 +1425,6 @@ private void notifyShareRejected(String code, String message) { event.put("message", message); notifyListeners(EVENT_SHARE_DOCUMENT, event, true); } - private boolean canWrite(Uri uri, Intent grantIntent) { if (grantIntent != null && (grantIntent.getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { return true; diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/IncomingIntentParser.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/IncomingIntentParser.java new file mode 100644 index 0000000..d4def1b --- /dev/null +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/IncomingIntentParser.java @@ -0,0 +1,339 @@ +package io.github.renakoni.marktextandroid; + +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import java.util.Locale; +import java.util.Set; + +/** + * Parses and classifies incoming ACTION_VIEW / ACTION_SEND intents into + * structured results: which pipeline handles them (open-with document, + * shared Markdown stream, shared text) or why they are rejected. Owns the + * MIME/extension candidate rules shared with the document read paths. Does + * not touch UI, events, or document persistence — the plugin dispatches on + * the returned result. + */ +final class IncomingIntentParser { + + private static final String TAG = "MarkTextAndroid"; + + enum Kind { + IGNORED, + OPEN_WITH_DOCUMENT, + SHARE_STREAM, + SHARE_TEXT, + REJECTED_OPEN_WITH, + REJECTED_SHARE, + } + + static final class Result { + + final Kind kind; + final Uri uri; + final String text; + final String mimeType; + final String rawTitle; + final String code; + final String message; + + private Result( + Kind kind, + Uri uri, + String text, + String mimeType, + String rawTitle, + String code, + String message + ) { + this.kind = kind; + this.uri = uri; + this.text = text; + this.mimeType = mimeType; + this.rawTitle = rawTitle; + this.code = code; + this.message = message; + } + + private static Result ignored() { + return new Result(Kind.IGNORED, null, null, null, null, null, null); + } + + private static Result openWithDocument(Uri uri) { + return new Result(Kind.OPEN_WITH_DOCUMENT, uri, null, null, null, null, null); + } + + private static Result shareStream(Uri uri) { + return new Result(Kind.SHARE_STREAM, uri, null, null, null, null, null); + } + + private static Result shareText(String text, String mimeType, String rawTitle) { + return new Result(Kind.SHARE_TEXT, null, text, mimeType, rawTitle, null, null); + } + + private static Result rejectedOpenWith(String code, String message) { + return new Result(Kind.REJECTED_OPEN_WITH, null, null, null, null, code, message); + } + + private static Result rejectedShare(String code, String message) { + return new Result(Kind.REJECTED_SHARE, null, null, null, null, code, message); + } + } + + private IncomingIntentParser() {} + + static boolean isIncomingAction(String action) { + return ( + Intent.ACTION_VIEW.equals(action) || + Intent.ACTION_SEND.equals(action) || + Intent.ACTION_SEND_MULTIPLE.equals(action) + ); + } + + static Result parse(Intent intent) { + if (intent == null) { + return Result.ignored(); + } + + String action = intent.getAction(); + if (Intent.ACTION_VIEW.equals(action)) { + return parseOpenWith(intent); + } + if (Intent.ACTION_SEND.equals(action)) { + return parseShare(intent); + } + if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + Log.w(TAG, "Rejected Android multi-share intent"); + return Result.rejectedShare("UNSUPPORTED_SHARE_DOCUMENT", "Share one Markdown file at a time"); + } + return Result.ignored(); + } + + private static Result parseOpenWith(Intent intent) { + Uri uri = intent.getData(); + if (uri == null) { + return Result.rejectedOpenWith("DOCUMENT_URI_MISSING", "Android open-with intent returned no URI"); + } + + if (!hasOnlyAllowedViewCategories(intent)) { + Log.w(TAG, "Rejected Android open-with intent with unsupported categories"); + return Result.rejectedOpenWith("INVALID_OPEN_WITH_INTENT", "This Android open-with request is not supported"); + } + + if (!"content".equals(uri.getScheme())) { + Log.w(TAG, "Rejected Android open-with URI with unsupported scheme: " + safeForLog(uri.getScheme())); + return Result.rejectedOpenWith("INVALID_SOURCE_URI", "A valid content URI is required"); + } + + return Result.openWithDocument(uri); + } + + private static Result parseShare(Intent intent) { + if (!hasOnlyAllowedShareCategories(intent)) { + Log.w(TAG, "Rejected Android share intent with unsupported categories"); + return Result.rejectedShare("INVALID_SHARE_INTENT", "This Android share request is not supported"); + } + + Uri streamUri; + try { + streamUri = getSharedStreamUri(intent); + } catch (DocumentReadException ex) { + Log.w(TAG, "Android share stream extra rejected: " + ex.getMessage()); + return Result.rejectedShare(ex.code, ex.getMessage()); + } + + if (streamUri != null) { + if (!"content".equals(streamUri.getScheme())) { + Log.w(TAG, "Rejected Android shared URI with unsupported scheme: " + safeForLog(streamUri.getScheme())); + return Result.rejectedShare( + "INVALID_SHARE_SOURCE_URI", + "This Android share did not provide a supported file URI" + ); + } + return Result.shareStream(streamUri); + } + + CharSequence sharedText; + try { + sharedText = getSharedText(intent); + } catch (DocumentReadException ex) { + Log.w(TAG, "Android share text extra rejected: " + ex.getMessage()); + return Result.rejectedShare(ex.code, ex.getMessage()); + } + + if (sharedText != null && sharedText.length() > 0) { + return parseSharedText(sharedText.toString(), intent); + } + + return Result.rejectedShare("SHARE_CONTENT_MISSING", "This Android share did not include Markdown content"); + } + + private static Result parseSharedText(String markdown, Intent intent) { + String mimeType = normalizeMimeType(intent.getType()); + if (!isSharedTextMimeType(mimeType)) { + Log.w(TAG, "Rejected Android shared text with unsupported MIME type: " + safeForLog(mimeType)); + return Result.rejectedShare("UNSUPPORTED_SHARE_DOCUMENT", "Share Markdown text or a Markdown file"); + } + + try { + MarkdownCodec.validateBytes(markdown); + } catch (DocumentReadException ex) { + Log.w(TAG, "Android shared text rejected: " + ex.getMessage()); + return Result.rejectedShare(ex.code, ex.getMessage()); + } + + return Result.shareText(markdown, mimeType, getSharedTextRawTitle(intent)); + } + + /** EXTRA_TITLE, then EXTRA_SUBJECT, then a fixed fallback — un-normalized. */ + private static String getSharedTextRawTitle(Intent intent) { + CharSequence titleValue = getOptionalCharSequenceExtra(intent, Intent.EXTRA_TITLE); + String title = titleValue == null ? "" : titleValue.toString(); + CharSequence subjectValue = getOptionalCharSequenceExtra(intent, Intent.EXTRA_SUBJECT); + if (title.trim().length() == 0 && subjectValue != null) { + title = subjectValue.toString(); + } + if (title == null || title.trim().length() == 0) { + title = "Shared Markdown"; + } + return title; + } + + // ------------------------------------------------------- classification + + static boolean isMarkdownCandidate(String displayName, String mimeType) { + if (hasMarkdownExtension(displayName)) { + return true; + } + + return ( + isMarkdownMimeType(mimeType) || + "text/plain".equals(mimeType) + ); + } + + static boolean isOpenWithMarkdownCandidate(Uri uri, String displayName, String mimeType) { + return ( + hasMarkdownExtension(displayName) || + hasMarkdownExtension(uri.getLastPathSegment()) || + isMarkdownMimeType(mimeType) + ); + } + + static boolean isSharedStreamMarkdownCandidate(Uri uri, String displayName, String mimeType) { + return ( + hasMarkdownExtension(displayName) || + hasMarkdownExtension(uri.getLastPathSegment()) || + isMarkdownMimeType(mimeType) + ); + } + + static boolean hasMarkdownExtension(String value) { + String lowerName = value == null ? "" : value.toLowerCase(Locale.US); + return ( + lowerName.endsWith(".md") || + lowerName.endsWith(".markdown") || + lowerName.endsWith(".mdown") || + lowerName.endsWith(".mkdn") || + lowerName.endsWith(".mkd") + ); + } + + static boolean isMarkdownMimeType(String mimeType) { + return ( + "text/markdown".equals(mimeType) || + "text/x-markdown".equals(mimeType) || + "text/vnd.daringfireball.markdown".equals(mimeType) + ); + } + + static boolean isSharedTextMimeType(String mimeType) { + return ( + mimeType.length() == 0 || + "text/plain".equals(mimeType) || + mimeType.startsWith("text/") || + isMarkdownMimeType(mimeType) + ); + } + + static String normalizeMimeType(String mimeType) { + return mimeType == null ? "" : mimeType.toLowerCase(Locale.US); + } + + // ------------------------------------------------------------ internals + + @SuppressWarnings("deprecation") + private static CharSequence getSharedText(Intent intent) throws DocumentReadException { + try { + return intent.getCharSequenceExtra(Intent.EXTRA_TEXT); + } catch (RuntimeException ex) { + throw new DocumentReadException( + "INVALID_SHARE_INTENT", + "This Android share request is not supported" + ); + } + } + + private static CharSequence getOptionalCharSequenceExtra(Intent intent, String key) { + try { + return intent.getCharSequenceExtra(key); + } catch (RuntimeException ex) { + Log.w(TAG, "Ignored malformed Android share text metadata: " + safeForLog(key)); + return null; + } + } + + private static Uri getSharedStreamUri(Intent intent) throws DocumentReadException { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class); + } + return intent.getParcelableExtra(Intent.EXTRA_STREAM); + } catch (RuntimeException ex) { + throw new DocumentReadException( + "INVALID_SHARE_INTENT", + "This Android share request is not supported" + ); + } + } + + private static boolean hasOnlyAllowedViewCategories(Intent intent) { + Set categories = intent.getCategories(); + if (categories == null) { + return true; + } + + for (String category : categories) { + if ( + !Intent.CATEGORY_DEFAULT.equals(category) && + !Intent.CATEGORY_BROWSABLE.equals(category) && + !Intent.CATEGORY_OPENABLE.equals(category) + ) { + return false; + } + } + return true; + } + + private static boolean hasOnlyAllowedShareCategories(Intent intent) { + Set categories = intent.getCategories(); + if (categories == null) { + return true; + } + + for (String category : categories) { + if (!Intent.CATEGORY_DEFAULT.equals(category)) { + return false; + } + } + return true; + } + + private static String safeForLog(String value) { + if (value == null) { + return ""; + } + return value.replace('\r', ' ').replace('\n', ' ').trim(); + } +} diff --git a/android/app/src/test/java/io/github/renakoni/marktextandroid/IncomingIntentParserTest.java b/android/app/src/test/java/io/github/renakoni/marktextandroid/IncomingIntentParserTest.java new file mode 100644 index 0000000..d60ac7d --- /dev/null +++ b/android/app/src/test/java/io/github/renakoni/marktextandroid/IncomingIntentParserTest.java @@ -0,0 +1,55 @@ +package io.github.renakoni.marktextandroid; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class IncomingIntentParserTest { + + @Test + public void recognizesMarkdownExtensionsCaseInsensitively() { + assertTrue(IncomingIntentParser.hasMarkdownExtension("Notes.MD")); + assertTrue(IncomingIntentParser.hasMarkdownExtension("a.markdown")); + assertTrue(IncomingIntentParser.hasMarkdownExtension("a.mdown")); + assertTrue(IncomingIntentParser.hasMarkdownExtension("a.mkdn")); + assertTrue(IncomingIntentParser.hasMarkdownExtension("a.mkd")); + assertFalse(IncomingIntentParser.hasMarkdownExtension("archive.mdx")); + assertFalse(IncomingIntentParser.hasMarkdownExtension(null)); + } + + @Test + public void recognizesMarkdownMimeTypesExactly() { + assertTrue(IncomingIntentParser.isMarkdownMimeType("text/markdown")); + assertTrue(IncomingIntentParser.isMarkdownMimeType("text/x-markdown")); + assertTrue(IncomingIntentParser.isMarkdownMimeType("text/vnd.daringfireball.markdown")); + assertFalse(IncomingIntentParser.isMarkdownMimeType("text/plain")); + assertFalse(IncomingIntentParser.isMarkdownMimeType("application/octet-stream")); + } + + @Test + public void documentCandidatesAcceptPlainTextOnlyByMime() { + // Picker/read path: a .txt is acceptable via its MIME… + assertTrue(IncomingIntentParser.isMarkdownCandidate("notes.txt", "text/plain")); + // …and a Markdown extension wins regardless of MIME. + assertTrue(IncomingIntentParser.isMarkdownCandidate("notes.md", "application/octet-stream")); + assertFalse(IncomingIntentParser.isMarkdownCandidate("photo.png", "image/png")); + } + + @Test + public void sharedTextAcceptsAnyTextMimeIncludingEmpty() { + assertTrue(IncomingIntentParser.isSharedTextMimeType("")); + assertTrue(IncomingIntentParser.isSharedTextMimeType("text/plain")); + assertTrue(IncomingIntentParser.isSharedTextMimeType("text/anything")); + assertTrue(IncomingIntentParser.isSharedTextMimeType("text/markdown")); + assertFalse(IncomingIntentParser.isSharedTextMimeType("application/json")); + assertFalse(IncomingIntentParser.isSharedTextMimeType("image/png")); + } + + @Test + public void normalizesMimeTypesToLowerCaseAndEmpty() { + assertEquals("text/markdown", IncomingIntentParser.normalizeMimeType("Text/Markdown")); + assertEquals("", IncomingIntentParser.normalizeMimeType(null)); + } +}