diff --git a/app/src/main/java/org/mozilla/fenix/gleanplumb/Message.kt b/app/src/main/java/org/mozilla/fenix/gleanplumb/Message.kt index 86af480be..061cdbeef 100644 --- a/app/src/main/java/org/mozilla/fenix/gleanplumb/Message.kt +++ b/app/src/main/java/org/mozilla/fenix/gleanplumb/Message.kt @@ -48,6 +48,8 @@ data class Message( * @param pressed Indicates if a message has been clicked. * @param dismissed Indicates if a message has been closed. * @param lastTimeShown A timestamp indicating when was the last time, the message was shown. + * @param latestBootIdentifier A unique boot identifier for when the message was last displayed + * (this may be a boot count or a boot id). */ data class Metadata( val id: String, @@ -55,5 +57,6 @@ data class Message( val pressed: Boolean = false, val dismissed: Boolean = false, val lastTimeShown: Long = 0L, + val latestBootIdentifier: String? = null, ) } diff --git a/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt b/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt index 8486bb57c..bf1bf64bc 100644 --- a/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt +++ b/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt @@ -27,6 +27,7 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.nimbus.MessageSurfaceId import org.mozilla.fenix.onboarding.MARKETING_CHANNEL_ID +import org.mozilla.fenix.utils.BootUtils import org.mozilla.fenix.utils.IntentUtils import org.mozilla.fenix.utils.createBaseNotification import java.util.concurrent.TimeUnit @@ -54,16 +55,26 @@ class MessageNotificationWorker( messagingStorage.getNextMessage(MessageSurfaceId.NOTIFICATION, messages) ?: return@launch + val currentBootUniqueIdentifier = BootUtils.getBootIdentifier(context) + val messageMetadata = nextMessage.metadata + // Device has NOT been power cycled. + if (messageMetadata.latestBootIdentifier == currentBootUniqueIdentifier) { + return@launch + } + val nimbusMessagingController = NimbusMessagingController(messagingStorage) // Update message as displayed. val updatedMessage = - nimbusMessagingController.updateMessageAsDisplayed(nextMessage) + nimbusMessagingController.updateMessageAsDisplayed( + nextMessage, + currentBootUniqueIdentifier, + ) nimbusMessagingController.onMessageDisplayed(updatedMessage) NotificationManagerCompat.from(context).notify( MESSAGE_TAG, - SharedIdsHelper.getNextIdForTag(context, updatedMessage.id), + SharedIdsHelper.getIdForTag(context, updatedMessage.id), buildNotification( context, updatedMessage, diff --git a/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt b/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt index 9923d5028..4680ee04d 100644 --- a/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt +++ b/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt @@ -20,12 +20,13 @@ class NimbusMessagingController( /** * Called when a message is just about to be shown to the user. * - * Update the display count and time shown metadata for the given [message]. + * Update the display count, time shown and boot identifier metadata for the given [message]. */ - fun updateMessageAsDisplayed(message: Message): Message { + fun updateMessageAsDisplayed(message: Message, bootIdentifier: String? = null): Message { val updatedMetadata = message.metadata.copy( displayCount = message.metadata.displayCount + 1, lastTimeShown = now(), + latestBootIdentifier = bootIdentifier, ) return message.copy( metadata = updatedMetadata, diff --git a/app/src/main/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorage.kt b/app/src/main/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorage.kt index c41a17f72..ecf991485 100644 --- a/app/src/main/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorage.kt +++ b/app/src/main/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorage.kt @@ -81,7 +81,7 @@ internal fun JSONArray.toMetadataMap(): Map { @Suppress("MaxLineLength") // To avoid adding any extra space to the string. internal fun Message.Metadata.toJson(): String { - return """{"id":"$id","displayCount":$displayCount,"pressed":$pressed,"dismissed":$dismissed,"lastTimeShown":$lastTimeShown}""" + return """{"id":"$id","displayCount":$displayCount,"pressed":$pressed,"dismissed":$dismissed,"lastTimeShown":$lastTimeShown,"latestBootIdentifier":"$latestBootIdentifier"}""" } internal fun JSONObject.toMetadata(): Message.Metadata { @@ -91,5 +91,6 @@ internal fun JSONObject.toMetadata(): Message.Metadata { pressed = optBoolean("pressed"), dismissed = optBoolean("dismissed"), lastTimeShown = optLong("lastTimeShown"), + latestBootIdentifier = optString("latestBootIdentifier"), ) } diff --git a/app/src/main/java/org/mozilla/fenix/utils/BootUtils.kt b/app/src/main/java/org/mozilla/fenix/utils/BootUtils.kt new file mode 100644 index 000000000..1952fdbac --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/utils/BootUtils.kt @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.utils + +import android.content.Context +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import java.io.File + +/** + * Provides access to system properties. + */ +interface BootUtils { + + /** + * Gets the device boot count. + * + * **Only for Android versions N(24) and above.** + */ + @RequiresApi(Build.VERSION_CODES.N) + fun getDeviceBootCount(context: Context): String + + val deviceBootId: String? + + val bootIdFileExists: Boolean + + companion object { + /** + * @return either the boot count or a boot id depending on the device Android version. + */ + fun getBootIdentifier(context: Context, bootUtils: BootUtils = BootUtilsImpl()): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + bootUtils.getDeviceBootCount(context) + } else { + return if (bootUtils.bootIdFileExists) { + bootUtils.deviceBootId ?: NO_BOOT_IDENTIFIER + } else { + NO_BOOT_IDENTIFIER + } + } + } + } +} + +/** + * Implementation of [BootUtils]. + */ +class BootUtilsImpl : BootUtils { + private val bootIdFile by lazy { File("/proc/sys/kernel/random/boot_id") } + + @RequiresApi(Build.VERSION_CODES.N) + override fun getDeviceBootCount(context: Context): String = + Settings.Global.getString(context.contentResolver, Settings.Global.BOOT_COUNT) + + override val deviceBootId: String? by lazy { bootIdFile.readLines().firstOrNull()?.trim() } + + override val bootIdFileExists: Boolean by lazy { bootIdFile.exists() } +} + +private const val NO_BOOT_IDENTIFIER = "no boot identifier available" diff --git a/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt b/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt index c09732c5d..7586e81cd 100644 --- a/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt @@ -51,19 +51,48 @@ class NimbusMessagingControllerTest { } @Test - fun `WHEN calling updateMessageAsDisplayed message THEN message metadata is updated`() = + fun `WHEN calling updateMessageAsDisplayed with message & no boot id THEN metadata for count and lastTimeShown is updated`() = coroutineScope.runTest { val message = createMessage("id-1") assertEquals(0, message.metadata.displayCount) assertEquals(0L, message.metadata.lastTimeShown) + assertNull(message.metadata.latestBootIdentifier) val expectedMessage = with(message) { - copy(metadata = metadata.copy(displayCount = 1, lastTimeShown = MOCK_TIME_MILLIS)) + copy( + metadata = metadata.copy( + displayCount = 1, + lastTimeShown = MOCK_TIME_MILLIS, + latestBootIdentifier = null, + ), + ) } assertEquals(expectedMessage, controller.updateMessageAsDisplayed(message)) } + @Test + fun `WHEN calling updateMessageAsDisplayed with message & boot id THEN metadata for count, lastTimeShown & latestBootIdentifier is updated`() = + coroutineScope.runTest { + val message = createMessage("id-1") + assertEquals(0, message.metadata.displayCount) + assertEquals(0L, message.metadata.lastTimeShown) + assertNull(message.metadata.latestBootIdentifier) + + val bootId = "test boot id" + val expectedMessage = with(message) { + copy( + metadata = metadata.copy( + displayCount = 1, + lastTimeShown = MOCK_TIME_MILLIS, + latestBootIdentifier = bootId, + ), + ) + } + + assertEquals(expectedMessage, controller.updateMessageAsDisplayed(message, bootId)) + } + @Test fun `GIVEN message not expired WHEN calling onMessageDisplayed THEN record a messageShown event and update storage`() = coroutineScope.runTest { diff --git a/app/src/test/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorageTest.kt b/app/src/test/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorageTest.kt index bed5e72f8..579a09dce 100644 --- a/app/src/test/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorageTest.kt +++ b/app/src/test/java/org/mozilla/fenix/gleanplumb/OnDiskMessageMetadataStorageTest.kt @@ -20,19 +20,12 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mozilla.experiments.nimbus.GleanPlumbInterface -import org.mozilla.experiments.nimbus.internal.FeatureHolder import org.mozilla.fenix.helpers.FenixRobolectricTestRunner -import org.mozilla.fenix.nimbus.Messaging @RunWith(FenixRobolectricTestRunner::class) class OnDiskMessageMetadataStorageTest { private lateinit var storage: OnDiskMessageMetadataStorage - private lateinit var metadataStorage: MessageMetadataStorage - private lateinit var gleanPlumb: GleanPlumbInterface - private lateinit var messagingFeature: FeatureHolder - private lateinit var messaging: Messaging @Before fun setup() { @@ -98,10 +91,11 @@ class OnDiskMessageMetadataStorageTest { pressed = false, dismissed = false, lastTimeShown = 0L, + latestBootIdentifier = "9", ) val expected = - """{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0}""" + """{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}""" assertEquals(expected, metadata.toJson()) } @@ -109,7 +103,7 @@ class OnDiskMessageMetadataStorageTest { @Test fun `WHEN calling toMetadata THEN return Metadata representation`() { val json = - """{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0}""" + """{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}""" val jsonObject = JSONObject(json) @@ -119,6 +113,7 @@ class OnDiskMessageMetadataStorageTest { pressed = false, dismissed = false, lastTimeShown = 0L, + latestBootIdentifier = "9", ) assertEquals(metadata, jsonObject.toMetadata()) @@ -127,7 +122,7 @@ class OnDiskMessageMetadataStorageTest { @Test fun `WHEN calling toMetadataMap THEN return map representation`() { val json = - """[{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0}]""" + """[{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}]""" val jsonArray = JSONArray(json) @@ -137,6 +132,7 @@ class OnDiskMessageMetadataStorageTest { pressed = false, dismissed = false, lastTimeShown = 0L, + latestBootIdentifier = "9", ) assertEquals(metadata, jsonArray.toMetadataMap()[metadata.id]) diff --git a/app/src/test/java/org/mozilla/fenix/utils/BootUtilsTest.kt b/app/src/test/java/org/mozilla/fenix/utils/BootUtilsTest.kt new file mode 100644 index 000000000..4395484c2 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/utils/BootUtilsTest.kt @@ -0,0 +1,79 @@ +package org.mozilla.fenix.utils + +import android.os.Build +import io.mockk.every +import io.mockk.mockk +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.utils.BootUtils.Companion.getBootIdentifier +import org.robolectric.annotation.Config + +private const val NO_BOOT_IDENTIFIER = "no boot identifier available" + +@RunWith(FenixRobolectricTestRunner::class) +class BootUtilsTest { + + private lateinit var bootUtils: BootUtils + + @Before + fun setUp() { + bootUtils = mockk(relaxed = true) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `WHEN no boot id file & Android version is less than N(24) THEN getBootIdentifier returns NO_BOOT_IDENTIFIER`() { + every { bootUtils.bootIdFileExists }.returns(false) + + assertEquals(NO_BOOT_IDENTIFIER, getBootIdentifier(testContext, bootUtils)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `WHEN boot id file returns null & Android version is less than N(24) THEN getBootIdentifier returns NO_BOOT_IDENTIFIER`() { + every { bootUtils.bootIdFileExists }.returns(true) + every { bootUtils.deviceBootId }.returns(null) + + assertEquals(NO_BOOT_IDENTIFIER, getBootIdentifier(testContext, bootUtils)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `WHEN boot id file has text & Android version is less than N(24) THEN getBootIdentifier returns the boot id`() { + every { bootUtils.bootIdFileExists }.returns(true) + val bootId = "test" + every { bootUtils.deviceBootId }.returns(bootId) + + assertEquals(bootId, getBootIdentifier(testContext, bootUtils)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `WHEN boot id file has text with whitespace & Android version is less than N(24) THEN getBootIdentifier returns the trimmed boot id`() { + every { bootUtils.bootIdFileExists }.returns(true) + val bootId = " test " + every { bootUtils.deviceBootId }.returns(bootId) + + assertEquals(bootId, getBootIdentifier(testContext, bootUtils)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun `WHEN Android version is N(24) THEN getBootIdentifier returns the boot count`() { + val bootCount = "9" + every { bootUtils.getDeviceBootCount(any()) }.returns(bootCount) + assertEquals(bootCount, getBootIdentifier(testContext, bootUtils)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.O]) + fun `WHEN Android version is more than N(24) THEN getBootIdentifier returns the boot count`() { + val bootCount = "9" + every { bootUtils.getDeviceBootCount(any()) }.returns(bootCount) + assertEquals(bootCount, getBootIdentifier(testContext, bootUtils)) + } +}