Skip to content

cmp-intent-launcher

cmp-intent-launcher

Typed Android-Intent builder with cross-platform ActivityResult contracts for Kotlin Multiplatform.

Status: Experimental. All public APIs marked @ExperimentalIntentLauncherApi. Ships alongside the other cmp-* modules at the shared kmptoolkit.version.

Features

  • Typed Intent DSL: intent { action(...); data(...); type(...); extra(...); result(...) }
  • ActivityResult contracts: PickImage, PickDocument, PickMultipleImages, PickContact, Custom<R>
  • Compose-scoped launcher: rememberIntentLauncher() — lifecycle-bound to enclosing Composable
  • Android escape hatch: ComponentActivity.intentLauncher() for non-Compose callers
  • Lifecycle-free SystemIntents (v0.4+): openAppSettings() + createDocument(name, mime) callable from commonMain with no Composable / Activity wiring (Android Context auto-injected via ContentProvider; SAF round-trip via invisible proxy Activity)
  • Cross-platform fallback: arbitrary intents route through onUnsupported { } lambda on non-Android

SystemIntents — lifecycle-free entry points (v0.4+)

For operations that don't need an ActivityResult round-trip (settings deep-link) or that should round-trip through a library-managed proxy (createDocument SAF), use the SystemIntents expect object — callable from commonMain / DI graphs / non-UI code:

import com.mobilebytelabs.kmptoolkit.intentlauncher.SystemIntents

suspend fun openSettings() {
    when (val result = SystemIntents.openAppSettings()) {
        is IntentResult.Ok -> { /* settings opened */ }
        is IntentResult.Failed -> { /* UnsupportedPlatform on JS/wasmJs/tvOS/watchOS */ }
        IntentResult.Cancelled -> { /* not used by openAppSettings */ }
    }
}

suspend fun saveReport() {
    val result = SystemIntents.createDocument(
        suggestedName = "report.pdf",
        mimeType = "application/pdf",
    )
    if (result is IntentResult.Ok) {
        val uri = result.data?.uri // platform-native URI to write to
    }
}

Per-platform behaviour:

Method Android iOS macOS JVM Linux Windows JS / wasmJs tvOS / watchOS
openAppSettings ✅ ACTION_APPLICATION_DETAILS_SETTINGS ✅ openSettingsURLString ✅ x-apple.systempreferences ✅ OS-aware shell ✅ gnome-control-center / kcmshell5 / xdg-open ✅ ms-settings:appsfeatures ❌ Unsupported ❌ Unsupported
createDocument ✅ Proxy Activity + SAF ✅ UIDocumentPicker (ExportToService) ✅ NSSavePanel ✅ JFileChooser ✅ zenity --save ❌ Unsupported (Win32 needs cinterop) ✅ showSaveFilePicker (Chromium only) ❌ Unsupported

Android consumer note: IntentLauncherInitProvider + CreateDocumentProxyActivity are bundled in the library AndroidManifest via manifest-merger — no consumer-side declaration needed.

Platform support

Platform Behaviour v0.1 status
Android Full Intent surface via Intent + androidx.activity.result.ActivityResultContracts ✅ Complete
JVM (Desktop) AWT FileDialog (LOAD) for picker contracts; withContext(Dispatchers.IO) ✅ Complete
JS Hidden <input type=file> element + URL.createObjectURL() for picker contracts ✅ Complete
iOS Routes picker contracts through onUnsupported callback ⚠️ PHPicker / UIDocumentPicker delegate wiring — v0.2 polish
macOS Routes picker contracts through onUnsupported callback ⚠️ NSOpenPanel wiring — v0.2 polish
wasmJs Routes picker contracts through onUnsupported callback ⚠️ DOM <input> via @JsFun — v0.2 polish

Not targeted: tvOS, watchOS, Linux native, mingwX64, wasmWasi. Per cmp-toolkit Tier-3 exclusion policy.

Install

// build.gradle.kts
dependencies {
    val kmptoolkit = "3.2.13" // or latest
    implementation("io.github.mobilebytelabs:cmp-intent-launcher:$kmptoolkit")
}

Quick start

Pick an image (cross-platform)

@OptIn(ExperimentalIntentLauncherApi::class)
@Composable
fun PickImageButton(onPicked: (String) -> Unit) {
    val launcher = rememberIntentLauncher()
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            val result = launcher.launch {
                result(ResultContracts.PickImage)
                type("image/*")
            }
            when (result) {
                is IntentResult.Ok -> result.data?.uri?.let(onPicked)
                IntentResult.Cancelled -> { /* user dismissed */ }
                is IntentResult.Failed -> showToast("Pick failed: ${result.cause}")
            }
        }
    }) { Text("Pick image") }
}

Pick a PDF

