From d2c8e714c308ee38eab67a45e7c8ca061cd33e46 Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 06:24:31 -0700 Subject: [PATCH] Extract native incoming-Intent parser from AndroidDocumentsPlugin Moves ACTION_VIEW/ACTION_SEND parsing and classification into an IncomingIntentParser that returns structured results: open-with document URI, shared Markdown stream URI, validated shared text with its raw title, or a rejection with the stable code/message pair. The parser owns the category allow-lists, scheme checks, EXTRA_STREAM/EXTRA_TEXT extraction with their malformed-extra handling, the shared-text MIME gate and byte validation, the SEND_MULTIPLE rejection, and the Markdown MIME/extension candidate rules shared with the document read paths. Parse-stage rejection logs move with the reasoning, preserving the exact log lines. The plugin keeps everything stateful and platform-facing: the duplicate-intent guard, ContentResolver document reads, URI permission persistence, JSObject/event emission (including the shared-text document payload, whose display name is still normalized at emission), and the Capacitor listeners. Event names, payload fields, error codes, and messages are unchanged. Adds IncomingIntentParserTest (5 JUnit tests) for the pure classification rules; Intent-level behavior was smoke-verified on the emulator with real SEND text (accepted, titled from EXTRA_SUBJECT), file-scheme VIEW (rejected with the preserved scheme log), and SEND_MULTIPLE (rejected) intents. --- .../AndroidDocumentsPlugin.java | 324 +++-------------- .../marktextandroid/IncomingIntentParser.java | 339 ++++++++++++++++++ .../IncomingIntentParserTest.java | 55 +++ 3 files changed, 441 insertions(+), 277 deletions(-) create mode 100644 android/app/src/main/java/io/github/renakoni/marktextandroid/IncomingIntentParser.java create mode 100644 android/app/src/test/java/io/github/renakoni/marktextandroid/IncomingIntentParserTest.java 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)); + } +}