Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 17 additions & 8 deletions android/src/main/kotlin/rs/pushups/ProcessState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ci/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
2 changes: 2 additions & 0 deletions ci/android/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
110 changes: 110 additions & 0 deletions ci/android/src/androidTest/kotlin/rs/pushups/ci/SingleTopTapTest.kt
Original file line number Diff line number Diff line change
@@ -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 <A : Activity> tapInto(activity: Class<A>, 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() }
}
}
}
34 changes: 32 additions & 2 deletions ci/android/src/androidTest/kotlin/rs/pushups/ci/TapActivity.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- The library PushupsProvider loads, as an app names its own. -->
<meta-data
android:name="pushups.lib_name"
android:value="pushups_probe" />

<!-- A singleTop Activity, so a tap while it runs reaches onNewIntent and creates nothing. -->
<activity
android:name="rs.pushups.ci.TapActivity"
android:exported="false"
android:launchMode="singleTop">
<intent-filter>
<action android:name="rs.pushups.ci.TAP" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<!-- The same for an androidx Activity, reached by its own click_action. -->
<activity
android:name="rs.pushups.ci.ComponentTapActivity"
android:exported="false"
android:launchMode="singleTop">
<intent-filter>
<action android:name="rs.pushups.ci.TAP_COMPONENT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
Loading