Skip to content

Commit

Permalink
Implement support for multiple Shortcut Tiles on Wear OS (#3697)
Browse files Browse the repository at this point in the history
* Extract JSONArray -> List<String> conversion to extension fun

* Refactor navigation around Shortcuts Tile settings

* Add ShortcutsTileId enum class

* Introduce multiple ShortcutTile subclasses and modify settings UI and storage to support multiple Tiles

* Check if correct ShortcutsTileId is passed as parameter to BaseShortcutTile

* Update TileUpdateRequester usages to account for multiple Tiles

* Add entity count to Shortcut Tile list in Settings

* Fix ktlint errors

* Fix more ktlint errors

* Extract string resource

* Add MULTI_INSTANCES_SUPPORTED to ShortcutsTile to be able to use any number of Tiles

* Refresh the list of Shortcut Tiles in the Settings without needing to restart the app

* Remove test logs

* Update androidx.wear.tiles:tiles to the latest version which doesn't yet require API 34 compileSdk version

* Fix crash when the preference's value is "{}"

* Fix crash when key String is "null" and converting to Int

* Rename placeholder variable name

* Add a comment explaining why to save the tiles in a getter

* Return emptyList() directly for clarity

* Remove icons from "Shortcut tile #n" entries in Settings

* Pass emptyList instead of using the non-null assertion operator to prevent NPEs in edge cases

* Refactor getTileShortcuts and getAllTileShortcuts in WearPrefsRepositoryImpl.
Make the code more readable and understandable, and reduce code duplication.

* Make it explicit that intKey is only intended to be null if stringKey == "null"

* Rename getTileShortcuts to getTileShortcutsAndSaveTileId and make it save the tileId not only when there's a null key in the map, but also when the tileId is not yet in the map. This way, actual Tiles and the tileId keys will be more in sync.

* Handle adding Shortcuts Tile immediately after updating the app to a new version which introduces support for multiple Shortcuts Tiles.

* Show message in the Settings when there are no Shortcuts tiles added yet.

* Refine message in the Settings when there are no Shortcuts tiles added yet

* WIP: ConfigShortcutsTile action

* Update comments about Wear OS versions

* Finalize ConfigShortcutsTile feature by applying @jpelgrom's suggestion to OpenShortcutTileSettingsActivity

* Only wrap the code in runCatching which is expected to throw an Exception under normal circumstances, when the pref value needs to be migrated from the old format.

* Call getTileShortcutsAndSaveTileId in OpenShortcutTileSettingsActivity

* Remove unnecessary stuff
  • Loading branch information
marazmarci authored Jul 31, 2023
1 parent fcab330 commit a8a7363
Show file tree
Hide file tree
Showing 17 changed files with 421 additions and 100 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package io.homeassistant.companion.android.common.data.prefs

interface WearPrefsRepository {
suspend fun getTileShortcuts(): List<String>
suspend fun setTileShortcuts(entities: List<String>)
suspend fun getAllTileShortcuts(): Map<Int?, List<String>>
suspend fun getTileShortcutsAndSaveTileId(tileId: Int): List<String>
suspend fun setTileShortcuts(tileId: Int?, entities: List<String>)
suspend fun removeTileShortcuts(tileId: Int?): List<String>?
suspend fun getShowShortcutText(): Boolean
suspend fun setShowShortcutTextEnabled(enabled: Boolean)
suspend fun getTemplateTileContent(): String
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package io.homeassistant.companion.android.common.data.prefs

import io.homeassistant.companion.android.common.data.LocalStorage
import io.homeassistant.companion.android.common.util.toStringList
import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Named

Expand Down Expand Up @@ -52,15 +54,68 @@ class WearPrefsRepositoryImpl @Inject constructor(
}
}

override suspend fun getTileShortcuts(): List<String> {
val jsonArray = JSONArray(localStorage.getString(PREF_TILE_SHORTCUTS) ?: "[]")
return List(jsonArray.length()) {
jsonArray.getString(it)
override suspend fun getTileShortcutsAndSaveTileId(tileId: Int): List<String> {
val tileIdToShortcutsMap = getAllTileShortcuts()
return if (null in tileIdToShortcutsMap && tileId !in tileIdToShortcutsMap) {
// if there are shortcuts with an unknown (null) tileId key from a previous installation,
// and the tileId parameter is not already present in the map, associate it with those shortcuts
val entities = removeTileShortcuts(null)!!
setTileShortcuts(tileId, entities)
entities
} else {
val entities = tileIdToShortcutsMap[tileId]
if (entities == null) {
setTileShortcuts(tileId, emptyList())
}
entities ?: emptyList()
}
}

override suspend fun setTileShortcuts(entities: List<String>) {
localStorage.putString(PREF_TILE_SHORTCUTS, JSONArray(entities).toString())
override suspend fun getAllTileShortcuts(): Map<Int?, List<String>> {
return localStorage.getString(PREF_TILE_SHORTCUTS)?.let { jsonStr ->
runCatching {
JSONObject(jsonStr)
}.fold(
onSuccess = { jsonObject ->
buildMap {
jsonObject.keys().forEach { stringKey ->
val intKey = stringKey.takeUnless { it == "null" }?.toInt()
val jsonArray = jsonObject.getJSONArray(stringKey)
val entities = jsonArray.toStringList()
put(intKey, entities)
}
}
},
onFailure = {
// backward compatibility with the previous format when there was only one Shortcut Tile:
val jsonArray = JSONArray(jsonStr)
val entities = jsonArray.toStringList()
mapOf(
null to entities // the key is null since we don't (yet) have the tileId
)
}
)
} ?: emptyMap()
}

override suspend fun setTileShortcuts(tileId: Int?, entities: List<String>) {
val map = getAllTileShortcuts() + mapOf(tileId to entities)
setTileShortcuts(map)
}

private suspend fun setTileShortcuts(map: Map<Int?, List<String>>) {
val jsonArrayMap = map.map { (tileId, entities) ->
tileId.toString() to JSONArray(entities)
}.toMap()
val jsonStr = JSONObject(jsonArrayMap).toString()
localStorage.putString(PREF_TILE_SHORTCUTS, jsonStr)
}

override suspend fun removeTileShortcuts(tileId: Int?): List<String>? {
val tileShortcutsMap = getAllTileShortcuts().toMutableMap()
val entities = tileShortcutsMap.remove(tileId)
setTileShortcuts(tileShortcutsMap)
return entities
}

override suspend fun getTemplateTileContent(): String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.homeassistant.companion.android.common.util

import org.json.JSONArray

fun JSONArray.toStringList(): List<String> =
List(length()) { i ->
getString(i)
}
9 changes: 8 additions & 1 deletion common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@
<item quantity="one">%d entity found</item>
<item quantity="other">%d entities found</item>
</plurals>
<plurals name="n_entities">
<item quantity="one">1 entity</item>
<item quantity="other">%d entities</item>
</plurals>
<string name="entity_attribute_checkbox">Append Attribute Value</string>
<string name="entity_id">Entity ID</string>
<string name="entity_id_name">Entity ID: %1$s</string>
Expand Down Expand Up @@ -732,11 +736,14 @@
<string name="shortcuts_choose">Choose shortcuts</string>
<string name="shortcut5_note">Not all launchers support displaying 5 shortcuts, you may not see this shortcut if the above 4 are already added.</string>
<string name="shortcuts_summary">Add shortcuts to launcher</string>
<string name="shortcuts_tile">Shortcuts tile</string>
<string name="shortcut_tiles">Shortcut tiles</string>
<string name="shortcuts_tile_n">Shortcuts tile #%d</string>
<string name="shortcuts_tile_select">Select shortcuts tile to manage</string>
<string name="shortcuts_tile_description">Select up to 7 entities</string>
<string name="shortcuts_tile_empty">Choose entities in settings</string>
<string name="shortcuts_tile_text_setting">Show names on shortcuts tile</string>
<string name="shortcuts_tile_log_in">Log in to Home Assistant to add your first shortcut</string>
<string name="shortcuts_tile_no_tiles_yet">There are no Shortcut tiles added yet</string>
<string name="shortcuts">Shortcuts</string>
<string name="show">Show</string>
<string name="show_share_logs_summary">Sharing logs with the Home Assistant team will help to solve issues. Please share the logs only if you have been asked to do so by a Home Assistant developer</string>
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ sentry-android = "6.26.0"
watchfaceComplicationsDataSourceKtx = "1.1.1"
wear = "1.2.0"
wear-compose-foundation = "1.1.2"
wear-tiles = "1.1.0"
wear-tiles = "1.2.0-alpha07"
wearPhoneInteractions = "1.0.1"
wearInput = "1.2.0-alpha02"
webkit = "1.7.0"
Expand Down
21 changes: 20 additions & 1 deletion wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@
</intent-filter>
</activity>

<activity
android:name=".tiles.OpenShortcutTileSettingsActivity"
android:exported="true"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="ConfigShortcutsTile" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.google.android.clockwork.tiles.category.PROVIDER_CONFIG" />
</intent-filter>
</activity>

<!-- To show confirmations and failures -->
<activity android:name="androidx.wear.activity.ConfirmationActivity" />

Expand Down Expand Up @@ -122,6 +133,14 @@

<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/favorite_entities_tile_example" />

<meta-data
android:name="com.google.android.clockwork.tiles.MULTI_INSTANCES_SUPPORTED"
android:value="true" /> <!-- This is supported starting from Wear OS 3 -->

<meta-data
android:name="com.google.android.clockwork.tiles.PROVIDER_CONFIG_ACTION"
android:value="ConfigShortcutsTile" />
</service>
<service
android:name=".tiles.TemplateTile"
Expand Down Expand Up @@ -219,4 +238,4 @@
</service>
</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_SHORTCUT_TILE
import io.homeassistant.companion.android.home.views.LoadHomePage
import io.homeassistant.companion.android.onboarding.OnboardingActivity
import io.homeassistant.companion.android.sensors.SensorReceiver
Expand All @@ -34,6 +36,16 @@ class HomeActivity : ComponentActivity(), HomeView {
fun newInstance(context: Context): Intent {
return Intent(context, HomeActivity::class.java)
}

fun getShortcutsTileSettingsIntent(
context: Context,
tileId: Int
) = Intent(
Intent.ACTION_VIEW,
"$DEEPLINK_PREFIX_SET_SHORTCUT_TILE/$tileId".toUri(),
context,
HomeActivity::class.java
)
}

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ interface HomePresenter {
suspend fun getDeviceRegistryUpdates(): Flow<DeviceRegistryUpdatedEvent>?
suspend fun getEntityRegistryUpdates(): Flow<EntityRegistryUpdatedEvent>?

suspend fun getTileShortcuts(): List<SimplifiedEntity>
suspend fun setTileShortcuts(entities: List<SimplifiedEntity>)
suspend fun getAllTileShortcuts(): Map<Int?, List<SimplifiedEntity>>
suspend fun getTileShortcuts(tileId: Int): List<SimplifiedEntity>
suspend fun setTileShortcuts(tileId: Int?, entities: List<SimplifiedEntity>)

suspend fun getWearHapticFeedback(): Boolean
suspend fun setWearHapticFeedback(enabled: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,20 @@ class HomePresenterImpl @Inject constructor(
return serverManager.webSocketRepository().getEntityRegistryUpdates()
}

override suspend fun getTileShortcuts(): List<SimplifiedEntity> {
return wearPrefsRepository.getTileShortcuts().map { SimplifiedEntity(it) }
override suspend fun getAllTileShortcuts(): Map<Int?, List<SimplifiedEntity>> {
return wearPrefsRepository.getAllTileShortcuts().mapValues { (_, entities) ->
entities.map {
SimplifiedEntity(it)
}
}
}

override suspend fun getTileShortcuts(tileId: Int): List<SimplifiedEntity> {
return wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId).map { SimplifiedEntity(it) }
}

override suspend fun setTileShortcuts(entities: List<SimplifiedEntity>) {
wearPrefsRepository.setTileShortcuts(entities.map { it.entityString })
override suspend fun setTileShortcuts(tileId: Int?, entities: List<SimplifiedEntity>) {
wearPrefsRepository.setTileShortcuts(tileId, entities.map { it.entityString })
}

override suspend fun getWearHapticFeedback(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
Expand Down Expand Up @@ -85,8 +86,8 @@ class MainViewModel @Inject constructor(
val favoriteEntityIds = favoritesDao.getAllFlow().collectAsState()
private val favoriteCaches = favoriteCachesDao.getAll()

var shortcutEntities = mutableStateListOf<SimplifiedEntity>()
private set
val shortcutEntitiesMap = mutableStateMapOf<Int?, SnapshotStateList<SimplifiedEntity>>()

var areas = mutableListOf<AreaRegistryResponse>()
private set

Expand Down Expand Up @@ -136,7 +137,7 @@ class MainViewModel @Inject constructor(
if (!homePresenter.isConnected()) {
return@launch
}
shortcutEntities.addAll(homePresenter.getTileShortcuts())
loadShortcutTileEntities()
isHapticEnabled.value = homePresenter.getWearHapticFeedback()
isToastEnabled.value = homePresenter.getWearToastConfirmation()
isShowShortcutTextEnabled.value = homePresenter.getShowShortcutText()
Expand All @@ -153,6 +154,16 @@ class MainViewModel @Inject constructor(
}
}

fun loadShortcutTileEntities() {
viewModelScope.launch {
val map = homePresenter.getAllTileShortcuts().mapValues { (_, entities) ->
entities.toMutableStateList()
}
shortcutEntitiesMap.clear()
shortcutEntitiesMap.putAll(map)
}
}

fun loadEntities() {
if (!homePresenter.isConnected()) return
viewModelScope.launch {
Expand Down Expand Up @@ -401,22 +412,24 @@ class MainViewModel @Inject constructor(
}
}

fun setTileShortcut(index: Int, entity: SimplifiedEntity) {
fun setTileShortcut(tileId: Int?, index: Int, entity: SimplifiedEntity) {
viewModelScope.launch {
val shortcutEntities = shortcutEntitiesMap[tileId]!!
if (index < shortcutEntities.size) {
shortcutEntities[index] = entity
} else {
shortcutEntities.add(entity)
}
homePresenter.setTileShortcuts(shortcutEntities)
homePresenter.setTileShortcuts(tileId, entities = shortcutEntities)
}
}

fun clearTileShortcut(index: Int) {
fun clearTileShortcut(tileId: Int?, index: Int) {
viewModelScope.launch {
val shortcutEntities = shortcutEntitiesMap[tileId]!!
if (index < shortcutEntities.size) {
shortcutEntities.removeAt(index)
homePresenter.setTileShortcuts(shortcutEntities)
homePresenter.setTileShortcuts(tileId, entities = shortcutEntities)
}
}
}
Expand Down
Loading

0 comments on commit a8a7363

Please sign in to comment.