mirror of
https://github.com/home-assistant/android
synced 2024-09-06 17:23:32 +00:00
Add a tile to start a conversation (#3158)
* Add a tile to interact with conversation integration * Check HA core version and that conversation is enabled * Rename search to conversation * Add some TODOs, review comments for more clean up * Review comment * Add back private set
This commit is contained in:
parent
8df504e1d7
commit
7f32738e10
|
@ -83,4 +83,6 @@ interface IntegrationRepository {
|
||||||
suspend fun updateSensors(sensors: Array<SensorRegistration<Any>>): Boolean
|
suspend fun updateSensors(sensors: Array<SensorRegistration<Any>>): Boolean
|
||||||
|
|
||||||
suspend fun shouldNotifySecurityWarning(): Boolean
|
suspend fun shouldNotifySecurityWarning(): Boolean
|
||||||
|
|
||||||
|
suspend fun getConversation(speech: String): String?
|
||||||
}
|
}
|
||||||
|
|
|
@ -583,6 +583,13 @@ class IntegrationRepositoryImpl @Inject constructor(
|
||||||
}?.toList()
|
}?.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(speech: String): String? {
|
||||||
|
// TODO: Also send back conversation ID for dialogue
|
||||||
|
val response = webSocketRepository.getConversation(speech)
|
||||||
|
|
||||||
|
return response?.response?.speech?.plain?.get("speech")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getEntities(): List<Entity<Any>>? {
|
override suspend fun getEntities(): List<Entity<Any>>? {
|
||||||
val response = webSocketRepository.getStates()
|
val response = webSocketRepository.getStates()
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities.
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryUpdatedEvent
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryUpdatedEvent
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationResponse
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryUpdatedEvent
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryUpdatedEvent
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
|
||||||
|
@ -36,4 +37,5 @@ interface WebSocketRepository {
|
||||||
suspend fun ackNotification(confirmId: String): Boolean
|
suspend fun ackNotification(confirmId: String): Boolean
|
||||||
suspend fun commissionMatterDevice(code: String): Boolean
|
suspend fun commissionMatterDevice(code: String): Boolean
|
||||||
suspend fun commissionMatterDeviceOnNetwork(pin: Long): Boolean
|
suspend fun commissionMatterDeviceOnNetwork(pin: Long): Boolean
|
||||||
|
suspend fun getConversation(speech: String): ConversationResponse?
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import io.homeassistant.companion.android.common.data.websocket.WebSocketState
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryUpdatedEvent
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryUpdatedEvent
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.ConversationResponse
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryUpdatedEvent
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryUpdatedEvent
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
|
||||||
|
@ -179,6 +180,18 @@ class WebSocketRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(speech: String): ConversationResponse? {
|
||||||
|
// TODO: Send default locale of device with request.
|
||||||
|
val socketResponse = sendMessage(
|
||||||
|
mapOf(
|
||||||
|
"type" to "conversation/process",
|
||||||
|
"text" to speech
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return mapResponse(socketResponse)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getStateChanges(): Flow<StateChangedEvent>? =
|
override suspend fun getStateChanges(): Flow<StateChangedEvent>? =
|
||||||
subscribeToEventsForType(EVENT_STATE_CHANGED)
|
subscribeToEventsForType(EVENT_STATE_CHANGED)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
package io.homeassistant.companion.android.common.data.websocket.impl.entities
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ConversationResponse(
|
||||||
|
val response: ConversationSpeechResponse,
|
||||||
|
val conversationId: String?
|
||||||
|
)
|
|
@ -0,0 +1,8 @@
|
||||||
|
package io.homeassistant.companion.android.common.data.websocket.impl.entities
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ConversationSpeechPlainResponse(
|
||||||
|
val plain: Map<String, String?>
|
||||||
|
)
|
|
@ -0,0 +1,12 @@
|
||||||
|
package io.homeassistant.companion.android.common.data.websocket.impl.entities
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ConversationSpeechResponse(
|
||||||
|
val speech: ConversationSpeechPlainResponse,
|
||||||
|
val card: Any?,
|
||||||
|
val language: String?,
|
||||||
|
val responseType: String?,
|
||||||
|
val data: Map<String, String?>?
|
||||||
|
)
|
|
@ -965,4 +965,8 @@
|
||||||
<string name="sensor_name_daily_steps">Daily Steps</string>
|
<string name="sensor_name_daily_steps">Daily Steps</string>
|
||||||
<string name="sensor_description_daily_steps">The total step count over a day, where the previous day ends and a new day begins at 12:00 AM local time.</string>
|
<string name="sensor_description_daily_steps">The total step count over a day, where the previous day ends and a new day begins at 12:00 AM local time.</string>
|
||||||
<string name="tile_vibrate">Vibrate when clicked</string>
|
<string name="tile_vibrate">Vibrate when clicked</string>
|
||||||
|
<string name="no_results">No results yet</string>
|
||||||
|
<string name="speak">Speak</string>
|
||||||
|
<string name="no_conversation_support">You must be at least on Home Assistant 2023.1 and have the conversation integration enabled</string>
|
||||||
|
<string name="conversation">Conversation</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -75,6 +75,9 @@
|
||||||
<!-- To show confirmations and failures -->
|
<!-- To show confirmations and failures -->
|
||||||
<activity android:name="androidx.wear.activity.ConfirmationActivity" />
|
<activity android:name="androidx.wear.activity.ConfirmationActivity" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".conversation.ConversationActivity"
|
||||||
|
android:exported="true" />
|
||||||
<!-- Tiles -->
|
<!-- Tiles -->
|
||||||
<service
|
<service
|
||||||
android:name=".tiles.ShortcutsTile"
|
android:name=".tiles.ShortcutsTile"
|
||||||
|
@ -102,6 +105,18 @@
|
||||||
<meta-data android:name="androidx.wear.tiles.PREVIEW"
|
<meta-data android:name="androidx.wear.tiles.PREVIEW"
|
||||||
android:resource="@drawable/template_tile_example" />
|
android:resource="@drawable/template_tile_example" />
|
||||||
</service>
|
</service>
|
||||||
|
<service
|
||||||
|
android:name=".tiles.ConversationTile"
|
||||||
|
android:label="@string/conversation"
|
||||||
|
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data android:name="androidx.wear.tiles.PREVIEW"
|
||||||
|
android:resource="@drawable/conversation_tile_example" />
|
||||||
|
</service>
|
||||||
<receiver android:name=".tiles.TileActionReceiver"
|
<receiver android:name=".tiles.TileActionReceiver"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
package io.homeassistant.companion.android.conversation
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import io.homeassistant.companion.android.conversation.views.ConversationResultView
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ConversationActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val conversationViewModel by viewModels<ConversationViewModel>()
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ConvActivity"
|
||||||
|
|
||||||
|
fun newInstance(context: Context): Intent {
|
||||||
|
return Intent(context, ConversationActivity::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchResults = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
conversationViewModel.updateSpeechResult(
|
||||||
|
result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS).let {
|
||||||
|
it?.get(0) ?: ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
conversationViewModel.getConversation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
conversationViewModel.isSupportConversation()
|
||||||
|
if (conversationViewModel.supportsConversation) {
|
||||||
|
val searchIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searchResults.launch(searchIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
ConversationResultView(conversationViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
package io.homeassistant.companion.android.conversation
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ConversationViewModel @Inject constructor(
|
||||||
|
application: Application,
|
||||||
|
private val integrationUseCase: IntegrationRepository,
|
||||||
|
private val webSocketRepository: WebSocketRepository
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
var speechResult by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
|
||||||
|
var conversationResult by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
|
||||||
|
var supportsConversation by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun getConversation() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
conversationResult = integrationUseCase.getConversation(speechResult) ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isSupportConversation() {
|
||||||
|
supportsConversation =
|
||||||
|
integrationUseCase.isHomeAssistantVersionAtLeast(2023, 1, 0) &&
|
||||||
|
webSocketRepository.getConfig()?.components?.contains("conversation") == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSpeechResult(result: String) {
|
||||||
|
speechResult = result
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package io.homeassistant.companion.android.conversation.views
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.wear.compose.material.PositionIndicator
|
||||||
|
import androidx.wear.compose.material.Scaffold
|
||||||
|
import androidx.wear.compose.material.Text
|
||||||
|
import androidx.wear.compose.material.rememberScalingLazyListState
|
||||||
|
import io.homeassistant.companion.android.common.R
|
||||||
|
import io.homeassistant.companion.android.conversation.ConversationViewModel
|
||||||
|
import io.homeassistant.companion.android.home.views.TimeText
|
||||||
|
import io.homeassistant.companion.android.theme.WearAppTheme
|
||||||
|
import io.homeassistant.companion.android.views.ThemeLazyColumn
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConversationResultView(
|
||||||
|
conversationViewModel: ConversationViewModel
|
||||||
|
) {
|
||||||
|
|
||||||
|
val scrollState = rememberScalingLazyListState()
|
||||||
|
|
||||||
|
WearAppTheme {
|
||||||
|
Scaffold(
|
||||||
|
positionIndicator = {
|
||||||
|
if (scrollState.isScrollInProgress)
|
||||||
|
PositionIndicator(scalingLazyListState = scrollState)
|
||||||
|
},
|
||||||
|
timeText = { TimeText(visible = !scrollState.isScrollInProgress) }
|
||||||
|
) {
|
||||||
|
ThemeLazyColumn(
|
||||||
|
state = scrollState
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = conversationViewModel.speechResult.ifEmpty {
|
||||||
|
if (conversationViewModel.supportsConversation)
|
||||||
|
stringResource(R.string.no_results)
|
||||||
|
else
|
||||||
|
stringResource(R.string.no_conversation_support)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(40.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (conversationViewModel.conversationResult.isNotEmpty())
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = conversationViewModel.conversationResult,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, start = 32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
104
wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt
Executable file
104
wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt
Executable file
|
@ -0,0 +1,104 @@
|
||||||
|
package io.homeassistant.companion.android.tiles
|
||||||
|
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.wear.tiles.ActionBuilders
|
||||||
|
import androidx.wear.tiles.ColorBuilders.argb
|
||||||
|
import androidx.wear.tiles.DimensionBuilders.dp
|
||||||
|
import androidx.wear.tiles.DimensionBuilders.sp
|
||||||
|
import androidx.wear.tiles.LayoutElementBuilders
|
||||||
|
import androidx.wear.tiles.LayoutElementBuilders.Layout
|
||||||
|
import androidx.wear.tiles.LayoutElementBuilders.LayoutElement
|
||||||
|
import androidx.wear.tiles.ModifiersBuilders
|
||||||
|
import androidx.wear.tiles.RequestBuilders.ResourcesRequest
|
||||||
|
import androidx.wear.tiles.RequestBuilders.TileRequest
|
||||||
|
import androidx.wear.tiles.ResourceBuilders.Resources
|
||||||
|
import androidx.wear.tiles.TileBuilders.Tile
|
||||||
|
import androidx.wear.tiles.TileService
|
||||||
|
import androidx.wear.tiles.TimelineBuilders.Timeline
|
||||||
|
import androidx.wear.tiles.TimelineBuilders.TimelineEntry
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import io.homeassistant.companion.android.common.R
|
||||||
|
import io.homeassistant.companion.android.conversation.ConversationActivity
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.guava.future
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ConversationTile : TileService() {
|
||||||
|
private val serviceJob = Job()
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||||
|
|
||||||
|
override fun onTileRequest(requestParams: TileRequest): ListenableFuture<Tile> =
|
||||||
|
serviceScope.future {
|
||||||
|
Tile.Builder()
|
||||||
|
.setResourcesVersion("1")
|
||||||
|
.setTimeline(
|
||||||
|
Timeline.Builder().addTimelineEntry(
|
||||||
|
TimelineEntry.Builder().setLayout(
|
||||||
|
Layout.Builder().setRoot(
|
||||||
|
boxLayout()
|
||||||
|
).build()
|
||||||
|
).build()
|
||||||
|
).build()
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture<Resources> =
|
||||||
|
serviceScope.future {
|
||||||
|
Resources.Builder()
|
||||||
|
.setVersion("1")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
// Cleans up the coroutine
|
||||||
|
serviceJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun boxLayout(): LayoutElement =
|
||||||
|
LayoutElementBuilders.Box.Builder()
|
||||||
|
.addContent(tappableElement())
|
||||||
|
.setHeight(dp(50f))
|
||||||
|
.setWidth(dp(100f))
|
||||||
|
.setModifiers(
|
||||||
|
ModifiersBuilders.Modifiers.Builder()
|
||||||
|
.setClickable(
|
||||||
|
ModifiersBuilders.Clickable.Builder()
|
||||||
|
.setId("conversation")
|
||||||
|
.setOnClick(
|
||||||
|
ActionBuilders.LaunchAction.Builder()
|
||||||
|
.setAndroidActivity(
|
||||||
|
ActionBuilders.AndroidActivity.Builder()
|
||||||
|
.setClassName(ConversationActivity::class.java.name)
|
||||||
|
.setPackageName(this.packageName)
|
||||||
|
.build()
|
||||||
|
).build()
|
||||||
|
).build()
|
||||||
|
)
|
||||||
|
.setBackground(
|
||||||
|
ModifiersBuilders.Background.Builder()
|
||||||
|
.setColor(argb(ContextCompat.getColor(baseContext, R.color.colorAccent)))
|
||||||
|
.setCorner(
|
||||||
|
ModifiersBuilders.Corner.Builder()
|
||||||
|
.setRadius(dp(10f))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun tappableElement(): LayoutElement =
|
||||||
|
LayoutElementBuilders.Text.Builder()
|
||||||
|
.setText(getString(R.string.speak))
|
||||||
|
.setFontStyle(
|
||||||
|
LayoutElementBuilders.FontStyle.Builder()
|
||||||
|
.setSize(sp(30f))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
BIN
wear/src/main/res/drawable/conversation_tile_example.png
Executable file
BIN
wear/src/main/res/drawable/conversation_tile_example.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Loading…
Reference in a new issue