package social.grain import android.Manifest import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.os.Build import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import social.grain.data.push.GrainMessagingService /** * Clearing the launcher's badge. * * iOS drives the app icon badge from the unseen count directly, so reading the * notifications takes it down. Android draws that dot from whatever the app * still has posted in the shade instead, which means a push read inside the * app — rather than by tapping the notification — used to leave the dot up * with nothing behind it. */ @RunWith(AndroidJUnit4::class) class NotificationBadgeTest { private val instrumentation = InstrumentationRegistry.getInstrumentation() private val context = instrumentation.targetContext private val manager = NotificationManagerCompat.from(context) private val socialId = 4242 private val otherId = 4343 private val otherChannel = "test_other_channel" @Before fun grantPostingAndCreateChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { instrumentation.uiAutomation.grantRuntimePermission( context.packageName, Manifest.permission.POST_NOTIFICATIONS, ) } GrainMessagingService.ensureChannel(context) context.getSystemService(NotificationManager::class.java)?.createNotificationChannel( NotificationChannel(otherChannel, "Other", NotificationManager.IMPORTANCE_LOW), ) } @After fun tidyUp() { manager.cancel(socialId) manager.cancel(otherId) context.getSystemService(NotificationManager::class.java) ?.deleteNotificationChannel(otherChannel) } private fun pendingIntent() = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) private fun isPosted(id: Int) = manager.activeNotifications.any { it.id == id } /** Posting and cancelling both settle asynchronously, so poll rather than assume. */ private fun awaitPosted(id: Int, expected: Boolean): Boolean { repeat(50) { if (isPosted(id) == expected) return true SystemClock.sleep(100) } return false } @Test fun clearingTakesDownSocialPushesAndLeavesEverythingElseUp() { manager.notify( socialId, GrainMessagingService.notification(context, "Someone", "liked it", pendingIntent()), ) manager.notify( otherId, NotificationCompat.Builder(context, otherChannel) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("Uploading") .build(), ) assertTrue("the social push never posted", awaitPosted(socialId, expected = true)) assertTrue("the other notification never posted", awaitPosted(otherId, expected = true)) GrainMessagingService.clearPosted(context) assertTrue("the social push is still up", awaitPosted(socialId, expected = false)) assertTrue("an unrelated notification was cancelled too", isPosted(otherId)) } }