Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ kotlin {
implementation(libs.play.update)
implementation(libs.play.update.ktx)
implementation(libs.coil.gif)
// androidx.sharetarget provides ChooserTargetServiceCompat,
// referenced from AndroidManifest.xml on ShareTargetActivity.
// Provides backward-compat surfacing of dynamic Sharing
// Shortcuts as Direct Share targets through the legacy
// ChooserTargetService API used by some launchers.
implementation(libs.androidx.sharetarget)
}
androidInstrumentedTest.dependencies {
implementation(libs.androidx.test.runner)
Expand Down
49 changes: 45 additions & 4 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@
android:name=".MainActivity"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:label="@string/intent_filter_notion_login">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_targets" />
<intent-filter android:label="@string/intent_filter_notion_login">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
Expand Down Expand Up @@ -110,6 +113,44 @@
android:name="com.viktormykhailiv.kmp.health.HealthConnectPermissionActivity"
android:noHistory="true"
android:excludeFromRecents="true" />
<!--
ShareTargetActivity receives ACTION_SEND when the user picks a
watchapp from the system share sheet. Each share-sheet entry is a
dynamic Sharing Shortcut pushed at runtime by ShareTargetSync
(libpebble3) for any installed watchapp that declared `shareTarget`
in its package.json. The static share_targets.xml metadata only
tells Android this activity is a valid Direct Share target.

The activity has no UI and finishes immediately — exported=true is
required for it to be reachable from the share sheet, but
taskAffinity="" and excludeFromRecents prevent it from appearing
as its own task in the recents list.
-->
<activity
android:name="coredevices.coreapp.sharing.ShareTargetActivity"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity="">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>

<!-- Hooks the AndroidX `sharetarget` library's compat shim so
dynamic Sharing Shortcuts surface as Direct Share targets
on Android versions that route through the legacy
ChooserTargetService API. Without this, some launchers
(notably older Samsung One UI versions) won't include
our shortcuts in the share-sheet's direct share row even
though the shortcuts themselves are correctly published.
The androidx.sharetarget dependency must be present in
the host app's build.gradle for the referenced class to
resolve. -->
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class MainApplication : Application(), SingletonImageLoader.Factory {
private val experimentalDevices: ExperimentalDevices by inject()
private val fileLogWriter: FileLogWriter by inject()
private val coreConfigHolder: CoreConfigHolder by inject()
// PR 1: share-intent target.
private val shareTargetSync: io.rebble.libpebblecommon.shareintent.ShareTargetSync by inject()

override fun onCreate() {
super.onCreate()
Expand Down Expand Up @@ -88,6 +90,9 @@ class MainApplication : Application(), SingletonImageLoader.Factory {
experimentalDevices.appInit()
// Cactus telemetry is initialized via CommonAppDelegate.initCactus()
pebbleAppDelegate.init()
// PR 1: kick off Sharing Shortcuts maintenance. Idempotent at the
// shortcut-id level; safe to call once at app start.
shareTargetSync.start()
configureStrictMode()
NotifierManager.initialize(
configuration = NotificationPlatformConfiguration.Android(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import coredevices.coreapp.BuildConfig
import coredevices.coreapp.auth.RealAppleAuthUtil
import coredevices.coreapp.auth.RealGithubAuthUtil
import coredevices.coreapp.auth.RealGoogleAuthUtil
import coredevices.coreapp.sharing.ShareTargetActivity
import coredevices.coreapp.util.AndroidAppUpdate
import coredevices.coreapp.util.AppUpdate
import coredevices.pebble.PebbleAndroidDelegate
Expand All @@ -27,11 +28,14 @@ import coredevices.util.auth.GitHubAuthUtil
import coredevices.util.models.ModelDownloadManager
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import io.rebble.libpebblecommon.shareintent.ShareTargetSync
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
import android.content.ComponentName
import coredevices.util.R as UtilR
import kotlin.time.Duration
import kotlin.time.toJavaDuration

Expand Down Expand Up @@ -66,4 +70,20 @@ val androidDefaultModule = module {
}
single { createAndroidAnalytics(get()) }
singleOf(::ModelDownloadManager)
// PR 1: share-intent target. ShareTargetSync runs at app start (kicked
// off in MainApplication.onCreate) and keeps Android Sharing Shortcuts
// in sync with watchapps that declared `shareTarget` in their
// package.json. We supply the activity ComponentName and a fallback icon
// here because libpebble3 has no business knowing about composeApp's
// activities or resources.
single {
val context: android.content.Context = get<io.rebble.libpebblecommon.connection.AppContext>().context
ShareTargetSync(
activityClass = ComponentName(context, ShareTargetActivity::class.java),
// TODO: thread per-watchapp icons through from the locker so
// each shortcut shows its real watchapp icon. For now everyone
// gets the Pebble launcher icon.
fallbackIconResId = UtilR.mipmap.ic_launcher,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package coredevices.coreapp.sharing

import android.app.Activity
import android.app.AlertDialog
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.util.Patterns
import android.widget.Toast
import co.touchlab.kermit.Logger
import io.rebble.libpebblecommon.di.LibPebbleKoinComponent
import io.rebble.libpebblecommon.shareintent.ShareIntentDispatcher
import io.rebble.libpebblecommon.shareintent.ShareTargetEntry
import io.rebble.libpebblecommon.shareintent.ShareTargetSync.Companion.EXTRA_WATCHAPP_UUID
import io.rebble.libpebblecommon.shareintent.ShareTargetSync.Companion.SHORTCUT_ID_PREFIX
import org.koin.core.component.inject
import kotlin.uuid.Uuid

/**
* Lightweight activity that handles the OS share-sheet routing the user
* picked from a Sharing Shortcut surfaced by
* [io.rebble.libpebblecommon.shareintent.ShareTargetSync] or from the
* static "Pebble" intent-filter entry.
*
* Routing paths:
*
* - **Direct Share** (user picked "Share to <Watchapp>" from the share
* sheet's direct-share row): system constructs an ACTION_SEND intent
* with [Intent.EXTRA_SHORTCUT_ID] set to the shortcut's id. We decode
* the watchapp uuid from that and dispatch directly.
*
* - **Launcher long-press** (user tapped "Share to <Watchapp>" from a
* long-press menu): system uses the shortcut's registered intent
* verbatim, which carries [EXTRA_WATCHAPP_UUID].
*
* - **Static fallback** (user picked "Pebble" from the apps row): no
* shortcut involvement; neither extra is set. Behavior depends on
* how many share-capable watchapps are installed:
* - zero: toast prompting the user to install a share-capable watchapp
* - one: dispatch directly to that watchapp (no chooser needed)
* - two+: show a chooser dialog so the user picks which watchapp
*
* The activity finishes immediately after dispatch — the actual delivery
* survives via the application-scoped coroutine in the dispatcher.
*
* Implements [LibPebbleKoinComponent] because [ShareIntentDispatcher] is
* registered in libpebble3's isolated Koin context, not the host app's
* global one.
*/
class ShareTargetActivity : Activity(), LibPebbleKoinComponent {
private val dispatcher: ShareIntentDispatcher by inject()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
handleShareIntent(intent)
} catch (e: Exception) {
logger.e(e) { "share intent handling failed" }
Toast.makeText(this, "Couldn't share to your Pebble", Toast.LENGTH_SHORT).show()
finish()
}
// Note: we do NOT call finish() here unconditionally — the chooser
// dialog path needs the activity alive to host the AlertDialog.
// Each routing branch in handleShareIntent() finishes itself when
// appropriate (immediately for direct dispatch; on dialog dismiss
// for the chooser path).
}

private fun handleShareIntent(intent: Intent?) {
if (intent == null) {
finish()
return
}

// Extract payload first; we need it for every routing path.
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (text.isNullOrBlank()) {
logger.w { "share intent missing EXTRA_TEXT" }
Toast.makeText(this, "Nothing to share", Toast.LENGTH_SHORT).show()
finish()
return
}
val url = extractFirstUrl(text)

// The watchapp uuid can arrive via either of two extras depending
// on which path the share sheet took to launch us:
//
// - Direct Share path: system sets Intent.EXTRA_SHORTCUT_ID to the
// shortcut id ("share-watchapp-<uuid>"). Our custom
// EXTRA_WATCHAPP_UUID is NOT preserved on this path.
//
// - Launcher-shortcut tap path: system uses the shortcut's
// registered intent verbatim, which carries EXTRA_WATCHAPP_UUID.
//
// - Static "Pebble" entry: neither extra is set — falls through
// to the chooser/auto-pick logic below.
val uuidString = intent.getStringExtra(EXTRA_WATCHAPP_UUID)
?: intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID)
?.removePrefix(SHORTCUT_ID_PREFIX)
?.takeIf { it.isNotBlank() }

if (uuidString.isNullOrBlank()) {
handleAmbiguousFallback(text = text, url = url, subject = subject)
return
}

// We have a specific watchapp targeted — dispatch directly.
val uuid = try {
Uuid.parse(uuidString)
} catch (e: IllegalArgumentException) {
logger.w(e) { "bad uuid in share intent: $uuidString" }
finish()
return
}
Toast.makeText(this, "Sharing to your Pebble…", Toast.LENGTH_SHORT).show()
dispatcher.enqueue(uuid, text = text, url = url, subject = subject)
finish()
}

/**
* Static "Pebble" entry path: no specific watchapp identified. Decide
* what to do based on how many share-capable watchapps are installed.
*/
private fun handleAmbiguousFallback(text: String, url: String?, subject: String?) {
val targets = dispatcher.availableTargets.value
when {
targets.isEmpty() -> {
Toast.makeText(
this,
"No watchapp is set up to receive shares. Install a watchapp that supports sharing.",
Toast.LENGTH_LONG,
).show()
finish()
}
targets.size == 1 -> {
// Single share-capable watchapp — no chooser needed.
val target = targets.first()
logger.i { "ambiguous fallback resolved to sole target ${target.uuid}" }
Toast.makeText(this, "Sharing to your Pebble…", Toast.LENGTH_SHORT).show()
dispatcher.enqueue(target.uuid, text = text, url = url, subject = subject)
finish()
}
else -> {
showChooserDialog(targets, text = text, url = url, subject = subject)
}
}
}

/**
* Multiple share-capable watchapps installed — let the user pick one.
* The dialog hosts itself on this activity; on dismiss/cancel the
* activity finishes without dispatching.
*/
private fun showChooserDialog(
targets: List<ShareTargetEntry>,
text: String,
url: String?,
subject: String?,
) {
// Sort alphabetically by display name for a stable, predictable order.
val sorted = targets.sortedBy { displayNameFor(it) }
val labels = sorted.map { displayNameFor(it) }.toTypedArray()

AlertDialog.Builder(this)
.setTitle("Share to which watchapp?")
.setItems(labels) { _: DialogInterface, which: Int ->
val picked = sorted[which]
logger.i { "chooser picked ${picked.uuid}" }
Toast.makeText(this, "Sharing to your Pebble…", Toast.LENGTH_SHORT).show()
dispatcher.enqueue(picked.uuid, text = text, url = url, subject = subject)
finish()
}
.setOnCancelListener { finish() }
.setOnDismissListener { if (!isFinishing) finish() }
.show()
}

private fun displayNameFor(entry: ShareTargetEntry): String =
entry.shareTarget.label?.takeIf { it.isNotBlank() }
?: entry.longName.takeIf { it.isNotBlank() }
?: entry.shortName

private fun extractFirstUrl(text: String): String? =
Patterns.WEB_URL.matcher(text).takeIf { it.find() }?.group()

companion object {
private val logger = Logger.withTag(ShareTargetActivity::class.simpleName!!)
}
}
18 changes: 18 additions & 0 deletions composeApp/src/androidMain/res/xml/share_targets.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Static share-target metadata referenced by ShareTargetActivity in the
manifest. The actual per-watchapp shortcuts that surface in the share
sheet are pushed at runtime by ShareTargetSync (libpebble3) via
ShortcutManagerCompat.pushDynamicShortcut. This static descriptor exists
only so Android knows the activity is a valid Direct Share target — it
declares the supported intent-shape (ACTION_SEND text/plain) and binds
the dynamic shortcut category that ShareTargetSync uses.

See https://developer.android.com/training/sharing/receive#sharing-shortcuts
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="coredevices.coreapp.sharing.ShareTargetActivity">
<data android:mimeType="text/plain" />
<category android:name="io.rebble.libpebblecommon.WATCHAPP_SHARE" />
</share-target>
</shortcuts>
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ settings = "1.3.0"
kable = "0.42.0"
kmpio = "0.3.0"
androidx-core = "1.17.0"
androidx-sharetarget = "1.2.0"
monitorVersion = "1.8.0"
androidXTestVersion = "1.7.0"
androidXRulesVersion = "1.7.0"
Expand Down Expand Up @@ -125,6 +126,7 @@ settings-serialization = { group = "com.russhwolf", name = "multiplatform-settin
kable = { group = "com.juul.kable", name = "kable-core", version.ref = "kable" }
kmpio = { module = "io.github.skolson:kmp-io", version.ref = "kmpio" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-sharetarget = { module = "androidx.sharetarget:sharetarget", version.ref = "androidx-sharetarget" }
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitorVersion" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidXTestVersion" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXRulesVersion" }
Expand Down
18 changes: 18 additions & 0 deletions libpebble3/src/androidMain/assets/startup.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ navigator.geolocation.clearWatch = (id) => {
APP_MESSAGE_NACK: 'appmessage_nack',
GET_TIMELINE_TOKEN_SUCCESS: 'getTimelineTokenSuccess',
GET_TIMELINE_TOKEN_FAILURE: 'getTimelineTokenFailure',
SHARE_INTENT: 'shareintent',
};
Object.freeze(PebbleEventTypes);
const DEFAULT_TIMEOUT = 5000; // 5 seconds
Expand Down Expand Up @@ -292,6 +293,23 @@ navigator.geolocation.clearWatch = (id) => {
}
dispatchPebbleEvent(PebbleEventTypes.GET_TIMELINE_TOKEN_FAILURE, { payload });
};
/**
* Dispatched when the OS routes a share intent (Android ACTION_SEND, etc.)
* to this watchapp. The payload object is `{ text, url, subject }` —
* `text` is always present; `url` is a best-effort URL extraction; both
* `url` and `subject` may be null.
*/
global.signalShareIntent = (data) => {
var payload;
if (typeof data === 'string') {
// Android: payload arrives as a JSON string from evaluateJavascript
payload = data ? JSON.parse(data) : {};
} else {
// iOS: payload is already an object
payload = data || {};
}
dispatchPebbleEvent(PebbleEventTypes.SHARE_INTENT, payload);
};

const PebbleAPI = {
addEventListener: (type, callback, useCapture) => {
Expand Down
Loading