From c95b91e34df9ec45555b5a0a691a8f12d4fe1295 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 29 Jul 2026 22:59:41 +0200 Subject: [PATCH] Fix Android share completion and link intents --- android/src/main/java/ShareIntentPayload.kt | 21 +++++ android/src/main/java/SharePlugin.kt | 63 ++++++++------- android/src/main/java/ShareSessionState.kt | 38 ++++++++++ .../test/java/ShareIntentPayloadUnitTest.kt | 76 +++++++++++++++++++ .../test/java/ShareSessionStateUnitTest.kt | 43 +++++++++++ .../app/tauri/annotation/ActivityCallback.kt | 5 ++ .../src/main/java/app/tauri/plugin/Plugin.kt | 10 +++ 7 files changed, 223 insertions(+), 33 deletions(-) create mode 100644 android/src/main/java/ShareIntentPayload.kt create mode 100644 android/src/main/java/ShareSessionState.kt create mode 100644 android/src/test/java/ShareIntentPayloadUnitTest.kt create mode 100644 android/src/test/java/ShareSessionStateUnitTest.kt create mode 100644 android/test-support/tauri-api/src/main/java/app/tauri/annotation/ActivityCallback.kt diff --git a/android/src/main/java/ShareIntentPayload.kt b/android/src/main/java/ShareIntentPayload.kt new file mode 100644 index 0000000..8e1b5c6 --- /dev/null +++ b/android/src/main/java/ShareIntentPayload.kt @@ -0,0 +1,21 @@ +package plugin.vnidrop.share + +internal data class ShareIntentPayload( + val title: String?, + val body: String?, +) { + companion object { + fun from(options: ShareOptions): ShareIntentPayload { + val text = options.text.nonEmptyOrNull() + val title = options.title.nonEmptyOrNull() + val url = options.url.nonEmptyOrNull() + + return ShareIntentPayload( + title = title ?: text, + body = url ?: text, + ) + } + + private fun String?.nonEmptyOrNull(): String? = this?.takeIf { it.isNotEmpty() } + } +} diff --git a/android/src/main/java/SharePlugin.kt b/android/src/main/java/SharePlugin.kt index 2ea5d38..9f84db7 100644 --- a/android/src/main/java/SharePlugin.kt +++ b/android/src/main/java/SharePlugin.kt @@ -9,7 +9,9 @@ import android.os.Looper import android.util.Base64 import java.io.File import java.io.FileOutputStream +import androidx.activity.result.ActivityResult import androidx.core.content.FileProvider +import app.tauri.annotation.ActivityCallback import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin @@ -37,9 +39,8 @@ class ShareOptions { @TauriPlugin class SharePlugin(private val activity: Activity): Plugin(activity) { private var pendingShareInvoke: Invoke? = null - private var shareInProgress = false - private var awaitingShareResume = false private var pendingCleanupFiles: List = emptyList() + private val shareSession = ShareSessionState() private val cleanupHandler = Handler(Looper.getMainLooper()) companion object { @@ -57,7 +58,7 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { @Command fun share(invoke: Invoke) { - if (shareInProgress) { + if (shareSession.isInProgress) { invoke.reject("Share already in progress.") return } @@ -111,22 +112,22 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { shareIntent.type = determinedMimeType - val combinedText = combineTextAndUrl(args.text, args.url) - if (combinedText != null) { - shareIntent.putExtra(Intent.EXTRA_TEXT, combinedText) + val payload = ShareIntentPayload.from(args) + payload.body?.let { + shareIntent.putExtra(Intent.EXTRA_TEXT, it) } - if (args.title != null) { - shareIntent.putExtra(Intent.EXTRA_TITLE, args.title) + payload.title?.let { + shareIntent.putExtra(Intent.EXTRA_TITLE, it) + shareIntent.putExtra(Intent.EXTRA_SUBJECT, it) } shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val chooser = Intent.createChooser(shareIntent, args.title) + val chooser = Intent.createChooser(shareIntent, payload.title) pendingShareInvoke = invoke - shareInProgress = true - awaitingShareResume = false pendingCleanupFiles = filesForShare - activity.startActivity(chooser) + shareSession.start() + startActivityForResult(invoke, chooser, "shareResult") } catch (e: Exception) { cleanupFiles(filesForShare) resetPendingShare() @@ -136,19 +137,20 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { override fun onPause() { super.onPause() - if (shareInProgress) { - awaitingShareResume = true - } + shareSession.markPaused() } override fun onResume() { super.onResume() - if (shareInProgress && awaitingShareResume) { - val invoke = pendingShareInvoke - val filesToClean = pendingCleanupFiles - resetPendingShare() - scheduleCleanup(filesToClean) - invoke?.resolve() + if (shareSession.completeFromResume()) { + resolvePendingShare() + } + } + + @ActivityCallback + fun shareResult(invoke: Invoke, result: ActivityResult) { + if (shareSession.completeFromActivityResult()) { + resolvePendingShare(invoke) } } @@ -194,23 +196,18 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { return clipData } - private fun combineTextAndUrl(text: String?, url: String?): String? { - val hasText = !text.isNullOrEmpty() - val hasUrl = !url.isNullOrEmpty() - - return when { - hasText && hasUrl -> "$text\n$url" - hasText -> text - hasUrl -> url - else -> null - } + private fun resolvePendingShare(invoke: Invoke? = pendingShareInvoke) { + val filesToClean = pendingCleanupFiles + pendingShareInvoke = null + pendingCleanupFiles = emptyList() + scheduleCleanup(filesToClean) + invoke?.resolve() } private fun resetPendingShare() { pendingShareInvoke = null - shareInProgress = false - awaitingShareResume = false pendingCleanupFiles = emptyList() + shareSession.reset() } private fun scheduleCleanup(files: List) { diff --git a/android/src/main/java/ShareSessionState.kt b/android/src/main/java/ShareSessionState.kt new file mode 100644 index 0000000..590d4ab --- /dev/null +++ b/android/src/main/java/ShareSessionState.kt @@ -0,0 +1,38 @@ +package plugin.vnidrop.share + +internal class ShareSessionState { + var isInProgress: Boolean = false + private set + + private var awaitingResume: Boolean = false + + fun start() { + check(!isInProgress) { "Share session already in progress." } + isInProgress = true + awaitingResume = false + } + + fun markPaused() { + if (isInProgress) { + awaitingResume = true + } + } + + fun completeFromActivityResult(): Boolean = completeIf(isInProgress) + + fun completeFromResume(): Boolean = completeIf(isInProgress && awaitingResume) + + fun reset() { + isInProgress = false + awaitingResume = false + } + + private fun completeIf(shouldComplete: Boolean): Boolean { + if (!shouldComplete) { + return false + } + + reset() + return true + } +} diff --git a/android/src/test/java/ShareIntentPayloadUnitTest.kt b/android/src/test/java/ShareIntentPayloadUnitTest.kt new file mode 100644 index 0000000..d654d57 --- /dev/null +++ b/android/src/test/java/ShareIntentPayloadUnitTest.kt @@ -0,0 +1,76 @@ +package plugin.vnidrop.share + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ShareIntentPayloadUnitTest { + @Test + fun usesUrlAsBodyAndTextAsFallbackTitle() { + val payload = ShareIntentPayload.from( + options( + text = "Read this article", + url = "https://example.com/article", + ) + ) + + assertEquals("Read this article", payload.title) + assertEquals("https://example.com/article", payload.body) + } + + @Test + fun explicitTitleTakesPrecedenceOverText() { + val payload = ShareIntentPayload.from( + options( + text = "Description", + title = "Article title", + url = "https://example.com/article", + ) + ) + + assertEquals("Article title", payload.title) + assertEquals("https://example.com/article", payload.body) + } + + @Test + fun textOnlyPayloadUsesTextAsTitleAndBody() { + val payload = ShareIntentPayload.from(options(text = "Hello")) + + assertEquals("Hello", payload.title) + assertEquals("Hello", payload.body) + } + + @Test + fun emptyValuesDoNotHideShareableText() { + val payload = ShareIntentPayload.from( + options( + text = "Hello", + title = "", + url = "", + ) + ) + + assertEquals("Hello", payload.title) + assertEquals("Hello", payload.body) + } + + @Test + fun emptyPayloadHasNoTitleOrBody() { + val payload = ShareIntentPayload.from(options()) + + assertNull(payload.title) + assertNull(payload.body) + } + + private fun options( + text: String? = null, + title: String? = null, + url: String? = null, + ): ShareOptions { + return ShareOptions().apply { + this.text = text + this.title = title + this.url = url + } + } +} diff --git a/android/src/test/java/ShareSessionStateUnitTest.kt b/android/src/test/java/ShareSessionStateUnitTest.kt new file mode 100644 index 0000000..3882ca1 --- /dev/null +++ b/android/src/test/java/ShareSessionStateUnitTest.kt @@ -0,0 +1,43 @@ +package plugin.vnidrop.share + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShareSessionStateUnitTest { + @Test + fun activityResultCompletesSessionAndAllowsAnotherShare() { + val state = ShareSessionState() + + state.start() + assertTrue(state.isInProgress) + assertTrue(state.completeFromActivityResult()) + assertFalse(state.isInProgress) + + state.start() + assertTrue(state.isInProgress) + } + + @Test + fun resumeRemainsAFallbackAfterPause() { + val state = ShareSessionState() + + state.start() + assertFalse(state.completeFromResume()) + + state.markPaused() + assertTrue(state.completeFromResume()) + assertFalse(state.isInProgress) + } + + @Test + fun completionOnlyHappensOnce() { + val state = ShareSessionState() + + state.start() + state.markPaused() + assertTrue(state.completeFromActivityResult()) + assertFalse(state.completeFromResume()) + assertFalse(state.completeFromActivityResult()) + } +} diff --git a/android/test-support/tauri-api/src/main/java/app/tauri/annotation/ActivityCallback.kt b/android/test-support/tauri-api/src/main/java/app/tauri/annotation/ActivityCallback.kt new file mode 100644 index 0000000..9838567 --- /dev/null +++ b/android/test-support/tauri-api/src/main/java/app/tauri/annotation/ActivityCallback.kt @@ -0,0 +1,5 @@ +package app.tauri.annotation + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ActivityCallback diff --git a/android/test-support/tauri-api/src/main/java/app/tauri/plugin/Plugin.kt b/android/test-support/tauri-api/src/main/java/app/tauri/plugin/Plugin.kt index 599e48c..edc48ac 100644 --- a/android/test-support/tauri-api/src/main/java/app/tauri/plugin/Plugin.kt +++ b/android/test-support/tauri-api/src/main/java/app/tauri/plugin/Plugin.kt @@ -1,6 +1,7 @@ package app.tauri.plugin import android.app.Activity +import android.content.Intent open class Plugin(activity: Activity) { @Suppress("UNUSED_VARIABLE") @@ -9,4 +10,13 @@ open class Plugin(activity: Activity) { open fun onPause() {} open fun onResume() {} + + fun startActivityForResult(invoke: Invoke, intent: Intent, callback: String) { + @Suppress("UNUSED_VARIABLE") + val ignoredInvoke = invoke + @Suppress("UNUSED_VARIABLE") + val ignoredIntent = intent + @Suppress("UNUSED_VARIABLE") + val ignoredCallback = callback + } }