Add remote megaphone snooze capabilities.

main
Cody Henthorne 2022-10-27 16:43:23 -04:00
rodzic 2ea5c7e3bc
commit c357c35303
11 zmienionych plików z 542 dodań i 13 usunięć

Wyświetl plik

@ -538,6 +538,7 @@ dependencies {
force = true
}
testImplementation testLibs.hamcrest.hamcrest
testImplementation testLibs.mockk
testImplementation(testFixtures(project(":libsignal-service")))

Wyświetl plik

@ -6,7 +6,10 @@ import android.database.Cursor
import android.net.Uri
import androidx.core.content.contentValuesOf
import androidx.core.net.toUri
import org.json.JSONException
import org.json.JSONObject
import org.signal.core.util.delete
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
@ -24,6 +27,8 @@ import java.util.concurrent.TimeUnit
class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) {
companion object {
private val TAG = Log.tag(RemoteMegaphoneDatabase::class.java)
private const val TABLE_NAME = "remote_megaphone"
private const val ID = "_id"
private const val UUID = "uuid"
@ -44,6 +49,10 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
private const val SECONDARY_ACTION_TEXT = "secondary_action_text"
private const val SHOWN_AT = "shown_at"
private const val FINISHED_AT = "finished_at"
private const val PRIMARY_ACTION_DATA = "primary_action_data"
private const val SECONDARY_ACTION_DATA = "secondary_action_data"
private const val SNOOZED_AT = "snoozed_at"
private const val SEEN_COUNT = "seen_count"
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
@ -65,7 +74,11 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
$PRIMARY_ACTION_TEXT TEXT,
$SECONDARY_ACTION_TEXT TEXT,
$SHOWN_AT INTEGER DEFAULT 0,
$FINISHED_AT INTEGER DEFAULT 0
$FINISHED_AT INTEGER DEFAULT 0,
$PRIMARY_ACTION_DATA TEXT DEFAULT NULL,
$SECONDARY_ACTION_DATA TEXT DEFAULT NULL,
$SNOOZED_AT INTEGER DEFAULT 0,
$SEEN_COUNT INTEGER DEFAULT 0
)
""".trimIndent()
@ -99,7 +112,7 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
.readToList { it.toRemoteMegaphoneRecord() }
}
fun getPotentialMegaphonesAndClearOld(now: Long = System.currentTimeMillis()): List<RemoteMegaphoneRecord> {
fun getPotentialMegaphonesAndClearOld(now: Long): List<RemoteMegaphoneRecord> {
val records: List<RemoteMegaphoneRecord> = readableDatabase
.select()
.from(TABLE_NAME)
@ -148,6 +161,17 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
.run()
}
fun snooze(remote: RemoteMegaphoneRecord) {
writableDatabase
.update(TABLE_NAME)
.values(
SEEN_COUNT to remote.seenCount + 1,
SNOOZED_AT to System.currentTimeMillis()
)
.where("$UUID = ?", remote.uuid)
.run()
}
fun clearImageUrl(uuid: String) {
writableDatabase
.update(TABLE_NAME)
@ -192,7 +216,11 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
BODY to body,
PRIMARY_ACTION_TEXT to primaryActionText,
SECONDARY_ACTION_TEXT to secondaryActionText,
FINISHED_AT to finishedAt
FINISHED_AT to finishedAt,
PRIMARY_ACTION_DATA to primaryActionData?.toString(),
SECONDARY_ACTION_DATA to secondaryActionData?.toString(),
SNOOZED_AT to snoozedAt,
SEEN_COUNT to seenCount
)
}
@ -216,7 +244,24 @@ class RemoteMegaphoneDatabase(context: Context, databaseHelper: SignalDatabase)
primaryActionText = requireString(PRIMARY_ACTION_TEXT),
secondaryActionText = requireString(SECONDARY_ACTION_TEXT),
shownAt = requireLong(SHOWN_AT),
finishedAt = requireLong(FINISHED_AT)
finishedAt = requireLong(FINISHED_AT),
primaryActionData = requireString(PRIMARY_ACTION_DATA).parseJsonObject(),
secondaryActionData = requireString(SECONDARY_ACTION_DATA).parseJsonObject(),
snoozedAt = requireLong(SNOOZED_AT),
seenCount = requireInt(SEEN_COUNT)
)
}
private fun String?.parseJsonObject(): JSONObject? {
if (this == null) {
return null
}
return try {
JSONObject(this)
} catch (e: JSONException) {
Log.w(TAG, "Unable to parse data", e)
null
}
}
}

Wyświetl plik

@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V159_ThreadUnreadSe
import org.thoughtcrime.securesms.database.helpers.migration.V160_SmsMmsExportedIndexMigration
import org.thoughtcrime.securesms.database.helpers.migration.V161_StorySendMessageIdIndex
import org.thoughtcrime.securesms.database.helpers.migration.V162_ThreadUnreadSelfMentionCountFixup
import org.thoughtcrime.securesms.database.helpers.migration.V163_RemoteMegaphoneSnoozeSupportMigration
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -26,7 +27,7 @@ object SignalDatabaseMigrations {
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
const val DATABASE_VERSION = 162
const val DATABASE_VERSION = 163
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -85,6 +86,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 162) {
V162_ThreadUnreadSelfMentionCountFixup.migrate(context, db, oldVersion, newVersion)
}
if (oldVersion < 163) {
V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion)
}
}
@JvmStatic

Wyświetl plik

@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Add columns needed to track remote megaphone specific snooze rates.
*/
object V163_RemoteMegaphoneSnoozeSupportMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (columnMissing(db, "primary_action_data")) {
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN primary_action_data TEXT DEFAULT NULL")
}
if (columnMissing(db, "secondary_action_data")) {
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN secondary_action_data TEXT DEFAULT NULL")
}
if (columnMissing(db, "snoozed_at")) {
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN snoozed_at INTEGER DEFAULT 0")
}
if (columnMissing(db, "seen_count")) {
db.execSQL("ALTER TABLE remote_megaphone ADD COLUMN seen_count INTEGER DEFAULT 0")
}
}
private fun columnMissing(db: SupportSQLiteDatabase, column: String): Boolean {
db.query("PRAGMA table_info(remote_megaphone)", null).use { cursor ->
val nameColumnIndex = cursor.getColumnIndexOrThrow("name")
while (cursor.moveToNext()) {
val name = cursor.getString(nameColumnIndex)
if (name == column) {
return false
}
}
}
return true
}
}

Wyświetl plik

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.database.model
import android.net.Uri
import org.json.JSONObject
/**
* Represents a Remote Megaphone.
@ -24,7 +25,11 @@ data class RemoteMegaphoneRecord(
val primaryActionText: String?,
val secondaryActionText: String?,
val shownAt: Long = 0,
val finishedAt: Long = 0
val finishedAt: Long = 0,
val primaryActionData: JSONObject? = null,
val secondaryActionData: JSONObject? = null,
val snoozedAt: Long = 0,
val seenCount: Int = 0
) {
@get:JvmName("hasPrimaryAction")
val hasPrimaryAction = primaryActionId != null && primaryActionText != null
@ -32,6 +37,16 @@ data class RemoteMegaphoneRecord(
@get:JvmName("hasSecondaryAction")
val hasSecondaryAction = secondaryActionId != null && secondaryActionText != null
fun getDataForAction(actionId: ActionId): JSONObject? {
return if (primaryActionId == actionId) {
primaryActionData
} else if (secondaryActionId == actionId) {
secondaryActionData
} else {
null
}
}
enum class ActionId(val id: String, val isDonateAction: Boolean = false) {
SNOOZE("snooze"),
FINISH("finish"),

Wyświetl plik

@ -2,6 +2,9 @@ package org.thoughtcrime.securesms.jobs
import androidx.core.os.LocaleListCompat
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
import org.json.JSONObject
import org.signal.core.util.Hex
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
@ -275,7 +278,9 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
title = megaphone.translation.title,
body = megaphone.translation.body,
primaryActionText = megaphone.translation.primaryCtaText,
secondaryActionText = megaphone.translation.secondaryCtaText
secondaryActionText = megaphone.translation.secondaryCtaText,
primaryActionData = megaphone.remoteMegaphone.primaryCtaData?.takeIf { it is ObjectNode }?.let { JSONObject(it.toString()) },
secondaryActionData = megaphone.remoteMegaphone.secondaryCtaData?.takeIf { it is ObjectNode }?.let { JSONObject(it.toString()) }
)
SignalDatabase.remoteMegaphones.insert(record)
@ -384,7 +389,9 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
@JsonProperty val showForNumberOfDays: Long?,
@JsonProperty val conditionalId: String?,
@JsonProperty val primaryCtaId: String?,
@JsonProperty val secondaryCtaId: String?
@JsonProperty val secondaryCtaId: String?,
@JsonProperty val primaryCtaData: JsonNode?,
@JsonProperty val secondaryCtaData: JsonNode?
)
data class TranslatedReleaseNote(

Wyświetl plik

@ -307,7 +307,7 @@ public final class Megaphones {
}
private static @NonNull Megaphone buildRemoteMegaphone(@NonNull Context context) {
RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow();
RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(System.currentTimeMillis());
if (record != null) {
Megaphone.Builder builder = new Megaphone.Builder(Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)

Wyświetl plik

@ -4,7 +4,10 @@ import android.app.Application
import android.content.Context
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import org.json.JSONArray
import org.json.JSONException
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
@ -19,16 +22,23 @@ import org.thoughtcrime.securesms.util.LocaleFeatureFlags
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.VersionTracker
import java.util.Objects
import kotlin.math.min
import kotlin.time.Duration.Companion.days
/**
* Access point for interacting with Remote Megaphones.
*/
object RemoteMegaphoneRepository {
private val TAG = Log.tag(RemoteMegaphoneRepository::class.java)
private val db: RemoteMegaphoneDatabase = SignalDatabase.remoteMegaphones
private val context: Application = ApplicationDependencies.getApplication()
private val snooze: Action = Action { _, controller, _ -> controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE) }
private val snooze: Action = Action { _, controller, remote ->
controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE)
db.snooze(remote)
}
private val finish: Action = Action { context, controller, remote ->
if (remote.imageUri != null) {
@ -40,7 +50,7 @@ object RemoteMegaphoneRepository {
private val donate: Action = Action { context, controller, remote ->
controller.onMegaphoneNavigationRequested(AppSettingsActivity.manageSubscriptions(context))
finish.run(context, controller, remote)
snooze.run(context, controller, remote)
}
private val actions = mapOf(
@ -65,12 +75,13 @@ object RemoteMegaphoneRepository {
@WorkerThread
@JvmStatic
fun getRemoteMegaphoneToShow(): RemoteMegaphoneRecord? {
return db.getPotentialMegaphonesAndClearOld()
fun getRemoteMegaphoneToShow(now: Long = System.currentTimeMillis()): RemoteMegaphoneRecord? {
return db.getPotentialMegaphonesAndClearOld(now)
.asSequence()
.filter { it.imageUrl == null || it.imageUri != null }
.filter { it.countries == null || LocaleFeatureFlags.shouldShowReleaseNote(it.uuid, it.countries) }
.filter { it.conditionalId == null || checkCondition(it.conditionalId) }
.filter { it.snoozedAt == 0L || checkSnooze(it, now) }
.firstOrNull()
}
@ -95,6 +106,17 @@ object RemoteMegaphoneRepository {
}
}
private fun checkSnooze(record: RemoteMegaphoneRecord, now: Long): Boolean {
if (record.seenCount == 0) {
return true
}
val gaps: JSONArray? = record.getDataForAction(ActionId.SNOOZE)?.getJSONArray("snoozeDurationDays")?.takeIf { it.length() > 0 }
val gapDays: Int? = gaps?.getIntOrNull(record.seenCount - 1)
return gapDays == null || (record.snoozedAt + gapDays.days.inWholeMilliseconds <= now)
}
private fun shouldShowDonateMegaphone(): Boolean {
return VersionTracker.getDaysSinceFirstInstalled(context) >= 7 &&
PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS &&
@ -108,4 +130,16 @@ object RemoteMegaphoneRepository {
fun interface Action {
fun run(context: Context, controller: MegaphoneActionController, remoteMegaphone: RemoteMegaphoneRecord)
}
/**
* Gets the int at the specified index, or last index of array if larger then array length, or null if unable to parse json
*/
private fun JSONArray.getIntOrNull(index: Int): Int? {
return try {
getInt(min(index, length() - 1))
} catch (e: JSONException) {
Log.w(TAG, "Unable to parse", e)
null
}
}
}

Wyświetl plik

@ -0,0 +1,206 @@
package org.thoughtcrime.securesms.megaphone
import android.app.Application
import android.net.Uri
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
import org.json.JSONObject
import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.SignalStoreRule
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.util.toMillis
import java.time.LocalDateTime
import java.util.UUID
/**
* [RemoteMegaphoneRepository] is an Kotlin Object, which means it's like a singleton and thus maintains
* state and dependencies across tests. You must be aware of this when mocking/testing.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class RemoteMegaphoneRepositoryTest {
@get:Rule
val signalStore: SignalStoreRule = SignalStoreRule()
@Before
fun setUp() {
}
@After
fun tearDown() {
clearMocks(remoteMegaphoneDatabase)
}
/** Should return null if no megaphones in database. */
@Test
fun getRemoteMegaphoneToShow_noMegaphones() {
// GIVEN
every { remoteMegaphoneDatabase.getPotentialMegaphonesAndClearOld(any()) } returns emptyList()
// WHEN
val record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(0)
// THEN
assertThat(record, nullValue())
}
@Test
fun getRemoteMegaphoneToShow_oneMegaphone() {
// GIVEN
every { remoteMegaphoneDatabase.getPotentialMegaphonesAndClearOld(any()) } returns listOf(megaphone(1))
// WHEN
val record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(0)
// THEN
assertThat(record, notNullValue())
}
@Test
fun getRemoteMegaphoneToShow_snoozedMegaphone() {
// GIVEN
val snoozed = megaphone(
id = 1,
seenCount = 1,
snoozedAt = now.minusDays(1).toMillis(),
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
secondaryActionData = JSONObject("{\"snoozeDurationDays\":[3]}")
)
every { remoteMegaphoneDatabase.getPotentialMegaphonesAndClearOld(now.toMillis()) } returns listOf(snoozed)
// WHEN
val record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(now.toMillis())
// THEN
assertThat(record, nullValue())
}
@Test
fun getRemoteMegaphoneToShow_oldSnoozedMegaphone() {
// GIVEN
val snoozed = megaphone(
id = 1,
seenCount = 1,
snoozedAt = now.minusDays(5).toMillis(),
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
secondaryActionData = JSONObject("{\"snoozeDurationDays\":[3]}")
)
every { remoteMegaphoneDatabase.getPotentialMegaphonesAndClearOld(now.toMillis()) } returns listOf(snoozed)
// WHEN
val record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(now.toMillis())
// THEN
assertThat(record, notNullValue())
}
@Test
fun getRemoteMegaphoneToShow_multipleOldSnoozedMegaphone() {
// GIVEN
val snoozed = megaphone(
id = 1,
seenCount = 5,
snoozedAt = now.minusDays(8).toMillis(),
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
secondaryActionData = JSONObject("{\"snoozeDurationDays\":[3, 5, 7]}")
)
every { remoteMegaphoneDatabase.getPotentialMegaphonesAndClearOld(now.toMillis()) } returns listOf(snoozed)
// WHEN
val record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow(now.toMillis())
// THEN
assertThat(record, notNullValue())
}
companion object {
private val now = LocalDateTime.of(2021, 11, 5, 12, 0)
private val remoteMegaphoneDatabase: RemoteMegaphoneDatabase = mockk()
@BeforeClass
@JvmStatic
fun classSetup() {
mockkObject(SignalDatabase.Companion)
every { SignalDatabase.remoteMegaphones } returns remoteMegaphoneDatabase
}
@AfterClass
@JvmStatic
fun classCleanup() {
unmockkObject(SignalDatabase.Companion)
}
fun megaphone(
id: Long,
priority: Long = 100,
uuid: String = UUID.randomUUID().toString(),
countries: String? = null,
minimumVersion: Int = 100,
doNotShowBefore: Long = 0,
doNotShowAfter: Long = Long.MAX_VALUE,
showForNumberOfDays: Long = Long.MAX_VALUE,
conditionalId: String? = null,
primaryActionId: RemoteMegaphoneRecord.ActionId? = null,
secondaryActionId: RemoteMegaphoneRecord.ActionId? = null,
imageUrl: String? = null,
imageUri: Uri? = null,
title: String = "",
body: String = "",
primaryActionText: String? = null,
secondaryActionText: String? = null,
shownAt: Long = 0,
finishedAt: Long = 0,
primaryActionData: JSONObject? = null,
secondaryActionData: JSONObject? = null,
snoozedAt: Long = 0,
seenCount: Int = 0
): RemoteMegaphoneRecord {
return RemoteMegaphoneRecord(
id,
priority,
uuid,
countries,
minimumVersion,
doNotShowBefore,
doNotShowAfter,
showForNumberOfDays,
conditionalId,
primaryActionId,
secondaryActionId,
imageUrl,
imageUri,
title,
body,
primaryActionText,
secondaryActionText,
shownAt,
finishedAt,
primaryActionData,
secondaryActionData,
snoozedAt,
seenCount,
)
}
}
}

Wyświetl plik

@ -155,6 +155,7 @@ dependencyResolutionManagement {
alias('hamcrest-hamcrest').to('org.hamcrest:hamcrest:2.2')
alias('assertj-core').to('org.assertj:assertj-core:3.11.1')
alias('square-okhttp-mockserver').to('com.squareup.okhttp3:mockwebserver:3.12.13')
alias('mockk').to('io.mockk:mockk:1.13.2')
alias('conscrypt-openjdk-uber').to('org.conscrypt:conscrypt-openjdk-uber:2.0.0')
}

Wyświetl plik

@ -2569,6 +2569,86 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="a6a8701ed35259ead9c7fc86e855e26559050e0a60a3be66a8f5c327c53eeaf8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk" version="1.13.2">
<artifact name="mockk-1.13.2.module">
<sha256 value="763ca22e765cc710ef5ba32c8e016a31c0217579f6e61b173282e9338d2dd190" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-metadata-1.13.2.jar">
<sha256 value="5a01958c55e968afb2d03c73f0225f3285ee75a51aa0013071972795b83ba02c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-agent" version="1.13.2">
<artifact name="mockk-agent-1.13.2.module">
<sha256 value="4ada00e221245eda8ac728e24d8fedffd4ffc7ebeb7dd600057f544000e020ab" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-agent-metadata-1.13.2.jar">
<sha256 value="ec1468af9a0cb896126c1d0093246854a347192eda2e88ed45782bb56ca72982" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-agent-api" version="1.13.2">
<artifact name="mockk-agent-api-1.13.2.module">
<sha256 value="eb8a5b62cd9e2030da816dbad8b00cba108757eca11871629c7a2af47232ea12" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-agent-api-metadata-1.13.2.jar">
<sha256 value="a2e490f6424c49ce98ca42ca191731ea6ccae3914adbc846761e10474f7bbae1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-agent-api-jvm" version="1.13.2">
<artifact name="mockk-agent-api-jvm-1.13.2.jar">
<sha256 value="62baf321fd6a5121851b56800c20e07d5d4162f3f5866a8cd0d62cdd204588d4" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-agent-api-jvm-1.13.2.module">
<sha256 value="801ad0eabd00490bc47543ded2804c059324360280b75e375d2b91b44b2c7c0e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-agent-jvm" version="1.13.2">
<artifact name="mockk-agent-jvm-1.13.2.jar">
<sha256 value="ce89631cd9a1cffd7032342b0b48724cd84640083e9e13a39b3e3a019c866774" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-agent-jvm-1.13.2.module">
<sha256 value="f3378af6f8a84f064f55420b7c9c7be8eda238233dcf9be6515277f1fb5c4d5f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-core" version="1.13.2">
<artifact name="mockk-core-1.13.2.module">
<sha256 value="87b25b5325eeb89c7807846c4b99a8c583bf6b43399c27ba1df513d59e0ce915" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-core-metadata-1.13.2.jar">
<sha256 value="a2e490f6424c49ce98ca42ca191731ea6ccae3914adbc846761e10474f7bbae1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-core-jvm" version="1.13.2">
<artifact name="mockk-core-jvm-1.13.2.jar">
<sha256 value="485cb35c61fe764a58c6a015060f977758392654de4afcda36353aeb7a9c634f" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-core-jvm-1.13.2.module">
<sha256 value="632a2aeacbd83e6b4603a241eb5d06b2d7f90c230fa30d35abb1e8a2e512813f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-dsl" version="1.13.2">
<artifact name="mockk-dsl-1.13.2.module">
<sha256 value="a902b145ef6fc1280e4b74d6f89d44ec129fee722e715a48db8eb9fb39633c96" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-dsl-metadata-1.13.2.jar">
<sha256 value="794a42eb917780f99fe65bdc0d5483ab9a19bd3c532feb8c01c9586f5873f890" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-dsl-jvm" version="1.13.2">
<artifact name="mockk-dsl-jvm-1.13.2.jar">
<sha256 value="994203a45ea608078cbe746d8aa4855f4274cf2913fb1e31c27f3d2a096c428a" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-dsl-jvm-1.13.2.module">
<sha256 value="5e631c733c03a9ddc7300fc7000e9537a9039ea1d25c9e1244088fcdd253cc13" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.mockk" name="mockk-jvm" version="1.13.2">
<artifact name="mockk-jvm-1.13.2.jar">
<sha256 value="2917b9beb291078425a108a34b3611398ca9e8c58dcdf5a024c463971671b20b" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockk-jvm-1.13.2.module">
<sha256 value="c15b38f37ee61192e3f8640619c40b61b8cb39e931dd87e2873cbc3ca84c72d7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-buffer" version="4.1.34.Final">
<artifact name="netty-buffer-4.1.34.Final.jar">
<sha256 value="39dfe88df8505fd01fbf9c1dbb6b6fa9b0297e453c3dc4ce039ea578aea2eaa3" origin="Generated by Gradle"/>
@ -3304,6 +3384,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="1e1f57209f7238c3fd1735a1b9339a56565507dca249f8371bf59d91f601aeaa" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.7.10">
<artifact name="kotlin-reflect-1.7.10.jar">
<sha256 value="187c5e5a588a6ed18c3a41b54df138a5944121bdb396be1c3fa4abee67397955" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-script-runtime" version="1.5.31">
<artifact name="kotlin-script-runtime-1.5.31.jar">
<sha256 value="24e450fee7645ed3590981dddccf397c0d9ebb725815c94c4f555cc3db2f9f96" origin="Generated by Gradle"/>
@ -3449,6 +3534,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="f1b0634dbb94172038463020bb2dd45ca26849f8ce29d625acb0f1569d11dbee" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.7.10">
<artifact name="kotlin-stdlib-jdk7-1.7.10.jar">
<sha256 value="54f61351b1936ad88f4e53059fe781e723eae51d78ed9e7422d8b403574ec682" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.3.71">
<artifact name="kotlin-stdlib-jdk8-1.3.71.jar">
<sha256 value="a22192ac779ba8eff09d07084ae503e8be9e7c8ca1cb2b511ff8af4c68d83d66" origin="Generated by Gradle"/>
@ -3479,6 +3569,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="dab45489b47736d59fce44b80676f1947a9b6bcab10fd60e878a83bd82a6954c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.7.10">
<artifact name="kotlin-stdlib-jdk8-1.7.10.jar">
<sha256 value="8aafdd60c94f454c92e5066d266a5ed53ecc63c78f623b3fd9db56fea4032873" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-test" version="1.3.71">
<artifact name="kotlin-test-1.3.71.jar">
<sha256 value="d9236ecd3c5b22c23e2892537276f3c1fab63f439b5bdceb115a8768aacb3998" origin="Generated by Gradle"/>
@ -3546,6 +3641,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="558a3e6e5cc501f13aac0436bd08bb342d10de9f8bcfd3ba6d582f433e92c3b1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-android" version="1.6.4">
<artifact name="kotlinx-coroutines-android-1.6.4.jar">
<sha256 value="3fdc0eed5bc4b83ee9622774520a2db25470370eacd1581cac1e37704f095b00" origin="Generated by Gradle"/>
</artifact>
<artifact name="kotlinx-coroutines-android-1.6.4.module">
<sha256 value="68516559e6f84a621b9783cd892a64630ccd7875843588ddb3f0501425e33f15" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.4.1">
<artifact name="kotlinx-coroutines-core-1.4.1.module">
<sha256 value="3c00e44941f134b18cadbc5f18ab7b7f23d3ef1f78af95e344cb9c605db21a44" origin="Generated by Gradle"/>
@ -3564,6 +3667,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="67c807c236e19c32020fc5ba82b273fda1e76cad50693f0917efe6071f159db9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.6.4">
<artifact name="kotlinx-coroutines-core-1.6.4.module">
<sha256 value="a6eed4a1835588e7c84fcd7b0475fce9a7b3444c870ebc797b88ba64ccf4576b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core-jvm" version="1.4.1">
<artifact name="kotlinx-coroutines-core-jvm-1.4.1.jar">
<sha256 value="6d2f87764b6638f27aff12ed380db4b63c9d46ba55dc32683a650598fa5a3e22" origin="Generated by Gradle"/>
@ -3596,6 +3704,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="f31b672a11feb0ee49d5d49143067e93c33f5866768ae42b9a324b53cdad36e1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core-jvm" version="1.6.4">
<artifact name="kotlinx-coroutines-core-jvm-1.6.4.jar">
<sha256 value="c24c8bb27bb320c4a93871501a7e5e0c61607638907b197aef675513d4c820be" origin="Generated by Gradle"/>
</artifact>
<artifact name="kotlinx-coroutines-core-jvm-1.6.4.module">
<sha256 value="0d94c8a41483e7c2707ebd693e1b1357a84152998ce85550ebbc54ca4321a3a7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core-metadata" version="1.4.1">
<artifact name="kotlinx-coroutines-core-metadata-1.4.1-all.jar">
<sha256 value="877057d99a7fff9282059ba6631a9039bf3b54795d397b7e69a67363b7d2dcfe" origin="Generated by Gradle"/>
@ -3663,6 +3779,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="25de454b3add67654d6691b53de7593b502967b5ff549547b104d7961f1a52bd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.8.2">
<artifact name="junit-bom-5.8.2.module">
<sha256 value="40cfad993fa70ecdf2af74d0c56da1484ee220964be8f932cfe632be9a2733fa" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter" version="5.8.2">
<artifact name="junit-jupiter-5.8.2.jar">
<sha256 value="4f5c1cc6432244cd16e36aa0e02b74bce34a81ff95a13d63d50951ec4ce3f4bd" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-jupiter-5.8.2.module">
<sha256 value="d9df47b3c7431883ab1f1f1d820c2980ebcbd753edc4c82b99bf5ec2c533ad2e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-api" version="5.7.0">
<artifact name="junit-jupiter-api-5.7.0.jar">
<sha256 value="b03f78e0daeed2d77a0af9bcd662b4cdb9693f7ee72e01a539b508b84c63d182" origin="Generated by Gradle"/>
@ -3671,6 +3800,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="d1a7bae6cd44ad6d96715261410eef2338a494436d667280d1373a47c17e241c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-api" version="5.8.2">
<artifact name="junit-jupiter-api-5.8.2.jar">
<sha256 value="1808ee87e0f718cd6e25f3b75afc17956ac8a3edc48c7e9bab9f19f9a79e3801" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-jupiter-api-5.8.2.module">
<sha256 value="7e9af4dfff6267acddd157d9e11ba0d5dc91b332fa74bc4c65939e4517a1b770" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-engine" version="5.7.0">
<artifact name="junit-jupiter-engine-5.7.0.jar">
<sha256 value="dfa26af94644ac2612dde6625852fcb550a0d21caa243257de54cba738ba87af" origin="Generated by Gradle"/>
@ -3679,6 +3816,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="c36bda48228eef5c40cdc9a9d6303bb848382cdb0884b3677d49182b4ec7a1a4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-engine" version="5.8.2">
<artifact name="junit-jupiter-engine-5.8.2.jar">
<sha256 value="753b7726cdd158bb34cedb94c161e2291896f47832a1e9eda53d970020a8184e" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-jupiter-engine-5.8.2.module">
<sha256 value="a56204c716c2379970ca8e3fe2a72e3a030cd9027328d3ce1457dd10c0150e7e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.jupiter" name="junit-jupiter-params" version="5.8.2">
<artifact name="junit-jupiter-params-5.8.2.jar">
<sha256 value="d1c22d6fe5483568c08c8913f34abd2303490c3480ce6c18a2ea31c65e44102a" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-jupiter-params-5.8.2.module">
<sha256 value="5023e4e3ec156ec6bd3cf655f3070cfc5a3e8aad5d51b4bbe025a792e9498d73" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-commons" version="1.7.0">
<artifact name="junit-platform-commons-1.7.0.jar">
<sha256 value="5330ee87cc7586e6e25175a34e9251624ff12ff525269d3415d0b4ca519b6fea" origin="Generated by Gradle"/>
@ -3687,6 +3840,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="b0a5c7a1b7e409d1f5cc963d9aea1c77c34b90e163d6842a4489c6aa07ff8ee2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-commons" version="1.8.2">
<artifact name="junit-platform-commons-1.8.2.jar">
<sha256 value="d2e015fca7130e79af2f4608dc54415e4b10b592d77333decb4b1a274c185050" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-platform-commons-1.8.2.module">
<sha256 value="342847d3046fea435595692db4f05d5f039e0e1d1e204f4d5755902557082626" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-engine" version="1.7.0">
<artifact name="junit-platform-engine-1.7.0.jar">
<sha256 value="75f21a20dc594afdc875736725b408cec6d0344874d29f34b2dd3075500236f2" origin="Generated by Gradle"/>
@ -3695,6 +3856,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="6510a9fa881cef42d41485538dbc54749426db1e0b14ec433915ea5f2448079a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-engine" version="1.8.2">
<artifact name="junit-platform-engine-1.8.2.jar">
<sha256 value="0b7d000f8c3e8e5f7d6b819649936e7b9938314e87c8f983805218ea57567e59" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-platform-engine-1.8.2.module">
<sha256 value="eba77b36efdf75a67f46438333825f9e448f550d521e7249d950358580ee636b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit.platform" name="junit-platform-launcher" version="1.7.0">
<artifact name="junit-platform-launcher-1.7.0.jar">
<sha256 value="fbdc748fde4c4279fe1d3c607447cb3b7ccd45d7338fc574f8a894ddf2d16818" origin="Generated by Gradle"/>
@ -4026,6 +4195,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="cdba07964d1bb40a0761485c6b1e8c2f8fd9eb1d19c53928ac0d7f9510105c57" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.slf4j" name="slf4j-api" version="1.7.36">
<artifact name="slf4j-api-1.7.36.jar">
<sha256 value="d3ef575e3e4979678dc01bf1dcce51021493b4d11fb7f1be8ad982877c16a1c0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.smali" name="dexlib2" version="2.2.4">
<artifact name="dexlib2-2.2.4.jar">
<sha256 value="cb2677bfb66cfbc954e96e806ac6bda13051ad37754f9d1bcce38514e50e41e6" origin="Generated by Gradle"/>