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 @@ + + + + + + + + + + + + + + + + + + + + + + + +