@OptIn(ExperimentalIntentLauncherApi::class)
@Composable
fun PickPdfButton(onPicked: (String) -> Unit) {
    val launcher = rememberIntentLauncher()
    val scope = rememberCoroutineScope()
    Button(onClick = {
        scope.launch {
            val result = launcher.launch {
                action("android.intent.action.OPEN_DOCUMENT")
                type("application/pdf")
                result(ResultContracts.PickDocument)
            }
            (result as? IntentResult.Ok)?.data?.uri?.let(onPicked)
        }
    }) { Text("Attach PDF") }
}

Custom Android action with extras

@OptIn(ExperimentalIntentLauncherApi::class)
@Composable
fun VendorBarcodeScanButton(onScanned: (String) -> Unit) {
    val launcher = rememberIntentLauncher()
    val scope = rememberCoroutineScope()
    Button(onClick = {
        scope.launch {
            val result = launcher.launch {
                action("com.vendor.SCAN")
                packageName("com.vendor.barcodescanner")
                extra("scan_mode", "QR_CODE")
                onUnsupported {
                    IntentResult.Failed(IntentError.UnsupportedPlatform)
                }
            }
            (result as? IntentResult.Ok)?.data?.uri?.let(onScanned)
        }
    }) { Text("Scan barcode") }
}

Non-Compose Android (escape hatch)

@OptIn(ExperimentalIntentLauncherApi::class)
class LegacyFragment : Fragment() {
    private lateinit var launcher: IntentLauncher
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        launcher = (requireActivity() as ComponentActivity).intentLauncher()
    }
    fun pickAttachment() {
        viewLifecycleOwner.lifecycleScope.launch {
            launcher.launch { result(ResultContracts.PickDocument) }
        }
    }
}

⚠️ JS / wasmJs user-gesture requirement

The hidden <input type=file> element approach REQUIRES .launch() to be invoked from within a user-gesture handler (Composable onClick, key press, touch). Browsers reject programmatic .click() outside a user-activation context.

// ✅ CORRECT
Button(onClick = { scope.launch { launcher.launch { result(ResultContracts.PickImage) } } })

// ❌ WRONG — returns IntentResult.Failed(UserGestureMissing)
LaunchedEffect(Unit) { launcher.launch { result(ResultContracts.PickImage) } }

Coexists with cmp-open-url

Use case Module
"Open this URL" (https://, mailto:, tel:, geo:) cmp-open-urlopenUrl() / openWithApp(url, AppHint)
"Open the file picker, await result" cmp-intent-launcherlauncher.launch { result(ResultContracts.PickDocument) }
"Send custom Android Intent with extras" cmp-intent-launcher — full Intent builder

cmp-open-url handles URL opening (no result); cmp-intent-launcher handles richer Intent shapes including await-result. See ADR-02 in idea-layer.

See also

Module reference

Module Identity (auto-gen)

Artifact Package Current version Maven Since API tier
io.github.mobilebytelabs:cmp-intent-launcher com.mobilebytelabs.kmptoolkit.intent.launcher UNKNOWN Central 2026-05-30 experimental

Module purpose (one paragraph):


§2 Per-Platform Parity Matrix (auto-gen)

Target Source-set present Real impl UnsupportedPlatform stub .kt count Last reviewed Coverage Notes
androidMain ✅ real 0 6 2026-06-01 full
iosMain ✅ real 3 2 2026-06-01 full
macosMain ✅ real 3 2 2026-06-01 full
jvmMain ✅ real 3 2 2026-06-01 full
jsMain ✅ real 5 2 2026-06-01 full
wasmJsMain ✅ real 7 2 2026-06-01 full
mingwMain 🟡 🟡 wontfix-infra 5 2 2026-06-01 wontfix-infra
linuxMain ✅ real 3 2 2026-06-01 full
tvosMain 🟡 🟡 wontfix-OS 4 2 2026-06-01 wontfix-OS
watchosMain 🟡 🟡 partial 5 2 2026-06-01 partial

Legend (Real impl): ✅ real impl, 🟡 partial / wontfix-OS / wontfix-infra / legacy stub, ⛔ not declared, — N/A. Legend (Coverage enum, since 2026-06-01): full (all public-API methods backed by OS primitive) · partial (most real; some typed UnsupportedPlatform fallbacks for contracts that don't apply) · wontfix-OS (OS lacks the primitive) · wontfix-infra (impl possible but CI/toolchain blocks it) · (legacy:full|stub) (auto-derived; pre-opt-in modules — add a // LD-2-coverage: {enum} comment to the platform's primary .kt file to graduate). See RULE-LIB-DEVELOPMENT-MD-001 LD-2 + ADRs for accepted wontfix cases.


API reference

Each release ships the module's full Dokka HTML site inside its -javadoc.jar artifact on Maven Central.

In IntelliJ / Android Studio the IDE mounts the jar and surfaces it automatically in hover popups, Quick Documentation, and Symbol search.