Merge pull request #3333 from nextcloud/rememberFilter

Save filter state
This commit is contained in:
Andy Scherzinger 2023-09-21 23:50:38 +02:00 committed by GitHub
commit dec27e9da6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 270 additions and 64 deletions

View File

@ -0,0 +1,139 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "666fcc4bbbdf3ff121b8f1ace8fcbcb8",
"entities": [
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pushConfigurationState",
"columnName": "pushConfigurationState",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "capabilities",
"columnName": "capabilities",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientCertificate",
"columnName": "clientCertificate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalSignalingServer",
"columnName": "externalSignalingServer",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "current",
"columnName": "current",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledForDeletion",
"columnName": "scheduledForDeletion",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ArbitraryStorage",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))",
"fields": [
{
"fieldPath": "accountIdentifier",
"columnName": "accountIdentifier",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "storageObject",
"columnName": "object",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountIdentifier",
"key"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '666fcc4bbbdf3ff121b8f1ace8fcbcb8')"
]
}
}

View File

@ -25,7 +25,7 @@ import com.nextcloud.talk.data.storage.model.ArbitraryStorage
import io.reactivex.Maybe import io.reactivex.Maybe
class ArbitraryStorageManager(private val arbitraryStoragesRepository: ArbitraryStoragesRepository) { class ArbitraryStorageManager(private val arbitraryStoragesRepository: ArbitraryStoragesRepository) {
fun storeStorageSetting(accountIdentifier: Long, key: String?, value: String?, objectString: String?) { fun storeStorageSetting(accountIdentifier: Long, key: String, value: String?, objectString: String?) {
arbitraryStoragesRepository.saveArbitraryStorage(ArbitraryStorage(accountIdentifier, key, objectString, value)) arbitraryStoragesRepository.saveArbitraryStorage(ArbitraryStorage(accountIdentifier, key, objectString, value))
} }

View File

@ -79,6 +79,7 @@ import com.nextcloud.talk.adapters.items.MessageResultItem
import com.nextcloud.talk.adapters.items.MessagesTextHeaderItem import com.nextcloud.talk.adapters.items.MessagesTextHeaderItem
import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsActivity
import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.data.user.model.User
@ -106,6 +107,7 @@ import com.nextcloud.talk.utils.ClosedInterfaceImpl
import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.FileUtils
import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.Mimetype
import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.ParticipantPermissions
import com.nextcloud.talk.utils.UserIdUtils
import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_MSG_FLAG import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_MSG_FLAG
@ -158,6 +160,9 @@ class ConversationsListActivity :
@Inject @Inject
lateinit var platformPermissionUtil: PlatformPermissionUtil lateinit var platformPermissionUtil: PlatformPermissionUtil
@Inject
lateinit var arbitraryStorageManager: ArbitraryStorageManager
override val appBarLayoutType: AppBarLayoutType override val appBarLayoutType: AppBarLayoutType
get() = AppBarLayoutType.SEARCH_BAR get() = AppBarLayoutType.SEARCH_BAR
@ -272,6 +277,65 @@ class ConversationsListActivity :
showSearchOrToolbar() showSearchOrToolbar()
} }
fun filterConversation() {
val accountId = UserIdUtils.getIdForUser(userManager.currentUser.blockingGet())
filterState[FilterConversationFragment.UNREAD] = (
arbitraryStorageManager.getStorageSetting(
accountId,
FilterConversationFragment.UNREAD,
""
).blockingGet()?.value ?: ""
) == "true"
filterState[FilterConversationFragment.MENTION] = (
arbitraryStorageManager.getStorageSetting(
accountId,
FilterConversationFragment.MENTION,
""
).blockingGet()?.value ?: ""
) == "true"
val newItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
if (filterState[FilterConversationFragment.UNREAD] == false &&
filterState[FilterConversationFragment.MENTION] == false
) {
adapter!!.updateDataSet(conversationItems, true)
} else {
val items = conversationItems
for (i in items) {
val conversation = (i as ConversationItem).model
if (filter(conversation)) {
newItems.add(i)
}
}
adapter!!.updateDataSet(newItems, true)
setFilterableItems(newItems)
}
updateFilterConversationButtonColor()
}
private fun filter(conversation: Conversation): Boolean {
var result = true
for ((k, v) in filterState) {
if (v) {
when (k) {
FilterConversationFragment.MENTION -> result = (result && conversation.unreadMention) ||
(
result &&
(
conversation.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
conversation.type == Conversation.ConversationType.FORMER_ONE_TO_ONE
) &&
(conversation.unreadMessages > 0)
)
FilterConversationFragment.UNREAD -> result = result && (conversation.unreadMessages > 0)
}
}
}
return result
}
private fun setupActionBar() { private fun setupActionBar() {
setSupportActionBar(binding.conversationListToolbar) setSupportActionBar(binding.conversationListToolbar)
@ -578,6 +642,7 @@ class ConversationsListActivity :
sortConversations(conversationItems) sortConversations(conversationItems)
sortConversations(conversationItemsWithHeader) sortConversations(conversationItemsWithHeader)
if (!filterState.containsValue(true)) filterableConversationItems = conversationItems if (!filterState.containsValue(true)) filterableConversationItems = conversationItems
filterConversation()
adapter!!.updateDataSet(filterableConversationItems, false) adapter!!.updateDataSet(filterableConversationItems, false)
Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong()) Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong())
fetchOpenConversations(apiVersion) fetchOpenConversations(apiVersion)
@ -788,8 +853,6 @@ class ConversationsListActivity :
binding.filterConversationsButton.setOnClickListener { binding.filterConversationsButton.setOnClickListener {
val newFragment: DialogFragment = FilterConversationFragment.newInstance( val newFragment: DialogFragment = FilterConversationFragment.newInstance(
adapter!!,
conversationItems,
filterState, filterState,
this this
) )

View File

@ -40,6 +40,13 @@ object Migrations {
} }
} }
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
Log.i("Migrations", "Migrating 8 to 9")
migrateToDualPrimaryKeyArbitraryStorage(database)
}
}
fun migrateToRoom(database: SupportSQLiteDatabase) { fun migrateToRoom(database: SupportSQLiteDatabase) {
database.execSQL( database.execSQL(
"CREATE TABLE User_new (" + "CREATE TABLE User_new (" +
@ -92,4 +99,29 @@ object Migrations {
database.execSQL("ALTER TABLE User_new RENAME TO User") database.execSQL("ALTER TABLE User_new RENAME TO User")
database.execSQL("ALTER TABLE ArbitraryStorage_new RENAME TO ArbitraryStorage") database.execSQL("ALTER TABLE ArbitraryStorage_new RENAME TO ArbitraryStorage")
} }
fun migrateToDualPrimaryKeyArbitraryStorage(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE ArbitraryStorage_dualPK (" +
"accountIdentifier INTEGER NOT NULL, " +
"\"key\" TEXT NOT NULL, " +
"object TEXT, " +
"value TEXT, " +
"PRIMARY KEY(accountIdentifier, \"key\")" +
")"
)
// Copy the data
database.execSQL(
"INSERT INTO ArbitraryStorage_dualPK (" +
"accountIdentifier, \"key\", object, value) " +
"SELECT " +
"accountIdentifier, \"key\", object, value " +
"FROM ArbitraryStorage"
)
// Remove the old table
database.execSQL("DROP TABLE ArbitraryStorage")
// Change the table name to the correct one
database.execSQL("ALTER TABLE ArbitraryStorage_dualPK RENAME TO ArbitraryStorage")
}
} }

View File

@ -45,7 +45,7 @@ import java.util.Locale
@Database( @Database(
entities = [UserEntity::class, ArbitraryStorageEntity::class], entities = [UserEntity::class, ArbitraryStorageEntity::class],
version = 8, version = 9,
exportSchema = true exportSchema = true
) )
@TypeConverters( @TypeConverters(
@ -96,7 +96,7 @@ abstract class TalkDatabase : RoomDatabase() {
.databaseBuilder(context.applicationContext, TalkDatabase::class.java, dbName) .databaseBuilder(context.applicationContext, TalkDatabase::class.java, dbName)
// comment out openHelperFactory to view the database entries in Android Studio for debugging // comment out openHelperFactory to view the database entries in Android Studio for debugging
.openHelperFactory(factory) .openHelperFactory(factory)
.addMigrations(Migrations.MIGRATION_6_8, Migrations.MIGRATION_7_8) .addMigrations(Migrations.MIGRATION_6_8, Migrations.MIGRATION_7_8, Migrations.MIGRATION_8_9)
.allowMainThreadQueries() .allowMainThreadQueries()
.addCallback( .addCallback(
object : RoomDatabase.Callback() { object : RoomDatabase.Callback() {

View File

@ -41,6 +41,11 @@ abstract class ArbitraryStoragesDao {
objectString: String objectString: String
): Maybe<ArbitraryStorageEntity> ): Maybe<ArbitraryStorageEntity>
@Query(
"SELECT * FROM ArbitraryStorage"
)
abstract fun getAll(): Maybe<List<ArbitraryStorageEntity>>
@Query("DELETE FROM ArbitraryStorage WHERE accountIdentifier = :accountIdentifier") @Query("DELETE FROM ArbitraryStorage WHERE accountIdentifier = :accountIdentifier")
abstract fun deleteArbitraryStorage(accountIdentifier: Long): Int abstract fun deleteArbitraryStorage(accountIdentifier: Long): Int

View File

@ -21,10 +21,12 @@
package com.nextcloud.talk.data.storage package com.nextcloud.talk.data.storage
import com.nextcloud.talk.data.storage.model.ArbitraryStorage import com.nextcloud.talk.data.storage.model.ArbitraryStorage
import com.nextcloud.talk.data.storage.model.ArbitraryStorageEntity
import io.reactivex.Maybe import io.reactivex.Maybe
interface ArbitraryStoragesRepository { interface ArbitraryStoragesRepository {
fun getStorageSetting(accountIdentifier: Long, key: String, objectString: String): Maybe<ArbitraryStorage> fun getStorageSetting(accountIdentifier: Long, key: String, objectString: String): Maybe<ArbitraryStorage>
fun deleteArbitraryStorage(accountIdentifier: Long): Int fun deleteArbitraryStorage(accountIdentifier: Long): Int
fun saveArbitraryStorage(arbitraryStorage: ArbitraryStorage): Long fun saveArbitraryStorage(arbitraryStorage: ArbitraryStorage): Long
fun getAll(): Maybe<List<ArbitraryStorageEntity>>
} }

View File

@ -21,6 +21,7 @@
package com.nextcloud.talk.data.storage package com.nextcloud.talk.data.storage
import com.nextcloud.talk.data.storage.model.ArbitraryStorage import com.nextcloud.talk.data.storage.model.ArbitraryStorage
import com.nextcloud.talk.data.storage.model.ArbitraryStorageEntity
import io.reactivex.Maybe import io.reactivex.Maybe
class ArbitraryStoragesRepositoryImpl(private val arbitraryStoragesDao: ArbitraryStoragesDao) : class ArbitraryStoragesRepositoryImpl(private val arbitraryStoragesDao: ArbitraryStoragesDao) :
@ -35,6 +36,10 @@ class ArbitraryStoragesRepositoryImpl(private val arbitraryStoragesDao: Arbitrar
.map { ArbitraryStorageMapper.toModel(it) } .map { ArbitraryStorageMapper.toModel(it) }
} }
override fun getAll(): Maybe<List<ArbitraryStorageEntity>> {
return arbitraryStoragesDao.getAll()
}
override fun deleteArbitraryStorage(accountIdentifier: Long): Int { override fun deleteArbitraryStorage(accountIdentifier: Long): Int {
return arbitraryStoragesDao.deleteArbitraryStorage(accountIdentifier) return arbitraryStoragesDao.deleteArbitraryStorage(accountIdentifier)
} }

View File

@ -25,8 +25,8 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class ArbitraryStorage( data class ArbitraryStorage(
var accountIdentifier: Long = 0, var accountIdentifier: Long,
var key: String? = null, var key: String,
var storageObject: String? = null, var storageObject: String? = null,
var value: String? = null var value: String? = null
) : Parcelable ) : Parcelable

View File

@ -23,18 +23,16 @@ package com.nextcloud.talk.data.storage.model
import android.os.Parcelable import android.os.Parcelable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@Entity(tableName = "ArbitraryStorage") @Entity(tableName = "ArbitraryStorage", primaryKeys = ["accountIdentifier", "key"])
data class ArbitraryStorageEntity( data class ArbitraryStorageEntity(
@PrimaryKey
@ColumnInfo(name = "accountIdentifier") @ColumnInfo(name = "accountIdentifier")
var accountIdentifier: Long = 0, var accountIdentifier: Long = 0,
@ColumnInfo(name = "key") @ColumnInfo(name = "key")
var key: String? = null, var key: String = "",
@ColumnInfo(name = "object") @ColumnInfo(name = "object")
var storageObject: String? = null, var storageObject: String? = null,

View File

@ -28,28 +28,22 @@ import androidx.fragment.app.DialogFragment
import autodagger.AutoInjector import autodagger.AutoInjector
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.adapters.items.ConversationItem
import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.conversationlist.ConversationsListActivity
import com.nextcloud.talk.databinding.DialogFilterConversationBinding import com.nextcloud.talk.databinding.DialogFilterConversationBinding
import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.users.UserManager
import eu.davidea.flexibleadapter.FlexibleAdapter import com.nextcloud.talk.utils.UserIdUtils
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import javax.inject.Inject import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class) @AutoInjector(NextcloudTalkApplication::class)
class FilterConversationFragment( class FilterConversationFragment(
adapter: FlexibleAdapter<AbstractFlexibleItem<*>>,
currentConversations: MutableList<AbstractFlexibleItem<*>>,
savedFilterState: MutableMap<String, Boolean>, savedFilterState: MutableMap<String, Boolean>,
conversationsListActivity: ConversationsListActivity conversationsListActivity: ConversationsListActivity
) : DialogFragment() { ) : DialogFragment() {
lateinit var binding: DialogFilterConversationBinding lateinit var binding: DialogFilterConversationBinding
private var dialogView: View? = null private var dialogView: View? = null
private var currentAdapter: FlexibleAdapter<AbstractFlexibleItem<*>> = adapter
private var currentItems = currentConversations
private var filterState = savedFilterState private var filterState = savedFilterState
private var conversationsList = conversationsListActivity private var conversationsList = conversationsListActivity
@ -58,6 +52,9 @@ class FilterConversationFragment(
@Inject @Inject
lateinit var viewThemeUtils: ViewThemeUtils lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var arbitraryStorageManager: ArbitraryStorageManager
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = DialogFilterConversationBinding.inflate(LayoutInflater.from(context)) binding = DialogFilterConversationBinding.inflate(LayoutInflater.from(context))
dialogView = binding.root dialogView = binding.root
@ -119,57 +116,23 @@ class FilterConversationFragment(
} }
private fun processSubmit() { private fun processSubmit() {
val newItems: MutableList<AbstractFlexibleItem<*>> = ArrayList() // store
if (!filterState.containsValue(true)) { val accountId = UserIdUtils.getIdForUser(userManager.currentUser.blockingGet())
currentAdapter.updateDataSet(currentItems, true) val mentionValue = filterState[MENTION] == true
} else { val unreadValue = filterState[UNREAD] == true
val items = currentItems
for (i in items) {
val conversation = (i as ConversationItem).model
if (filter(conversation)) {
newItems.add(i)
}
}
currentAdapter.updateDataSet(newItems, true)
conversationsList.setFilterableItems(newItems)
}
conversationsList.updateFilterState(
filterState[MENTION]!!,
filterState[UNREAD]!!
)
conversationsList.updateFilterConversationButtonColor() arbitraryStorageManager.storeStorageSetting(accountId, MENTION, mentionValue.toString(), "")
} arbitraryStorageManager.storeStorageSetting(accountId, UNREAD, unreadValue.toString(), "")
private fun filter(conversation: Conversation): Boolean {
var result = true
for ((k, v) in filterState) {
if (v) {
when (k) {
MENTION -> result = (result && conversation.unreadMention) ||
(
result &&
(
conversation.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
conversation.type == Conversation.ConversationType.FORMER_ONE_TO_ONE
) &&
(conversation.unreadMessages > 0)
)
UNREAD -> result = result && (conversation.unreadMessages > 0)
}
}
}
return result conversationsList.filterConversation()
} }
companion object { companion object {
@JvmStatic @JvmStatic
fun newInstance( fun newInstance(
adapter: FlexibleAdapter<AbstractFlexibleItem<*>>,
currentConversations: MutableList<AbstractFlexibleItem<*>>,
savedFilterState: MutableMap<String, Boolean>, savedFilterState: MutableMap<String, Boolean>,
conversationsListActivity: ConversationsListActivity conversationsListActivity: ConversationsListActivity
) = FilterConversationFragment(adapter, currentConversations, savedFilterState, conversationsListActivity) ) = FilterConversationFragment(savedFilterState, conversationsListActivity)
val TAG: String = FilterConversationFragment::class.java.simpleName val TAG: String = FilterConversationFragment::class.java.simpleName
const val MENTION: String = "mention" const val MENTION: String = "mention"
const val UNREAD: String = "unread" const val UNREAD: String = "unread"

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/guest_access_settings" android:id="@+id/guest_access_settings"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -73,7 +72,7 @@
android:background="?android:attr/selectableItemBackground"> android:background="?android:attr/selectableItemBackground">
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">

View File

@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE DO NOT TOUCH; GENERATED BY DRONE
<span class="mdl-layout-title">Lint Report: 93 warnings</span> <span class="mdl-layout-title">Lint Report: 92 warnings</span>