diff --git a/README.md b/README.md
index 3ddc27b..5f6162f 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@ fn on_push(context: Context, message: Message) {
}
```
-The payload is the FCM `data` map as a JSON object. A notification message that FCM shows itself reaches the app when the user taps it.
+The payload is the FCM `data` map as a JSON object. A notification message that FCM shows itself reaches the app when the user taps it. A tap into a running `singleTop` or `singleTask` Activity reaches it through that Activity's `onNewIntent`, which an androidx `ComponentActivity` reports by itself, so a plain `android.app.Activity` with such a launch mode calls `setIntent(intent)` in its `onNewIntent`.
An app whose `Config` has a `UnifiedPushConfig` gets its pushes through a [UnifiedPush](https://unifiedpush.org) distributor, such as ntfy, when the device has one, and through FCM otherwise. The first `register` with several distributors installed asks the user to pick one. The app holds one token at a time, so on UnifiedPush it is a Web Push subscription, and the payload is the message body the server sent.
diff --git a/android/src/main/kotlin/rs/pushups/ProcessState.kt b/android/src/main/kotlin/rs/pushups/ProcessState.kt
index 3e8a334..3e00ef9 100644
--- a/android/src/main/kotlin/rs/pushups/ProcessState.kt
+++ b/android/src/main/kotlin/rs/pushups/ProcessState.kt
@@ -3,8 +3,10 @@ package rs.pushups
import android.app.Activity
import android.app.Application
import android.content.Context
+import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
+import androidx.core.app.OnNewIntentProvider
import org.json.JSONArray
import org.json.JSONObject
import java.nio.charset.StandardCharsets
@@ -50,7 +52,12 @@ object ProcessState : Application.ActivityLifecycleCallbacks {
first to started
}
if (newSession) invokeNative("onSession") { Native.onSession(true) }
- handleTap(activity, firstActivity)
+ val context = activity.applicationContext
+ handleTap(context, activity.intent, firstActivity)
+ // A tap into a running singleTop or singleTask Activity arrives here, and an androidx Activity keeps its old intent.
+ if (activity is OnNewIntentProvider) {
+ activity.addOnNewIntentListener { intent -> handleTap(context, intent, firstActivity = false) }
+ }
}
override fun onActivityDestroyed(activity: Activity) {
@@ -66,7 +73,9 @@ object ProcessState : Application.ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) = Unit
- override fun onActivityResumed(activity: Activity) = Unit
+ // A plain Activity whose onNewIntent calls setIntent shows the tap here, and the delivered ids skip every other resume.
+ override fun onActivityResumed(activity: Activity) =
+ handleTap(activity.applicationContext, activity.intent, firstActivity = false)
override fun onActivityPaused(activity: Activity) = Unit
@@ -88,20 +97,20 @@ object ProcessState : Application.ActivityLifecycleCallbacks {
private fun tapStartedApp(firstActivity: Boolean): Boolean =
firstActivity && synchronized(lock) { pushesSeen == 0 }
- private fun handleTap(activity: Activity, firstActivity: Boolean) {
- val id = activity.intent?.getStringExtra(MSG_ID) ?: return
- val prefs = activity.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private fun handleTap(context: Context, intent: Intent?, firstActivity: Boolean) {
+ val extras = intent?.extras ?: return
+ val id = extras.getString(MSG_ID) ?: return
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
loadDelivered(prefs)
if (synchronized(lock) { delivered.contains(id) }) return
val startedApp = tapStartedApp(firstActivity)
- val bytes = tapPayload(activity).toString().toByteArray(StandardCharsets.UTF_8)
+ val bytes = tapPayload(extras).toString().toByteArray(StandardCharsets.UTF_8)
invokeNative("onMessage") { Native.onMessage(bytes, startedApp) }
rememberDelivered(prefs, id)
}
- private fun tapPayload(activity: Activity): JSONObject {
+ private fun tapPayload(extras: Bundle): JSONObject {
val json = JSONObject()
- val extras = activity.intent?.extras ?: return json
for (key in extras.keySet()) {
if (key.startsWith("google.") || key.startsWith("gcm.")) continue
if (key in DROPPED) continue
diff --git a/ci/android/build.gradle.kts b/ci/android/build.gradle.kts
index c877a04..4a591d8 100644
--- a/ci/android/build.gradle.kts
+++ b/ci/android/build.gradle.kts
@@ -37,6 +37,8 @@ project(":pushups") {
"androidTestImplementation"("androidx.test:runner:1.6.2")
"androidTestImplementation"("androidx.test.ext:junit:1.2.1")
"androidTestImplementation"("androidx.test.uiautomator:uiautomator:2.3.0")
+ // ComponentTapActivity, an androidx Activity like wry's, for the singleTop taps.
+ "androidTestImplementation"("androidx.activity:activity:1.9.3")
}
}
}
diff --git a/ci/android/run.sh b/ci/android/run.sh
index 802a43a..dcbecf5 100755
--- a/ci/android/run.sh
+++ b/ci/android/run.sh
@@ -112,6 +112,8 @@ run_variant unifiedpush "config-real background-handler" default UnifiedPushTest
adb shell pm disable-user --user 0 io.heckel.ntfy >/dev/null
run_variant unifiedpush-fallback "config-real background-handler" default UnifiedPushFallbackTest vapid "$vapid_hex"
adb shell pm enable io.heckel.ntfy >/dev/null
+# A tap into a running singleTop Activity, plain and androidx.
+run_variant single-top "config-real background-handler" single-top SingleTopTapTest
# Rust: every variant's profile against its own library, kept to the crate's own sources, with
# paths relative to the repository.
diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/SingleTopTapTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/SingleTopTapTest.kt
new file mode 100644
index 0000000..ec1dfb4
--- /dev/null
+++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/SingleTopTapTest.kt
@@ -0,0 +1,110 @@
+package rs.pushups.ci
+
+import android.app.Activity
+import android.content.Intent
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import org.json.JSONObject
+import org.junit.AfterClass
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.BeforeClass
+import org.junit.FixMethodOrder
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.MethodSorters
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Taps on a notification while a `singleTop` Activity runs, which reach its `onNewIntent` and
+ * create nothing, as the Android tap table lays out.
+ */
+@RunWith(AndroidJUnit4::class)
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+class SingleTopTapTest {
+
+ companion object {
+ private val instrumentation get() = InstrumentationRegistry.getInstrumentation()
+ private lateinit var token: String
+
+ @BeforeClass
+ @JvmStatic
+ fun register() {
+ // A fresh install may not post notifications from Android 13 on, and these tests are not about the prompt.
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ instrumentation.uiAutomation.grantRuntimePermission(
+ instrumentation.targetContext.packageName,
+ android.Manifest.permission.POST_NOTIFICATIONS,
+ )
+ }
+ assertNull(Probe.install())
+ ActivityScenario.launch(TapActivity::class.java).use {
+ Probe.setHandler()
+ assertNull(Probe.register())
+ token = awaitLine(Probe::events, "token ").removePrefix("token ")
+ }
+ }
+
+ @AfterClass
+ @JvmStatic
+ fun finish() = writeCoverage()
+ }
+
+ @Test
+ fun a1_aPlainActivityThatKeepsTheIntentGetsTheTapOnResume() {
+ tapInto(TapActivity::class.java, "rs.pushups.ci.TAP", TapActivity.newIntents)
+ }
+
+ @Test
+ fun a2_anAndroidxActivityGetsTheTapThroughItsListener() {
+ tapInto(ComponentTapActivity::class.java, "rs.pushups.ci.TAP_COMPONENT", ComponentTapActivity.newIntents)
+ }
+
+ /** Opens [activity], sends it to the background alive, taps a notification aimed at it, and awaits the tap. */
+ private fun tapInto(activity: Class, action: String, newIntents: AtomicInteger) {
+ val device = UiDevice.getInstance(instrumentation)
+ val context = instrumentation.targetContext
+ // ActivityScenario loses an Activity whose intent changes, so the test holds the instance itself.
+ val running = instrumentation.startActivitySync(
+ Intent(context, activity).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ try {
+ Probe.setHandler()
+ val id = "single-top-${UUID.randomUUID()}"
+ val title = "pushups tap $id"
+ // FCM shows a notification only while the app is in the background, and the Activity stays alive there.
+ device.pressHome()
+ Pushes.send(
+ context,
+ id,
+ JSONObject()
+ .put("token", token)
+ .put("notification", JSONObject().put("title", title).put("body", "tap to open"))
+ .put("data", JSONObject().put("id", id))
+ .put("android", JSONObject().put("notification", JSONObject().put("click_action", action))),
+ )
+ val before = newIntents.get()
+ device.openNotification()
+ val tapped = waitFor(DELIVERY_MS) {
+ device.wait(Until.findObject(By.text(title)), 1_000)?.click()
+ if (newIntents.get() > before) Unit else null
+ }
+ checkNotNull(tapped) { "tapping $title never reached the running Activity's onNewIntent" }
+ awaitLine(Probe::events, id, UI_MS)
+ // A later resume with the same intent must not deliver the tap again. Android 10 bars a test from bringing its own task back, so Instrumentation drives the resume, whose onResume runs the app's onActivityResumed.
+ instrumentation.runOnMainSync {
+ instrumentation.callActivityOnPause(running)
+ instrumentation.callActivityOnResume(running)
+ }
+ instrumentation.waitForIdleSync()
+ assertEquals(1, lines(Probe.events()).count { line -> line.contains(id) })
+ } finally {
+ instrumentation.runOnMainSync { running.finish() }
+ }
+ }
+}
diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/TapActivity.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/TapActivity.kt
index c2d8691..a87bebf 100644
--- a/ci/android/src/androidTest/kotlin/rs/pushups/ci/TapActivity.kt
+++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/TapActivity.kt
@@ -1,6 +1,36 @@
package rs.pushups.ci
import android.app.Activity
+import android.content.Intent
+import androidx.activity.ComponentActivity
+import java.util.concurrent.atomic.AtomicInteger
-/** The UI the tests open and close to move the process between UI sessions, and a tap's target. */
-class TapActivity : Activity()
+/**
+ * The UI the tests open and close to move the process between UI sessions, and a tap's target. A
+ * plain Activity that keeps the newest intent with `setIntent`, as such apps should.
+ */
+class TapActivity : Activity() {
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ newIntents.incrementAndGet()
+ }
+
+ companion object {
+ /** How many intents reached a running instance, which only a `singleTop` declaration allows. */
+ val newIntents = AtomicInteger()
+ }
+}
+
+/** An androidx Activity, as wry's is, that leaves `getIntent()` at the intent that created it. */
+class ComponentTapActivity : ComponentActivity() {
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ newIntents.incrementAndGet()
+ }
+
+ companion object {
+ /** How many intents reached a running instance. */
+ val newIntents = AtomicInteger()
+ }
+}
diff --git a/ci/android/src/androidTest/manifests/single-top/AndroidManifest.xml b/ci/android/src/androidTest/manifests/single-top/AndroidManifest.xml
new file mode 100644
index 0000000..d0aed18
--- /dev/null
+++ b/ci/android/src/androidTest/manifests/single-top/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+