Jetpack Compose icon dialog (#3631)

* Add icon dialog based on Jetpack Compose

TODO

* Migrate to new icon dialog

* Migrate old database

* Don't wrap with drawablecompat

* Rebase fixes and updates

 - Fix and update database migration
 - Fix dependencies
 - Fix shortcut icons
 - Fix ComposeView in AlertDialog not working by switching implementation to DialogFragment
 - Fix icons that no longer exist
 - ktlint

* Visual compatibility

 - Automotive asset
 - Handle icon ids in shortcuts to prevent users losing icons when updating shortcuts
 - Add padding, color filter to shortcut icons to keep icons consistent with older icons
 - Increase button widget icon padding to keep sizing consistent
 - Add tip to dialog about searching in non-English languages

* Fix line endings

---------

Co-authored-by: Tiger Oakes <contact@tigeroakes.com>
This commit is contained in:
Joris Pelgröm 2023-07-04 19:25:32 +02:00 committed by GitHub
parent 938c515549
commit d1b17aa606
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1861 additions and 423 deletions

View File

@ -143,8 +143,6 @@ dependencies {
implementation("com.github.Dimezis:BlurView:version-1.6.6")
implementation("org.altbeacon:android-beacon-library:2.19.5")
implementation("com.maltaisn:icondialog:3.3.0")
implementation("com.maltaisn:iconpack-community-material:5.3.45")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.8.22")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.22")

File diff suppressed because one or more lines are too long

View File

@ -13,12 +13,8 @@ import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmap
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
@ -35,6 +31,7 @@ import io.homeassistant.companion.android.database.qs.isSetup
import io.homeassistant.companion.android.database.qs.numberedId
import io.homeassistant.companion.android.settings.SettingsActivity
import io.homeassistant.companion.android.settings.qs.updateActiveTileServices
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@ -113,7 +110,7 @@ abstract class TileExtensions : TileService() {
serverManager.integrationRepository(tileData.serverId).getEntityUpdates(listOf(tileData.entityId))?.collect {
tile.state =
if (it.state in validActiveStates) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
getTileIcon(tileData.iconId, it, applicationContext)?.let { icon ->
getTileIcon(tileData.iconName, it, applicationContext)?.let { icon ->
tile.icon = Icon.createWithBitmap(icon)
}
tile.updateTile()
@ -147,7 +144,7 @@ abstract class TileExtensions : TileService() {
val state: Entity<*>? =
if (
tileData.entityId.split(".")[0] in toggleDomainsWithLock ||
tileData.iconId == null
tileData.iconName == null
) {
withContext(Dispatchers.IO) {
try {
@ -170,7 +167,7 @@ abstract class TileExtensions : TileService() {
tile.state = Tile.STATE_INACTIVE
}
getTileIcon(tileData.iconId, state, context)?.let { icon ->
getTileIcon(tileData.iconName, state, context)?.let { icon ->
tile.icon = Icon.createWithBitmap(icon)
}
Log.d(TAG, "Tile data set for tile ID: $tileId")
@ -308,7 +305,7 @@ abstract class TileExtensions : TileService() {
tileId = tileId,
added = true,
serverId = 0,
iconId = null,
iconName = null,
entityId = "",
label = "",
subtitle = null,
@ -324,26 +321,17 @@ abstract class TileExtensions : TileService() {
updateActiveTileServices(highestInUse, applicationContext)
}
private fun getTileIcon(tileIconId: Int?, entity: Entity<*>?, context: Context): Bitmap? {
private fun getTileIcon(tileIconName: String?, entity: Entity<*>?, context: Context): Bitmap? {
// Create an icon pack and load all drawables.
if (tileIconId != null) {
if (iconPack == null) {
val loader = IconPackLoader(context)
iconPack = createMaterialDesignIconPack(loader)
iconPack!!.loadDrawables(loader.drawableLoader)
}
val iconDrawable = iconPack?.icons?.get(tileIconId)?.drawable
if (iconDrawable != null) {
return DrawableCompat.wrap(iconDrawable).toBitmap()
}
if (!tileIconName.isNullOrBlank()) {
val icon = CommunityMaterial.getIconByMdiName(tileIconName) ?: return null
val iconDrawable = IconicsDrawable(context, icon)
return iconDrawable.toBitmap()
} else {
entity?.getIcon(context)?.let {
return DrawableCompat.wrap(
IconicsDrawable(context, it).apply {
sizeDp = 48
}
).toBitmap()
return IconicsDrawable(context, it).apply {
sizeDp = 48
}.toBitmap()
}
}
@ -352,7 +340,6 @@ abstract class TileExtensions : TileService() {
companion object {
private const val TAG = "TileExtensions"
private var iconPack: IconPack? = null
private val toggleDomains = listOf(
"automation", "cover", "fan", "humidifier", "input_boolean", "light",
"media_player", "remote", "siren", "switch"

View File

@ -8,20 +8,23 @@ import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.google.accompanist.themeadapter.material.MdcTheme
import com.maltaisn.icondialog.IconDialog
import com.maltaisn.icondialog.IconDialogSettings
import com.maltaisn.icondialog.pack.IconPack
import com.mikepenz.iconics.typeface.IIcon
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.settings.qs.views.ManageTilesView
import io.homeassistant.companion.android.util.icondialog.IconDialog
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class ManageTilesFragment : Fragment(), IconDialog.Callback {
class ManageTilesFragment : Fragment() {
companion object {
private const val TAG = "TileFragment"
@ -53,17 +56,24 @@ class ManageTilesFragment : Fragment(), IconDialog.Callback {
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
val settings = IconDialogSettings {
searchVisibility = IconDialog.SearchVisibility.ALWAYS
}
val iconDialog = IconDialog.newInstance(settings)
setContent {
MdcTheme {
var showingDialog by remember { mutableStateOf(false) }
if (showingDialog) {
IconDialog(
onSelect = {
onIconDialogIconsSelected(it)
showingDialog = false
},
onDismissRequest = { showingDialog = false }
)
}
ManageTilesView(
viewModel = viewModel,
onShowIconDialog = { tag ->
iconDialog.show(childFragmentManager, tag)
onShowIconDialog = {
showingDialog = true
}
)
}
@ -76,14 +86,8 @@ class ManageTilesFragment : Fragment(), IconDialog.Callback {
activity?.title = getString(commonR.string.tiles)
}
override val iconDialogIconPack: IconPack
get() = viewModel.iconPack
override fun onIconDialogIconsSelected(dialog: IconDialog, icons: List<com.maltaisn.icondialog.data.Icon>) {
Log.d(TAG, "Selected icon: ${icons.firstOrNull()}")
val selectedIcon = icons.firstOrNull()
if (selectedIcon != null) {
viewModel.selectIcon(selectedIcon)
}
private fun onIconDialogIconsSelected(selectedIcon: IIcon) {
Log.d(TAG, "Selected icon: ${selectedIcon.name}")
viewModel.selectIcon(selectedIcon)
}
}

View File

@ -4,24 +4,19 @@ import android.annotation.SuppressLint
import android.app.Application
import android.app.StatusBarManager
import android.content.ComponentName
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.maltaisn.icondialog.data.Icon
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.domain
@ -72,6 +67,8 @@ import io.homeassistant.companion.android.qs.Tile6Service
import io.homeassistant.companion.android.qs.Tile7Service
import io.homeassistant.companion.android.qs.Tile8Service
import io.homeassistant.companion.android.qs.Tile9Service
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
import io.homeassistant.companion.android.util.icondialog.mdiName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -139,8 +136,6 @@ class ManageTilesViewModel @Inject constructor(
)
}
lateinit var iconPack: IconPack
private val app = application
val slots = loadTileSlots(application.resources)
@ -154,9 +149,7 @@ class ManageTilesViewModel @Inject constructor(
private set
var selectedServerId by mutableStateOf(ServerManager.SERVER_ID_ACTIVE)
private set
var selectedIconId by mutableStateOf<Int?>(null)
private set
var selectedIconDrawable by mutableStateOf(AppCompatResources.getDrawable(application, commonR.drawable.ic_stat_ic_notification))
var selectedIconId by mutableStateOf<String?>(null)
private set
var selectedEntityId by mutableStateOf("")
var tileLabel by mutableStateOf("")
@ -165,6 +158,8 @@ class ManageTilesViewModel @Inject constructor(
private set
var selectedShouldVibrate by mutableStateOf(false)
var tileAuthRequired by mutableStateOf(false)
var selectedIcon: IIcon? = null
private var selectedTileId = 0
private var selectedTileAdded = false
@ -202,16 +197,6 @@ class ManageTilesViewModel @Inject constructor(
selectTile(slots.indexOf(selectedTile))
}
}
viewModelScope.launch(Dispatchers.IO) {
val loader = IconPackLoader(getApplication())
iconPack = createMaterialDesignIconPack(loader)
iconPack.loadDrawables(loader.drawableLoader)
withContext(Dispatchers.Main) {
// The icon pack might not have been initialized when the tile data was loaded
selectTile(slots.indexOf(selectedTile))
}
}
}
fun selectTile(index: Int) {
@ -259,21 +244,9 @@ class ManageTilesViewModel @Inject constructor(
if (selectedIconId == null) selectIcon(null) // trigger drawable update
}
fun selectIcon(icon: Icon?) {
selectedIconId = icon?.id
selectedIconDrawable = if (icon != null) {
icon.drawable?.let { DrawableCompat.wrap(it) }
} else {
sortedEntities.firstOrNull { it.entityId == selectedEntityId }?.let {
it.getIcon(app)?.let { iIcon ->
DrawableCompat.wrap(
IconicsDrawable(app, iIcon).apply {
sizeDp = 20
}
)
}
}
}
fun selectIcon(icon: IIcon?) {
selectedIconId = icon?.mdiName
selectedIcon = icon ?: sortedEntities.firstOrNull { it.entityId == selectedEntityId }?.getIcon(app)
}
private fun updateExistingTileFields(currentTile: TileEntity) {
@ -283,13 +256,7 @@ class ManageTilesViewModel @Inject constructor(
selectedShouldVibrate = currentTile.shouldVibrate
tileAuthRequired = currentTile.authRequired
selectIcon(
currentTile.iconId?.let {
if (::iconPack.isInitialized) {
iconPack.getIcon(it)
} else {
null
}
}
currentTile.iconName?.let { CommunityMaterial.getIconByMdiName(it) }
)
}
@ -300,7 +267,7 @@ class ManageTilesViewModel @Inject constructor(
tileId = selectedTile.id,
serverId = selectedServerId,
added = selectedTileAdded,
iconId = selectedIconId,
iconName = selectedIconId,
entityId = selectedEntityId,
label = tileLabel,
subtitle = tileSubtitle,
@ -315,11 +282,10 @@ class ManageTilesViewModel @Inject constructor(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !selectedTileAdded) {
val statusBarManager = app.getSystemService<StatusBarManager>()
val service = idToTileService[selectedTile.id] ?: Tile1Service::class.java
val icon = selectedIconDrawable?.let {
it.toBitmapOrNull(it.intrinsicWidth, it.intrinsicHeight)?.let { bitmap ->
android.graphics.drawable.Icon.createWithBitmap(bitmap)
}
} ?: android.graphics.drawable.Icon.createWithResource(app, commonR.drawable.ic_stat_ic_notification)
val icon = selectedIcon?.let {
val bitmap = IconicsDrawable(getApplication(), it).toBitmap()
Icon.createWithBitmap(bitmap)
} ?: Icon.createWithResource(app, commonR.drawable.ic_stat_ic_notification)
statusBarManager?.requestAddTileService(
ComponentName(app, service),

View File

@ -1,7 +1,6 @@
package io.homeassistant.companion.android.settings.qs.views
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -31,13 +30,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.settings.qs.ManageTilesViewModel
import io.homeassistant.companion.android.util.compose.ServerDropdownButton
@ -160,12 +157,9 @@ fun ManageTilesView(
OutlinedButton(
onClick = { onShowIconDialog(viewModel.selectedTile.id) }
) {
val iconBitmap = remember(viewModel.selectedIconDrawable) {
viewModel.selectedIconDrawable?.toBitmap()?.asImageBitmap()
}
iconBitmap?.let {
Image(
iconBitmap,
viewModel.selectedIcon?.let { icon ->
com.mikepenz.iconics.compose.Image(
icon,
contentDescription = stringResource(id = R.string.tile_icon),
colorFilter = ColorFilter.tint(colorResource(R.color.colorAccent)),
modifier = Modifier.size(20.dp)

View File

@ -1,7 +1,6 @@
package io.homeassistant.companion.android.settings.shortcuts
import android.content.Intent
import android.graphics.PorterDuff
import android.net.Uri
import android.os.Build
import android.os.Bundle
@ -11,28 +10,24 @@ import android.view.Menu
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.accompanist.themeadapter.material.MdcTheme
import com.maltaisn.icondialog.IconDialog
import com.maltaisn.icondialog.IconDialogSettings
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import com.mikepenz.iconics.typeface.IIcon
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.settings.shortcuts.views.ManageShortcutsView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.homeassistant.companion.android.util.icondialog.IconDialog
import io.homeassistant.companion.android.common.R as commonR
@RequiresApi(Build.VERSION_CODES.N_MR1)
@AndroidEntryPoint
class ManageShortcutsSettingsFragment : Fragment(), IconDialog.Callback {
class ManageShortcutsSettingsFragment : Fragment() {
companion object {
const val MAX_SHORTCUTS = 5
@ -41,19 +36,10 @@ class ManageShortcutsSettingsFragment : Fragment(), IconDialog.Callback {
}
val viewModel: ManageShortcutsViewModel by viewModels()
private lateinit var iconPack: IconPack
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val loader = IconPackLoader(requireContext())
iconPack = createMaterialDesignIconPack(loader)
iconPack.loadDrawables(loader.drawableLoader)
}
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
@ -71,14 +57,20 @@ class ManageShortcutsSettingsFragment : Fragment(), IconDialog.Callback {
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
val settings = IconDialogSettings {
searchVisibility = IconDialog.SearchVisibility.ALWAYS
}
val iconDialog = IconDialog.newInstance(settings)
setContent {
MdcTheme {
ManageShortcutsView(viewModel = viewModel, iconDialog = iconDialog, childFragment = childFragmentManager)
var showingTag by remember { mutableStateOf<String?>(null) }
showingTag?.let { tag ->
IconDialog(
onSelect = {
onIconDialogIconsSelected(tag, it)
showingTag = null
},
onDismissRequest = { showingTag = null }
)
}
ManageShortcutsView(viewModel = viewModel, showIconDialog = { showingTag = it })
}
}
}
@ -92,44 +84,17 @@ class ManageShortcutsSettingsFragment : Fragment(), IconDialog.Callback {
activity?.title = getString(commonR.string.shortcuts)
}
override val iconDialogIconPack: IconPack
get() = iconPack
private fun onIconDialogIconsSelected(tag: String, selectedIcon: IIcon) {
Log.d(TAG, "Selected icon: $selectedIcon")
override fun onIconDialogIconsSelected(dialog: IconDialog, icons: List<com.maltaisn.icondialog.data.Icon>) {
Log.d(TAG, "Selected icon: ${icons.firstOrNull()}")
val selectedIcon = icons.firstOrNull()
if (selectedIcon != null) {
val iconDrawable = selectedIcon.drawable
if (iconDrawable != null) {
val icon = DrawableCompat.wrap(iconDrawable)
icon.setColorFilter(resources.getColor(commonR.color.colorAccent), PorterDuff.Mode.SRC_IN)
when (dialog.tag) {
"shortcut_1" -> {
viewModel.shortcuts[0].selectedIcon.value = selectedIcon.id
viewModel.shortcuts[0].drawable.value = icon
}
"shortcut_2" -> {
viewModel.shortcuts[1].selectedIcon.value = selectedIcon.id
viewModel.shortcuts[1].drawable.value = icon
}
"shortcut_3" -> {
viewModel.shortcuts[2].selectedIcon.value = selectedIcon.id
viewModel.shortcuts[2].drawable.value = icon
}
"shortcut_4" -> {
viewModel.shortcuts[3].selectedIcon.value = selectedIcon.id
viewModel.shortcuts[3].drawable.value = icon
}
"shortcut_5" -> {
viewModel.shortcuts[4].selectedIcon.value = selectedIcon.id
viewModel.shortcuts[4].drawable.value = icon
}
else -> {
viewModel.shortcuts[5].selectedIcon.value = selectedIcon.id
viewModel.shortcuts[5].drawable.value = icon
}
}
}
val index = when (tag) {
"shortcut_1" -> 0
"shortcut_2" -> 1
"shortcut_3" -> 2
"shortcut_4" -> 3
"shortcut_5" -> 4
else -> 5
}
viewModel.shortcuts[index].selectedIcon.value = selectedIcon
}
}

View File

@ -4,35 +4,41 @@ import android.app.Application
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.Bitmap
import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.DrawableCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.IconicsSize
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.padding
import com.mikepenz.iconics.utils.size
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.IconDialogCompat
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
import io.homeassistant.companion.android.util.icondialog.mdiName
import io.homeassistant.companion.android.webview.WebViewActivity
import io.homeassistant.companion.android.widgets.assist.AssistShortcutActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@RequiresApi(Build.VERSION_CODES.N_MR1)
@ -44,14 +50,13 @@ class ManageShortcutsViewModel @Inject constructor(
val app = application
private val TAG = "ShortcutViewModel"
private lateinit var iconPack: IconPack
private var shortcutManager = application.applicationContext.getSystemService<ShortcutManager>()!!
val canPinShortcuts = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && shortcutManager.isRequestPinShortcutSupported
var pinnedShortcuts = shortcutManager.pinnedShortcuts
.filter { !it.id.startsWith(AssistShortcutActivity.SHORTCUT_PREFIX) }
.toMutableList()
private set
var dynamicShortcuts: MutableList<ShortcutInfo> = shortcutManager.dynamicShortcuts
var dynamicShortcuts = mutableListOf<ShortcutInfo>()
private set
var servers by mutableStateOf(serverManager.defaultServers)
@ -61,15 +66,16 @@ class ManageShortcutsViewModel @Inject constructor(
private val currentServerId = serverManager.getServer()?.id ?: 0
private val iconIdToName: Map<Int, String> by lazy { IconDialogCompat(app.assets).loadAllIcons() }
data class Shortcut(
var id: MutableState<String?>,
var serverId: MutableState<Int>,
var selectedIcon: MutableState<Int>,
var selectedIcon: MutableState<IIcon?>,
var label: MutableState<String>,
var desc: MutableState<String>,
var path: MutableState<String>,
var type: MutableState<String>,
var drawable: MutableState<Drawable?>,
var delete: MutableState<Boolean>
)
@ -90,6 +96,7 @@ class ManageShortcutsViewModel @Inject constructor(
}
}
}
updateDynamicShortcuts()
Log.d(TAG, "We have ${dynamicShortcuts.size} dynamic shortcuts")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -102,12 +109,11 @@ class ManageShortcutsViewModel @Inject constructor(
Shortcut(
mutableStateOf(""),
mutableStateOf(currentServerId),
mutableStateOf(0),
mutableStateOf(null),
mutableStateOf(""),
mutableStateOf(""),
mutableStateOf(""),
mutableStateOf("lovelace"),
mutableStateOf(AppCompatResources.getDrawable(application, R.drawable.ic_stat_ic_notification_blue)),
mutableStateOf(false)
)
)
@ -119,26 +125,31 @@ class ManageShortcutsViewModel @Inject constructor(
}
}
fun createShortcut(shortcutId: String, serverId: Int, shortcutLabel: String, shortcutDesc: String, shortcutPath: String, bitmap: Bitmap? = null, iconId: Int) {
fun createShortcut(shortcutId: String, serverId: Int, shortcutLabel: String, shortcutDesc: String, shortcutPath: String, icon: IIcon?) {
Log.d(TAG, "Attempt to add shortcut $shortcutId")
val intent = Intent(
WebViewActivity.newInstance(getApplication(), shortcutPath, serverId).addFlags(
WebViewActivity.newInstance(app, shortcutPath, serverId).addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
)
)
intent.action = shortcutPath
intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
intent.putExtra("iconId", iconId)
icon?.let { intent.putExtra("iconName", icon.mdiName) }
val shortcut = ShortcutInfo.Builder(getApplication(), shortcutId)
val shortcut = ShortcutInfo.Builder(app, shortcutId)
.setShortLabel(shortcutLabel)
.setLongLabel(shortcutDesc)
.setIcon(
if (bitmap != null) {
if (icon != null) {
val bitmap = IconicsDrawable(app, icon).apply {
size = IconicsSize.dp(48)
padding = IconicsSize.dp(2)
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(app, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
}.toBitmap()
Icon.createWithBitmap(bitmap)
} else {
Icon.createWithResource(getApplication(), R.drawable.ic_stat_ic_notification_blue)
Icon.createWithResource(app, R.drawable.ic_stat_ic_notification_blue)
}
)
.setIntent(intent)
@ -146,7 +157,7 @@ class ManageShortcutsViewModel @Inject constructor(
if (shortcutId.startsWith("shortcut")) {
shortcutManager.addDynamicShortcuts(listOf(shortcut))
dynamicShortcuts = shortcutManager.dynamicShortcuts
updateDynamicShortcuts()
} else {
var isNewPinned = true
for (item in pinnedShortcuts) {
@ -154,7 +165,7 @@ class ManageShortcutsViewModel @Inject constructor(
isNewPinned = false
Log.d(TAG, "Updating pinned shortcut: $shortcutId")
shortcutManager.updateShortcuts(listOf(shortcut))
Toast.makeText(getApplication(), R.string.shortcut_updated, Toast.LENGTH_SHORT).show()
Toast.makeText(app, R.string.shortcut_updated, Toast.LENGTH_SHORT).show()
}
}
@ -169,64 +180,54 @@ class ManageShortcutsViewModel @Inject constructor(
fun deleteShortcut(shortcutId: String) {
shortcutManager.removeDynamicShortcuts(listOf(shortcutId))
dynamicShortcuts = shortcutManager.dynamicShortcuts
updateDynamicShortcuts()
}
fun setPinnedShortcutData(shortcutId: String) {
fun setPinnedShortcutData(shortcutId: String) = viewModelScope.launch {
for (item in pinnedShortcuts) {
if (item.id == shortcutId) {
shortcuts.last().id.value = item.id
shortcuts.last().serverId.value = item.intent?.extras?.getInt("server", currentServerId) ?: currentServerId
shortcuts.last().label.value = item.shortLabel.toString()
shortcuts.last().desc.value = item.longLabel.toString()
shortcuts.last().path.value = item.intent?.action.toString()
shortcuts.last().selectedIcon.value = item.intent?.extras?.getInt("iconId").toString().toIntOrNull() ?: 0
if (shortcuts.last().selectedIcon.value != 0) {
shortcuts.last().drawable.value = getTileIcon(shortcuts.last().selectedIcon.value)
}
if (shortcuts.last().path.value.startsWith("entityId:")) {
shortcuts.last().type.value = "entityId"
} else {
shortcuts.last().type.value = "lovelace"
}
shortcuts.last().setData(item)
}
}
}
fun setDynamicShortcutData(shortcutId: String, index: Int) {
private fun updateDynamicShortcuts() {
dynamicShortcuts = shortcutManager.dynamicShortcuts.sortedBy { it.id }.toMutableList()
}
private fun setDynamicShortcutData(shortcutId: String, index: Int) = viewModelScope.launch {
if (dynamicShortcuts.isNotEmpty()) {
for (item in dynamicShortcuts) {
if (item.id == shortcutId) {
Log.d(TAG, "setting ${item.id} data")
shortcuts[index].serverId.value = item.intent?.extras?.getInt("server", currentServerId) ?: currentServerId
shortcuts[index].label.value = item.shortLabel.toString()
shortcuts[index].desc.value = item.longLabel.toString()
shortcuts[index].path.value = item.intent?.action.toString()
shortcuts[index].selectedIcon.value = item.intent?.extras?.getInt("iconId").toString().toIntOrNull() ?: 0
if (shortcuts[index].selectedIcon.value != 0) {
shortcuts[index].drawable.value = getTileIcon(shortcuts[index].selectedIcon.value)
}
if (shortcuts[index].path.value.startsWith("entityId:")) {
shortcuts[index].type.value = "entityId"
} else {
shortcuts[index].type.value = "lovelace"
}
shortcuts[index].setData(item)
}
}
}
}
private fun getTileIcon(tileIconId: Int): Drawable? {
val loader = IconPackLoader(getApplication())
iconPack = createMaterialDesignIconPack(loader)
iconPack.loadDrawables(loader.drawableLoader)
val iconDrawable = iconPack.icons[tileIconId]?.drawable
if (iconDrawable != null) {
val icon = DrawableCompat.wrap(iconDrawable)
icon.setColorFilter(app.resources.getColor(R.color.colorAccent), PorterDuff.Mode.SRC_IN)
return icon
private suspend fun Shortcut.setData(item: ShortcutInfo) {
serverId.value = item.intent?.extras?.getInt("server", currentServerId) ?: currentServerId
label.value = item.shortLabel.toString()
desc.value = item.longLabel.toString()
path.value = item.intent?.action.toString()
selectedIcon.value = if (item.intent?.extras?.containsKey("iconName") == true) {
item.intent?.extras?.getString("iconName")?.let { CommunityMaterial.getIconByMdiName(it) }
} else if (item.intent?.extras?.containsKey("iconId") == true) {
withContext(Dispatchers.IO) {
item.intent?.extras?.getInt("iconId")?.takeIf { it != 0 }?.let {
CommunityMaterial.getIconByMdiName("mdi:${iconIdToName.getValue(it)}")
}
}
} else {
null
}
if (path.value.startsWith("entityId:")) {
type.value = "entityId"
} else {
type.value = "lovelace"
}
return null
}
fun updatePinnedShortcuts() {

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.Divider
@ -25,16 +26,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.fragment.app.FragmentManager
import com.maltaisn.icondialog.IconDialog
import com.mikepenz.iconics.compose.IconicsPainter
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.settings.shortcuts.ManageShortcutsSettingsFragment
import io.homeassistant.companion.android.settings.shortcuts.ManageShortcutsViewModel
@ -44,8 +42,7 @@ import io.homeassistant.companion.android.util.compose.ServerDropdownButton
@Composable
fun ManageShortcutsView(
viewModel: ManageShortcutsViewModel,
iconDialog: IconDialog,
childFragment: FragmentManager
showIconDialog: (tag: String) -> Unit
) {
LazyColumn(contentPadding = PaddingValues(16.dp)) {
item {
@ -67,8 +64,7 @@ fun ManageShortcutsView(
CreateShortcutView(
i = i,
viewModel = viewModel,
iconDialog = iconDialog,
childFragment = childFragment
showIconDialog = showIconDialog
)
}
}
@ -76,7 +72,11 @@ fun ManageShortcutsView(
@RequiresApi(Build.VERSION_CODES.N_MR1)
@Composable
private fun CreateShortcutView(i: Int, viewModel: ManageShortcutsViewModel, iconDialog: IconDialog, childFragment: FragmentManager) {
private fun CreateShortcutView(
i: Int,
viewModel: ManageShortcutsViewModel,
showIconDialog: (tag: String) -> Unit
) {
val context = LocalContext.current
var expandedEntity by remember { mutableStateOf(false) }
var expandedPinnedShortcuts by remember { mutableStateOf(false) }
@ -155,17 +155,21 @@ private fun CreateShortcutView(i: Int, viewModel: ManageShortcutsViewModel, icon
modifier = Modifier.padding(end = 10.dp)
)
OutlinedButton(onClick = {
iconDialog.show(childFragment, shortcutId)
showIconDialog(shortcutId)
}) {
val icon = viewModel.shortcuts[i].drawable.value?.let { DrawableCompat.wrap(it) }
icon?.toBitmap()?.asImageBitmap()
?.let {
Image(
it,
contentDescription = stringResource(id = R.string.shortcut_icon),
colorFilter = ColorFilter.tint(colorResource(R.color.colorAccent))
)
}
val icon = viewModel.shortcuts[i].selectedIcon.value
val painter = if (icon != null) {
remember(icon) { IconicsPainter(icon) }
} else {
painterResource(R.drawable.ic_stat_ic_notification_blue)
}
Image(
painter = painter,
contentDescription = stringResource(id = R.string.shortcut_icon),
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(colorResource(R.color.colorAccent))
)
}
}
@ -269,7 +273,6 @@ private fun CreateShortcutView(i: Int, viewModel: ManageShortcutsViewModel, icon
shortcut.label.value,
shortcut.desc.value,
shortcut.path.value,
shortcut.drawable.value?.toBitmap(),
shortcut.selectedIcon.value
)
},

View File

@ -0,0 +1,24 @@
package io.homeassistant.companion.android.util.icondialog
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
const val MDI_PREFIX = "mdi:"
/**
* Gets the MDI name of an Iconics icon.
* MDI format is used by Home Assistant (ie "mdi:account-alert"),
* compared to Iconic's [IIcon.name] format (ie "cmd_account_alert").
*/
val IIcon.mdiName: String
get() = name.replace("${CommunityMaterial.mappingPrefix}_", MDI_PREFIX).replace('_', '-')
fun CommunityMaterial.getIconByMdiName(mdiName: String): IIcon? {
val name = mdiName.replace(MDI_PREFIX, "${mappingPrefix}_").replace('-', '_')
return try {
getIcon(name)
} catch (e: IllegalArgumentException) {
// Icon doesn't exist (anymore)
null
}
}

View File

@ -0,0 +1,69 @@
package io.homeassistant.companion.android.util.icondialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.google.accompanist.themeadapter.material.MdcTheme
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
@Composable
fun IconDialogContent(
iconFilter: IconFilter = DefaultIconFilter(),
onSelect: (IIcon) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
Column {
IconDialogSearch(value = searchQuery, onValueChange = { searchQuery = it })
IconDialogGrid(
typeface = CommunityMaterial,
searchQuery = searchQuery,
iconFilter = iconFilter,
onClick = onSelect
)
}
}
@Composable
fun IconDialog(
iconFilter: IconFilter = DefaultIconFilter(),
onSelect: (IIcon) -> Unit,
onDismissRequest: () -> Unit
) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = Modifier
.width(480.dp)
.height(500.dp),
shape = MaterialTheme.shapes.medium
) {
IconDialogContent(iconFilter = iconFilter, onSelect = onSelect)
}
}
}
@Preview
@Composable
private fun IconDialogPreview() {
MdcTheme {
Surface(
modifier = Modifier
.width(480.dp)
.height(500.dp),
shape = MaterialTheme.shapes.medium
) {
IconDialogContent(onSelect = {})
}
}
}

View File

@ -0,0 +1,57 @@
package io.homeassistant.companion.android.util.icondialog
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.DialogFragment
import com.google.accompanist.themeadapter.material.MdcTheme
import com.mikepenz.iconics.typeface.IIcon
import kotlin.math.min
class IconDialogFragment(callback: (IIcon) -> Unit) : DialogFragment() {
companion object {
const val TAG = "IconDialogFragment"
}
private val onSelect = callback
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).also {
it.clipToPadding = true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(view as ComposeView).setContent {
MdcTheme {
IconDialogContent(
onSelect = onSelect
)
}
}
}
override fun onResume() {
super.onResume()
val params = dialog?.window?.attributes ?: return
params.width = min(
(resources.displayMetrics.widthPixels * 0.9).toInt(),
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 480f, resources.displayMetrics).toInt()
)
params.height = min(
(resources.displayMetrics.heightPixels * 0.9).toInt(),
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 500f, resources.displayMetrics).toInt()
)
dialog?.window?.attributes = params
}
}

View File

@ -0,0 +1,98 @@
package io.homeassistant.companion.android.util.icondialog
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.themeadapter.material.MdcTheme
import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.ITypeface
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Display a grid of icons, letting the user select one.
* @param icons List of icons to display
* @param onClick Invoked when the user clicks on the given icon
*/
@Composable
fun IconDialogGrid(
icons: List<IIcon>,
tint: Color = MaterialTheme.colors.onSurface,
onClick: (IIcon) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 48.dp),
modifier = Modifier.fillMaxSize()
) {
items(icons) { icon ->
IconButton(onClick = { onClick(icon) }) {
Image(
asset = icon,
colorFilter = ColorFilter.tint(tint),
// https://material.io/design/iconography/system-icons.html#color
alpha = 0.54f
)
}
}
}
}
/**
* Display a grid of icons, letting the user select one.
* @param typeface Icon typeface that includes all possible icons.
* @param searchQuery Search term used to filter icons from the [typeface].
* @param iconFilter Adjust filtering logic for the search process.
* @param onClick Invoked when the user clicks on the given icon
*/
@Composable
fun IconDialogGrid(
typeface: ITypeface,
searchQuery: String,
iconFilter: IconFilter = DefaultIconFilter(),
tint: Color = MaterialTheme.colors.onSurface,
onClick: (IIcon) -> Unit
) {
var icons by remember { mutableStateOf<List<IIcon>>(emptyList()) }
LaunchedEffect(typeface, searchQuery) {
icons = withContext(Dispatchers.IO) { iconFilter.queryIcons(typeface, searchQuery) }
}
IconDialogGrid(icons = icons, tint = tint, onClick = onClick)
}
@Preview
@Composable
private fun IconDialogGridPreview() {
MdcTheme {
Surface(
modifier = Modifier
.width(480.dp)
.height(500.dp),
shape = MaterialTheme.shapes.medium
) {
IconDialogGrid(
icons = CommunityMaterial.icons.map { name -> CommunityMaterial.getIcon(name) },
onClick = {}
)
}
}
}

View File

@ -0,0 +1,60 @@
package io.homeassistant.companion.android.util.icondialog
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.themeadapter.material.MdcTheme
import io.homeassistant.companion.android.common.R
@Composable
fun IconDialogSearch(
value: String,
onValueChange: (String) -> Unit
) {
val isEnglish by remember { mutableStateOf(Locale.current.language == "en") }
TextField(
modifier = Modifier.fillMaxWidth(),
value = value,
onValueChange = onValueChange,
singleLine = true,
label = {
Text(text = stringResource(if (isEnglish) R.string.search_icons else R.string.search_icons_in_english))
},
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = if (value.isNotBlank()) {
{
IconButton(onClick = { onValueChange("") }) {
Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
}
}
} else {
null
}
)
}
@Preview
@Composable
private fun IconDialogSearchPreview() {
MdcTheme {
Surface {
IconDialogSearch(value = "account", onValueChange = {})
}
}
}

View File

@ -0,0 +1,79 @@
package io.homeassistant.companion.android.util.icondialog
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.ITypeface
import java.text.Normalizer
import java.util.Locale
/**
* Normalize [this] string, removing all diacritics, all unicode characters, hyphens,
* apostrophes and more. Resulting text has only lowercase latin letters and digits.
*/
private fun String.normalize(locale: Locale): String {
val normalized = this.lowercase(locale).trim().let { Normalizer.normalize(it, Normalizer.Form.NFKD) }
return normalized.filter { c -> c in 'a'..'z' || c in 'а'..'я' || c in '0'..'9' }
}
/**
* Class used to filter the icons for search and sort them afterwards.
* Icon filter must be parcelable to be put in bundle.
*/
interface IconFilter {
/**
* Get a list of all matching icons for a search [query], in no specific order.
*/
fun queryIcons(pack: ITypeface, query: String? = null): List<IIcon>
}
class DefaultIconFilter(
/**
* Regex used to split the query into multiple search terms.
* Can also be null to not split the query.
*/
private val termSplitPattern: Regex? = """[;,\s]""".toRegex(),
/**
* Whether to normalize search query or not, using [String.normalize].
*/
private val queryNormalized: Boolean = true
) : IconFilter {
/**
* Get a list of all matching icons for a search [query].
* Base implementation only returns the complete list of icons in the pack,
* sorted by ID. Subclasses take care of actual searching and must always ensure
* that the returned list is sorted by ID.
*/
override fun queryIcons(pack: ITypeface, query: String?): List<IIcon> {
val icons = pack.icons
if (query == null || query.isBlank()) {
// No search query, return all icons.
return icons.map { key -> pack.getIcon(key) }
}
// Split query into terms.
val trimmedQuery = query.trim()
val terms = (termSplitPattern?.let { trimmedQuery.split(it) } ?: listOf(trimmedQuery))
.map {
if (queryNormalized) {
it.normalize(Locale.ROOT)
} else {
it.lowercase(Locale.ROOT)
}
}.filter { it.isNotBlank() }
// Remove all icons that don't match any of the search terms.
return icons
.filter { icon -> matchesSearch(icon, terms) }
.map { key -> pack.getIcon(key) }
}
/**
* Check if an [icon] name matches any of the search [terms].
*/
private fun matchesSearch(icon: String, terms: List<String>): Boolean {
val name = if (queryNormalized) icon.normalize(Locale.ROOT) else icon
return terms.any { it in name }
}
}

View File

@ -21,9 +21,11 @@ import androidx.core.graphics.toColorInt
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.android.material.color.DynamicColors
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.IconicsSize
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.padding
import com.mikepenz.iconics.utils.size
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.servers.ServerManager
@ -31,6 +33,7 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetDao
import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity
import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
import io.homeassistant.companion.android.util.getAttribute
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
import io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -56,7 +59,7 @@ class ButtonWidget : AppWidgetProvider() {
internal const val EXTRA_SERVICE = "EXTRA_SERVICE"
internal const val EXTRA_SERVICE_DATA = "EXTRA_SERVICE_DATA"
internal const val EXTRA_LABEL = "EXTRA_LABEL"
internal const val EXTRA_ICON = "EXTRA_ICON"
internal const val EXTRA_ICON_NAME = "EXTRA_ICON_NAME"
internal const val EXTRA_BACKGROUND_TYPE = "EXTRA_BACKGROUND_TYPE"
internal const val EXTRA_TEXT_COLOR = "EXTRA_TEXT_COLOR"
internal const val EXTRA_REQUIRE_AUTHENTICATION = "EXTRA_REQUIRE_AUTHENTICATION"
@ -71,8 +74,6 @@ class ButtonWidget : AppWidgetProvider() {
@Inject
lateinit var buttonWidgetDao: ButtonWidgetDao
private var iconPack: IconPack? = null
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onUpdate(
@ -176,12 +177,6 @@ class ButtonWidget : AppWidgetProvider() {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
// Create an icon pack and load all drawables.
if (iconPack == null) {
val loader = IconPackLoader(context)
iconPack = createMaterialDesignIconPack(loader)
iconPack!!.loadDrawables(loader.drawableLoader)
}
val useDynamicColors = widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable()
return RemoteViews(context.packageName, if (useDynamicColors) R.layout.widget_button_wrapper_dynamiccolor else R.layout.widget_button_wrapper_default).apply {
// Theming
@ -196,39 +191,41 @@ class ButtonWidget : AppWidgetProvider() {
setLabelVisibility(this, widget)
// Content
val iconId = widget?.iconId ?: 988171 // Lightning bolt
val iconData = widget?.iconName?.let { CommunityMaterial.getIconByMdiName(it) }
?: CommunityMaterial.Icon2.cmd_flash // Lightning bolt
val iconDrawable = iconPack?.icons?.get(iconId)?.drawable
if (iconDrawable != null) {
val icon = DrawableCompat.wrap(iconDrawable)
if (widget?.backgroundType == WidgetBackgroundType.TRANSPARENT) {
setInt(R.id.widgetImageButton, "setColorFilter", textColor)
}
// Determine reasonable dimensions for drawing vector icon as a bitmap
val aspectRatio = iconDrawable.intrinsicWidth / iconDrawable.intrinsicHeight.toDouble()
val awo = if (widget != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(widget.id) else null
val maxWidth = (
awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, DEFAULT_MAX_ICON_SIZE)
?: DEFAULT_MAX_ICON_SIZE
).coerceAtLeast(16)
val maxHeight = (
awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, DEFAULT_MAX_ICON_SIZE)
?: DEFAULT_MAX_ICON_SIZE
).coerceAtLeast(16)
val width: Int
val height: Int
if (maxWidth > maxHeight) {
width = maxWidth
height = (maxWidth * (1 / aspectRatio)).toInt()
} else {
width = (maxHeight * aspectRatio).toInt()
height = maxHeight
}
// Render the icon into the Button's ImageView
setImageViewBitmap(R.id.widgetImageButton, icon.toBitmap(width, height))
val iconDrawable = IconicsDrawable(context, iconData).apply {
padding = IconicsSize.dp(2)
size = IconicsSize.dp(24)
}
val icon = DrawableCompat.wrap(iconDrawable)
if (widget?.backgroundType == WidgetBackgroundType.TRANSPARENT) {
setInt(R.id.widgetImageButton, "setColorFilter", textColor)
}
// Determine reasonable dimensions for drawing vector icon as a bitmap
val aspectRatio = iconDrawable.intrinsicWidth / iconDrawable.intrinsicHeight.toDouble()
val awo = if (widget != null) AppWidgetManager.getInstance(context).getAppWidgetOptions(widget.id) else null
val maxWidth = (
awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, DEFAULT_MAX_ICON_SIZE)
?: DEFAULT_MAX_ICON_SIZE
).coerceAtLeast(16)
val maxHeight = (
awo?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, DEFAULT_MAX_ICON_SIZE)
?: DEFAULT_MAX_ICON_SIZE
).coerceAtLeast(16)
val width: Int
val height: Int
if (maxWidth > maxHeight) {
width = maxWidth
height = (maxWidth * (1 / aspectRatio)).toInt()
} else {
width = (maxHeight * aspectRatio).toInt()
height = maxHeight
}
// Render the icon into the Button's ImageView
setImageViewBitmap(R.id.widgetImageButton, icon.toBitmap(width, height))
setOnClickPendingIntent(
R.id.widgetImageButtonLayout,
@ -364,7 +361,7 @@ class ButtonWidget : AppWidgetProvider() {
val serviceData: String? = extras.getString(EXTRA_SERVICE_DATA)
val label: String? = extras.getString(EXTRA_LABEL)
val requireAuthentication: Boolean = extras.getBoolean(EXTRA_REQUIRE_AUTHENTICATION)
val icon: Int = extras.getInt(EXTRA_ICON)
val icon: String = extras.getString(EXTRA_ICON_NAME) ?: "mdi:flash"
val backgroundType: WidgetBackgroundType = extras.getSerializable(EXTRA_BACKGROUND_TYPE) as WidgetBackgroundType
val textColor: String? = extras.getString(EXTRA_TEXT_COLOR)

View File

@ -5,6 +5,8 @@ import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Build
import android.os.Bundle
import android.text.Editable
@ -21,21 +23,18 @@ import android.widget.Spinner
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.toColorInt
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.android.material.color.DynamicColors
import com.maltaisn.icondialog.IconDialog
import com.maltaisn.icondialog.IconDialogSettings
import com.maltaisn.icondialog.data.Icon
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.Service
import io.homeassistant.companion.android.database.widget.ButtonWidgetDao
@ -43,6 +42,9 @@ import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
import io.homeassistant.companion.android.databinding.WidgetButtonConfigureBinding
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel
import io.homeassistant.companion.android.util.getHexForColor
import io.homeassistant.companion.android.util.icondialog.IconDialogFragment
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
import io.homeassistant.companion.android.util.icondialog.mdiName
import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.common.ServiceFieldBinder
import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter
@ -52,10 +54,9 @@ import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity(), IconDialog.Callback {
class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() {
companion object {
private const val TAG: String = "ButtonWidgetConfigAct"
private const val ICON_DIALOG_TAG = "icon-dialog"
private const val PIN_WIDGET_CALLBACK = "io.homeassistant.companion.android.widgets.button.ButtonWidgetConfigureActivity.PIN_WIDGET_CALLBACK"
}
@ -63,8 +64,6 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity(), IconDialog.
lateinit var buttonWidgetDao: ButtonWidgetDao
override val dao get() = buttonWidgetDao
private lateinit var iconPack: IconPack
private var services = mutableMapOf<Int, HashMap<String, Service>>()
private var entities = mutableMapOf<Int, HashMap<String, Entity<Any>>>()
private var dynamicFields = ArrayList<ServiceFieldBinder>()
@ -258,9 +257,6 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity(), IconDialog.
setupServerSelect(buttonWidget?.serverId)
// Create an icon pack loader with application context.
val loader = IconPackLoader(this)
serviceAdapter = SingleItemArrayAdapter<Service>(this) {
if (it != null) getServiceString(it) else ""
}
@ -349,16 +345,20 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity(), IconDialog.
// Do this off the main thread, takes a second or two...
runOnUiThread {
// Create an icon pack and load all drawables.
iconPack = createMaterialDesignIconPack(loader)
iconPack.loadDrawables(loader.drawableLoader)
val settings = IconDialogSettings {
searchVisibility = IconDialog.SearchVisibility.ALWAYS
}
val iconDialog = IconDialog.newInstance(settings)
val iconId = buttonWidget?.iconId ?: 62017
onIconDialogIconsSelected(iconDialog, listOf(iconPack.icons[iconId]!!))
val iconName = buttonWidget?.iconName ?: "mdi:flash"
val icon = CommunityMaterial.getIconByMdiName(iconName) ?: CommunityMaterial.Icon2.cmd_flash
onIconDialogIconsSelected(icon)
binding.widgetConfigIconSelector.setOnClickListener {
iconDialog.show(supportFragmentManager, ICON_DIALOG_TAG)
var alertDialog: DialogFragment? = null
alertDialog = IconDialogFragment(
callback = {
onIconDialogIconsSelected(it)
alertDialog?.dismiss()
}
)
alertDialog.show(supportFragmentManager, IconDialogFragment.TAG)
}
}
}
@ -400,23 +400,12 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity(), IconDialog.
}
}
override val iconDialogIconPack: IconPack?
get() = iconPack
private fun onIconDialogIconsSelected(selectedIcon: IIcon) {
binding.widgetConfigIconSelector.tag = selectedIcon.mdiName
val iconDrawable = IconicsDrawable(this, selectedIcon)
iconDrawable.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(this, commonR.color.colorIcon), PorterDuff.Mode.SRC_IN)
override fun onIconDialogIconsSelected(dialog: IconDialog, icons: List<Icon>) {
Log.d(TAG, "Selected icon: ${icons.firstOrNull()}")
val selectedIcon = icons.firstOrNull()
if (selectedIcon != null) {
binding.widgetConfigIconSelector.tag = selectedIcon.id
val iconDrawable = selectedIcon.drawable
if (iconDrawable != null) {
val icon = DrawableCompat.wrap(iconDrawable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
DrawableCompat.setTint(icon, resources.getColor(commonR.color.colorIcon, theme))
}
binding.widgetConfigIconSelector.setImageBitmap(icon.toBitmap())
}
}
binding.widgetConfigIconSelector.setImageBitmap(iconDrawable.toBitmap())
}
private fun onAddWidget() {
@ -459,8 +448,8 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity(), IconDialog.
binding.label.text.toString()
)
intent.putExtra(
ButtonWidget.EXTRA_ICON,
binding.widgetConfigIconSelector.tag as Int
ButtonWidget.EXTRA_ICON_NAME,
binding.widgetConfigIconSelector.tag as String
)
// Analyze and send service data

View File

@ -45,6 +45,9 @@ android {
java {
srcDirs("../app/src/main/java")
}
assets {
srcDirs("../app/src/main/assets")
}
res {
srcDirs("../app/src/main/res")
}
@ -169,8 +172,6 @@ dependencies {
implementation("com.github.Dimezis:BlurView:version-1.6.6")
implementation("org.altbeacon:android-beacon-library:2.19.5")
implementation("com.maltaisn:icondialog:3.3.0")
implementation("com.maltaisn:iconpack-community-material:5.3.45")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.8.22")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.22")

View File

@ -0,0 +1,994 @@
{
"formatVersion": 1,
"database": {
"version": 41,
"identityHash": "5ccc437900bf203ab7db2e782ffbd7f9",
"entities": [
{
"tableName": "sensor_attributes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))",
"fields": [
{
"fieldPath": "sensorId",
"columnName": "sensor_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueType",
"columnName": "value_type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sensor_id",
"name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "authentication_list",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"host"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "sensors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT DEFAULT NULL, `last_sent_icon` TEXT DEFAULT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`, `server_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "registered",
"columnName": "registered",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSentState",
"columnName": "last_sent_state",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "lastSentIcon",
"columnName": "last_sent_icon",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "stateType",
"columnName": "state_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceClass",
"columnName": "device_class",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unitOfMeasurement",
"columnName": "unit_of_measurement",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "stateClass",
"columnName": "state_class",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "entityCategory",
"columnName": "entity_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coreRegistration",
"columnName": "core_registration",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appRegistration",
"columnName": "app_registration",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"server_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "sensor_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))",
"fields": [
{
"fieldPath": "sensorId",
"columnName": "sensor_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueType",
"columnName": "value_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entries",
"columnName": "entries",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sensor_id",
"name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "button_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "iconName",
"columnName": "icon_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "service",
"columnName": "service",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serviceData",
"columnName": "service_data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "requireAuthentication",
"columnName": "require_authentication",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "camera_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "media_player_controls_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `show_skip` INTEGER NOT NULL, `show_seek` INTEGER NOT NULL, `show_volume` INTEGER NOT NULL, `show_source` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "showSkip",
"columnName": "show_skip",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showSeek",
"columnName": "show_seek",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showVolume",
"columnName": "show_volume",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showSource",
"columnName": "show_source",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "static_widget",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attributeIds",
"columnName": "attribute_ids",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "textSize",
"columnName": "text_size",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "stateSeparator",
"columnName": "state_separator",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attributeSeparator",
"columnName": "attribute_separator",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdate",
"columnName": "last_update",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "template_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "template",
"columnName": "template",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textSize",
"columnName": "text_size",
"affinity": "REAL",
"notNull": true,
"defaultValue": "12.0"
},
{
"fieldPath": "lastUpdate",
"columnName": "last_update",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "notification_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL, `server_id` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "received",
"columnName": "received",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "qs_tiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tileId",
"columnName": "tile_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "added",
"columnName": "added",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "iconName",
"columnName": "icon_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subtitle",
"columnName": "subtitle",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shouldVibrate",
"columnName": "should_vibrate",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "authRequired",
"columnName": "auth_required",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "favorites",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "favorite_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `friendly_name` TEXT NOT NULL, `icon` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "friendlyName",
"columnName": "friendly_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "entity_state_complications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `show_title` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "showTitle",
"columnName": "show_title",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `_name` TEXT NOT NULL, `name_override` TEXT, `_version` TEXT, `list_order` INTEGER NOT NULL, `device_name` TEXT, `external_url` TEXT NOT NULL, `internal_url` TEXT, `cloud_url` TEXT, `webhook_id` TEXT, `secret` TEXT, `cloudhook_url` TEXT, `use_cloud` INTEGER NOT NULL, `internal_ssids` TEXT NOT NULL, `prioritize_internal` INTEGER NOT NULL, `access_token` TEXT, `refresh_token` TEXT, `token_expiration` INTEGER, `token_type` TEXT, `install_id` TEXT, `user_id` TEXT, `user_name` TEXT, `user_is_owner` INTEGER, `user_is_admin` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "_name",
"columnName": "_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nameOverride",
"columnName": "name_override",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "_version",
"columnName": "_version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "listOrder",
"columnName": "list_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deviceName",
"columnName": "device_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "connection.externalUrl",
"columnName": "external_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "connection.internalUrl",
"columnName": "internal_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "connection.cloudUrl",
"columnName": "cloud_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "connection.webhookId",
"columnName": "webhook_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "connection.secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "connection.cloudhookUrl",
"columnName": "cloudhook_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "connection.useCloud",
"columnName": "use_cloud",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "connection.internalSsids",
"columnName": "internal_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "connection.prioritizeInternal",
"columnName": "prioritize_internal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "session.accessToken",
"columnName": "access_token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "session.refreshToken",
"columnName": "refresh_token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "session.tokenExpiration",
"columnName": "token_expiration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "session.tokenType",
"columnName": "token_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "session.installId",
"columnName": "install_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user.id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user.name",
"columnName": "user_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user.isOwner",
"columnName": "user_is_owner",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "user.isAdmin",
"columnName": "user_is_admin",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocket_setting` TEXT NOT NULL, `sensor_update_frequency` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websocketSetting",
"columnName": "websocket_setting",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sensorUpdateFrequency",
"columnName": "sensor_update_frequency",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"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, '5ccc437900bf203ab7db2e782ffbd7f9')"
]
}
}

View File

@ -1,9 +1,12 @@
package io.homeassistant.companion.android.database
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ContentValues
import android.content.Context
import android.content.res.AssetManager
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import android.os.Handler
@ -14,6 +17,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.edit
import androidx.core.content.getSystemService
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.OnConflictStrategy
@ -65,7 +69,6 @@ import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity
import io.homeassistant.companion.android.database.widget.WidgetBackgroundTypeConverter
import kotlinx.coroutines.runBlocking
import java.util.UUID
import kotlin.collections.ArrayList
import io.homeassistant.companion.android.common.R as commonR
@Database(
@ -87,7 +90,7 @@ import io.homeassistant.companion.android.common.R as commonR
Server::class,
Setting::class
],
version = 40,
version = 41,
autoMigrations = [
AutoMigration(from = 24, to = 25),
AutoMigration(from = 25, to = 26),
@ -175,12 +178,25 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_20_21,
MIGRATION_21_22,
MIGRATION_22_23,
MIGRATION_23_24
MIGRATION_23_24,
Migration40to41(context.assets)
)
.fallbackToDestructiveMigration()
.build()
}
private fun <T> Cursor.map(transform: (Cursor) -> T): List<T> {
return if (moveToFirst()) {
val results = mutableListOf<T>()
do {
results.add(transform(this))
} while (moveToNext())
results
} else {
emptyList()
}
}
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
@ -210,22 +226,22 @@ abstract class AppDatabase : RoomDatabase() {
}
private val MIGRATION_5_6 = object : Migration(5, 6) {
@SuppressLint("Range")
override fun migrate(database: SupportSQLiteDatabase) {
try {
val contentValues: ArrayList<ContentValues> = ArrayList()
val widgets = database.query("SELECT * FROM `static_widget`")
widgets.use {
if (widgets.count > 0) {
while (widgets.moveToNext()) {
val cv = ContentValues()
cv.put("id", widgets.getInt(widgets.getColumnIndex("id")))
cv.put("entity_id", widgets.getString(widgets.getColumnIndex("entity_id")))
cv.put("attribute_ids", widgets.getString(widgets.getColumnIndex("attribute_id")))
cv.put("label", widgets.getString(widgets.getColumnIndex("label")))
cv.put("text_size", widgets.getFloat(widgets.getColumnIndex("text_size")))
cv.put("state_separator", widgets.getString(widgets.getColumnIndex("separator")))
cv.put("attribute_separator", " ")
contentValues.add(cv)
val contentValues = widgets.map { widgets ->
ContentValues().apply {
put("id", widgets.getInt(widgets.getColumnIndex("id")))
put("entity_id", widgets.getString(widgets.getColumnIndex("entity_id")))
put("attribute_ids", widgets.getString(widgets.getColumnIndex("attribute_id")))
put("label", widgets.getString(widgets.getColumnIndex("label")))
put("text_size", widgets.getFloat(widgets.getColumnIndex("text_size")))
put("state_separator", widgets.getString(widgets.getColumnIndex("separator")))
put("attribute_separator", " ")
}
}
database.execSQL("DROP TABLE IF EXISTS `static_widget`")
database.execSQL("CREATE TABLE IF NOT EXISTS `static_widget` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` FLOAT NOT NULL DEFAULT '30', `state_separator` TEXT NOT NULL DEFAULT '', `attribute_separator` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))")
@ -245,44 +261,38 @@ abstract class AppDatabase : RoomDatabase() {
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
@SuppressLint("Range")
override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("SELECT * FROM sensors")
val sensors = mutableListOf<ContentValues>()
var migrationSuccessful = false
var migrationFailed = false
try {
if (cursor.moveToFirst()) {
while (cursor.moveToNext()) {
sensors.add(
ContentValues().also {
it.put("id", cursor.getString(cursor.getColumnIndex("unique_id")))
it.put("enabled", cursor.getInt(cursor.getColumnIndex("enabled")))
it.put(
"registered",
cursor.getInt(cursor.getColumnIndex("registered"))
)
it.put("state", "")
it.put("state_type", "")
it.put("type", "")
it.put("icon", "")
it.put("name", "")
it.put("device_class", "")
}
)
val sensors = try {
database.query("SELECT * FROM sensors").use { cursor ->
cursor.map {
ContentValues().also {
it.put("id", cursor.getString(cursor.getColumnIndex("unique_id")))
it.put("enabled", cursor.getInt(cursor.getColumnIndex("enabled")))
it.put(
"registered",
cursor.getInt(cursor.getColumnIndex("registered"))
)
it.put("state", "")
it.put("state_type", "")
it.put("type", "")
it.put("icon", "")
it.put("name", "")
it.put("device_class", "")
}
}
migrationSuccessful = true
}
cursor.close()
} catch (e: Exception) {
migrationFailed = true
Log.e(TAG, "Unable to migrate, proceeding with recreating the table", e)
null
}
database.execSQL("DROP TABLE IF EXISTS `sensors`")
database.execSQL("CREATE TABLE IF NOT EXISTS `sensors` (`id` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `registered` INTEGER NOT NULL, `state` TEXT NOT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, PRIMARY KEY(`id`))")
if (migrationSuccessful) {
sensors.forEach {
database.insert("sensors", OnConflictStrategy.REPLACE, it)
}
sensors?.forEach {
database.insert("sensors", OnConflictStrategy.REPLACE, it)
}
if (migrationFailed) {
notifyMigrationFailed()
@ -305,44 +315,38 @@ abstract class AppDatabase : RoomDatabase() {
}
private val MIGRATION_9_10 = object : Migration(9, 10) {
@SuppressLint("Range")
override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("SELECT * FROM sensors")
val sensors = mutableListOf<ContentValues>()
var migrationSuccessful = false
var migrationFailed = false
try {
if (cursor.moveToFirst()) {
while (cursor.moveToNext()) {
sensors.add(
ContentValues().also {
it.put("id", cursor.getString(cursor.getColumnIndex("id")))
it.put("enabled", cursor.getInt(cursor.getColumnIndex("enabled")))
it.put(
"registered",
cursor.getInt(cursor.getColumnIndex("registered"))
)
it.put("state", "")
it.put("last_sent_state", "")
it.put("state_type", "")
it.put("type", "")
it.put("icon", "")
it.put("name", "")
}
)
val sensors = try {
database.query("SELECT * FROM sensors").use { cursor ->
cursor.map {
ContentValues().also {
it.put("id", cursor.getString(cursor.getColumnIndex("id")))
it.put("enabled", cursor.getInt(cursor.getColumnIndex("enabled")))
it.put(
"registered",
cursor.getInt(cursor.getColumnIndex("registered"))
)
it.put("state", "")
it.put("last_sent_state", "")
it.put("state_type", "")
it.put("type", "")
it.put("icon", "")
it.put("name", "")
}
}
migrationSuccessful = true
}
cursor.close()
} catch (e: Exception) {
migrationFailed = true
Log.e(TAG, "Unable to migrate, proceeding with recreating the table", e)
null
}
database.execSQL("DROP TABLE IF EXISTS `sensors`")
database.execSQL("CREATE TABLE IF NOT EXISTS `sensors` (`id` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `registered` INTEGER NOT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT NOT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, PRIMARY KEY(`id`))")
if (migrationSuccessful) {
sensors.forEach {
database.insert("sensors", OnConflictStrategy.REPLACE, it)
}
sensors?.forEach {
database.insert("sensors", OnConflictStrategy.REPLACE, it)
}
if (migrationFailed) {
notifyMigrationFailed()
@ -388,6 +392,7 @@ abstract class AppDatabase : RoomDatabase() {
}
private val MIGRATION_16_17 = object : Migration(16, 17) {
@SuppressLint("Range")
override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("SELECT * FROM sensor_settings")
val sensorSettings = mutableListOf<ContentValues>()
@ -472,19 +477,17 @@ abstract class AppDatabase : RoomDatabase() {
}
)
}
migrationSuccessful = true
}
cursor.close()
} catch (e: Exception) {
migrationFailed = true
Log.e(TAG, "Unable to migrate, proceeding with recreating the table", e)
null
}
database.execSQL("DROP TABLE IF EXISTS `sensor_settings`")
database.execSQL("CREATE TABLE IF NOT EXISTS `sensor_settings` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL DEFAULT 'string', `entries` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT '1', PRIMARY KEY(`sensor_id`, `name`))")
if (migrationSuccessful) {
sensorSettings.forEach {
database.insert("sensor_settings", OnConflictStrategy.REPLACE, it)
}
sensorSettings?.forEach {
database.insert("sensor_settings", OnConflictStrategy.REPLACE, it)
}
if (migrationFailed) {
notifyMigrationFailed()
@ -811,6 +814,86 @@ abstract class AppDatabase : RoomDatabase() {
}
}
class Migration40to41(assets: AssetManager) : Migration(40, 41) {
private val iconIdToName: Map<Int, String> by lazy { IconDialogCompat(assets).loadAllIcons() }
private fun Cursor.getIconName(columnIndex: Int): String {
val iconId = getInt(columnIndex)
return "mdi:${iconIdToName.getValue(iconId)}"
}
@SuppressLint("Range")
override fun migrate(database: SupportSQLiteDatabase) {
var migrationFailed = false
val widgets = try {
database.query("SELECT * FROM `button_widgets`").use { cursor ->
cursor.map {
ContentValues().apply {
put("id", cursor.getString(cursor.getColumnIndex("id")))
put("server_id", cursor.getInt(cursor.getColumnIndex("server_id")))
put("domain", cursor.getString(cursor.getColumnIndex("domain")))
put("service", cursor.getString(cursor.getColumnIndex("service")))
put("service_data", cursor.getString(cursor.getColumnIndex("service_data")))
put("label", cursor.getStringOrNull(cursor.getColumnIndex("label")))
put("background_type", cursor.getString(cursor.getColumnIndex("background_type")))
put("text_color", cursor.getStringOrNull(cursor.getColumnIndex("text_color")))
put("require_authentication", cursor.getInt(cursor.getColumnIndex("require_authentication")))
put("icon_name", cursor.getIconName(cursor.getColumnIndex("icon_id")))
}
}
}
} catch (e: Exception) {
migrationFailed = true
Log.e(TAG, "Unable to migrate, proceeding with recreating the table", e)
null
}
database.execSQL("DROP TABLE IF EXISTS `button_widgets`")
database.execSQL("CREATE TABLE IF NOT EXISTS `button_widgets` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))")
widgets?.forEach {
database.insert("button_widgets", OnConflictStrategy.REPLACE, it)
}
Log.d(TAG, "Migrated ${widgets?.size ?: "no"} button widgets to MDI icon names")
val tiles = try {
database.query("SELECT * FROM `qs_tiles`").use { cursor ->
cursor.map {
ContentValues().apply {
put("id", cursor.getString(cursor.getColumnIndex("id")))
put("tile_id", cursor.getString(cursor.getColumnIndex("tile_id")))
put("added", cursor.getInt(cursor.getColumnIndex("added")))
put("server_id", cursor.getInt(cursor.getColumnIndex("server_id")))
put("entity_id", cursor.getString(cursor.getColumnIndex("entity_id")))
put("label", cursor.getString(cursor.getColumnIndex("label")))
put("subtitle", cursor.getStringOrNull(cursor.getColumnIndex("subtitle")))
put("should_vibrate", cursor.getInt(cursor.getColumnIndex("should_vibrate")))
put("auth_required", cursor.getInt(cursor.getColumnIndex("auth_required")))
val oldIconColumn = cursor.getColumnIndex("icon_id")
if (!cursor.isNull(oldIconColumn)) {
put("icon_name", cursor.getIconName(oldIconColumn))
}
}
}
}
} catch (e: Exception) {
migrationFailed = true
Log.e(TAG, "Unable to migrate, proceeding with recreating the table", e)
null
}
database.execSQL("DROP TABLE IF EXISTS `qs_tiles`")
database.execSQL("CREATE TABLE IF NOT EXISTS `qs_tiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)")
tiles?.forEach {
database.insert("qs_tiles", OnConflictStrategy.REPLACE, it)
}
Log.d(TAG, "Migrated ${tiles?.size ?: "no"} QS tiles to MDI icon names")
if (migrationFailed) {
notifyMigrationFailed()
}
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = appContext.getSystemService<NotificationManager>()!!

View File

@ -0,0 +1,64 @@
package io.homeassistant.companion.android.database
import android.content.res.AssetManager
import android.util.JsonReader
import android.util.NoSuchPropertyException
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import javax.inject.Inject
/**
* Translation layer for IDs used by the old icondialog package to material icon names.
*/
class IconDialogCompat @Inject constructor(
private val assets: AssetManager
) {
/**
* Loads map of icon IDs to regular icon names.
*/
@WorkerThread
fun loadAllIcons(): Map<Int, String> {
val inputStream = assets.open("mdi_id_map.json")
return JsonReader(inputStream.reader()).use { reader ->
val result = mutableMapOf<Int, String>()
reader.beginObject()
while (reader.hasNext()) {
val iconName = reader.nextName()
val iconId = reader.nextInt()
result[iconId] = iconName
}
reader.endObject()
result
}
}
/**
* Loads map of icon IDs to regular icon names in a background thread.
*/
suspend fun loadAllIconsAsync() = coroutineScope {
async(Dispatchers.IO) { loadAllIcons() }
}
suspend fun streamingIconLookup(iconId: Int): String {
val iconName = withContext(Dispatchers.IO) {
val inputStream = assets.open("mdi_id_map.json")
JsonReader(inputStream.reader()).use { reader ->
reader.beginObject()
while (reader.hasNext()) {
val iconName = reader.nextName()
val id = reader.nextInt()
if (iconId == id) {
return@use iconName
}
}
reader.endObject()
throw NoSuchPropertyException("ID $iconId is not valid")
}
}
return iconName
}
}

View File

@ -14,8 +14,9 @@ data class TileEntity(
val added: Boolean,
@ColumnInfo(name = "server_id", defaultValue = "0")
val serverId: Int,
@ColumnInfo(name = "icon_id")
val iconId: Int?,
/** Icon name, such as "mdi:account-alert" */
@ColumnInfo(name = "icon_name")
val iconName: String?,
@ColumnInfo(name = "entity_id")
val entityId: String,
@ColumnInfo(name = "label")

View File

@ -10,8 +10,8 @@ data class ButtonWidgetEntity(
override val id: Int,
@ColumnInfo(name = "server_id", defaultValue = "0")
override val serverId: Int,
@ColumnInfo(name = "icon_id")
val iconId: Int,
@ColumnInfo(name = "icon_name")
val iconName: String,
@ColumnInfo(name = "domain")
val domain: String,
@ColumnInfo(name = "service")

View File

@ -120,8 +120,9 @@
<string name="choose_entity">Choose entity</string>
<string name="choose_server">Choose server</string>
<string name="clear_favorites">Clear Favorites</string>
<string name="clear_search">Clear search</string>
<string name="close">Close</string>
<string name="color_temp">Color temperature: %1$s</string>
<string name="color_temp">Color temperature: %1$d</string>
<string name="collapse">Collapse</string>
<string name="complication_entity_invalid">Invalid entity</string>
<string name="complication_entity_state_content_description">Entity state</string>
@ -486,6 +487,8 @@
<string name="scene">Scene</string>
<string name="scenes">Scenes</string>
<string name="scripts">Scripts</string>
<string name="search_icons">Search icons</string>
<string name="search_icons_in_english">Search icons (in English)</string>
<string name="search_notifications">Search notifications</string>
<string name="search_results">Search Results</string>
<string name="search_sensors">Search sensors</string>