blob: 56f599a3a219855f5e1c15437829ecccbe7230cb [file] [log] [blame]
Dan Sandlerf4e83e02020-05-12 21:25:31 -04001/*
2 * Copyright (C) 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.egg.neko
18
19import android.app.PendingIntent
20import android.content.Intent
21import android.content.res.ColorStateList
22import android.graphics.drawable.Icon
23import android.service.controls.Control
24import android.service.controls.ControlsProviderService
25import android.service.controls.DeviceTypes
26import android.service.controls.actions.ControlAction
27import android.service.controls.actions.FloatAction
28import android.service.controls.templates.ControlButton
29import android.service.controls.templates.RangeTemplate
30import android.service.controls.templates.StatelessTemplate
31import android.service.controls.templates.ToggleTemplate
32import android.text.SpannableStringBuilder
33import android.text.style.ForegroundColorSpan
34import android.util.Log
35import androidx.annotation.RequiresApi
36import com.android.internal.logging.MetricsLogger
37import java.util.Random
38import java.util.concurrent.Flow
39import java.util.function.Consumer
40
41import com.android.egg.R
42
43const val CONTROL_ID_WATER = "water"
44const val CONTROL_ID_FOOD = "food"
45const val CONTROL_ID_TOY = "toy"
46
47const val FOOD_SPAWN_CAT_DELAY_MINS = 5L
48
49const val COLOR_FOOD_FG = 0xFFFF8000.toInt()
50const val COLOR_FOOD_BG = COLOR_FOOD_FG and 0x40FFFFFF.toInt()
51const val COLOR_WATER_FG = 0xFF0080FF.toInt()
52const val COLOR_WATER_BG = COLOR_WATER_FG and 0x40FFFFFF.toInt()
53const val COLOR_TOY_FG = 0xFFFF4080.toInt()
54const val COLOR_TOY_BG = COLOR_TOY_FG and 0x40FFFFFF.toInt()
55
56val P_TOY_ICONS = intArrayOf(
57 1, R.drawable.ic_toy_mouse,
58 1, R.drawable.ic_toy_fish,
59 1, R.drawable.ic_toy_ball,
60 1, R.drawable.ic_toy_laser
61)
62
63@RequiresApi(30)
64fun Control_toString(control: Control): String {
65 val hc = String.format("0x%08x", control.hashCode())
66 return ("Control($hc id=${control.controlId}, type=${control.deviceType}, " +
67 "title=${control.title}, template=${control.controlTemplate})")
68}
69
70@RequiresApi(30)
71public class NekoControlsService : ControlsProviderService(), PrefState.PrefsListener {
72 private val TAG = "NekoControls"
73
74 private val controls = HashMap<String, Control>()
75 private val publishers = ArrayList<UglyPublisher>()
76 private val rng = Random()
77
78 private var lastToyIcon: Icon? = null
79
80 private lateinit var prefs: PrefState
81
82 override fun onCreate() {
83 super.onCreate()
84
85 prefs = PrefState(this)
86 prefs.setListener(this)
87
88 createDefaultControls()
89 }
90
91 override fun onPrefsChanged() {
92 createDefaultControls()
93 }
94
95 private fun createDefaultControls() {
96 val foodState: Int = prefs.foodState
97 if (foodState != 0) {
98 NekoService.registerJobIfNeeded(this, FOOD_SPAWN_CAT_DELAY_MINS)
99 }
100
101 val water = prefs.waterState
102
103 controls[CONTROL_ID_WATER] = makeWaterBowlControl(water)
104 controls[CONTROL_ID_FOOD] = makeFoodBowlControl(foodState != 0)
105 controls[CONTROL_ID_TOY] = makeToyControl(currentToyIcon(), false)
106 }
107
108 private fun currentToyIcon(): Icon {
109 val icon = lastToyIcon ?: randomToyIcon()
110 lastToyIcon = icon
111 return icon
112 }
113
114 private fun randomToyIcon(): Icon {
115 return Icon.createWithResource(resources, Cat.chooseP(rng, P_TOY_ICONS, 4))
116 }
117
118 private fun colorize(s: CharSequence, color: Int): CharSequence {
119 val ssb = SpannableStringBuilder(s)
120 ssb.setSpan(ForegroundColorSpan(color), 0, s.length, 0)
121 return ssb
122 }
123
124 private fun makeToyControl(icon: Icon?, thrown: Boolean): Control {
125 return Control.StatefulBuilder(CONTROL_ID_TOY, getPendingIntent())
126 .setDeviceType(DeviceTypes.TYPE_UNKNOWN)
127 .setCustomIcon(icon)
128 // ?.setTint(COLOR_TOY_FG)) // TODO(b/159559045): uncomment when fixed
129 .setCustomColor(ColorStateList.valueOf(COLOR_TOY_BG))
130 .setTitle(colorize(getString(R.string.control_toy_title), COLOR_TOY_FG))
131 .setStatusText(colorize(
132 if (thrown) getString(R.string.control_toy_status) else "",
133 COLOR_TOY_FG))
134 .setControlTemplate(StatelessTemplate("toy"))
135 .setStatus(Control.STATUS_OK)
136 .setSubtitle(if (thrown) "" else getString(R.string.control_toy_subtitle))
137 .setAppIntent(getAppIntent())
138 .build()
139 }
140
141 private fun makeWaterBowlControl(fillLevel: Float): Control {
142 return Control.StatefulBuilder(CONTROL_ID_WATER, getPendingIntent())
143 .setDeviceType(DeviceTypes.TYPE_KETTLE)
144 .setTitle(colorize(getString(R.string.control_water_title), COLOR_WATER_FG))
145 .setCustomColor(ColorStateList.valueOf(COLOR_WATER_BG))
146 .setCustomIcon(Icon.createWithResource(resources,
147 if (fillLevel >= 100f) R.drawable.ic_water_filled else R.drawable.ic_water))
148 //.setTint(COLOR_WATER_FG)) // TODO(b/159559045): uncomment when fixed
149 .setControlTemplate(RangeTemplate("waterlevel", 0f, 200f, fillLevel, 10f,
150 "%.0f mL"))
151 .setStatus(Control.STATUS_OK)
152 .setSubtitle(if (fillLevel == 0f) getString(R.string.control_water_subtitle) else "")
153 .build()
154 }
155
156 private fun makeFoodBowlControl(filled: Boolean): Control {
157 return Control.StatefulBuilder(CONTROL_ID_FOOD, getPendingIntent())
158 .setDeviceType(DeviceTypes.TYPE_UNKNOWN)
159 .setCustomColor(ColorStateList.valueOf(COLOR_FOOD_BG))
160 .setTitle(colorize(getString(R.string.control_food_title), COLOR_FOOD_FG))
161 .setCustomIcon(Icon.createWithResource(resources,
162 if (filled) R.drawable.ic_foodbowl_filled else R.drawable.ic_bowl))
163 // .setTint(COLOR_FOOD_FG)) // TODO(b/159559045): uncomment when fixed
164 .setStatusText(
165 if (filled) colorize(
166 getString(R.string.control_food_status_full), 0xCCFFFFFF.toInt())
167 else colorize(
168 getString(R.string.control_food_status_empty), 0x80FFFFFF.toInt()))
169 .setControlTemplate(ToggleTemplate("foodbowl", ControlButton(filled, "Refill")))
170 .setStatus(Control.STATUS_OK)
171 .setSubtitle(if (filled) "" else getString(R.string.control_food_subtitle))
172 .build()
173 }
174
175 private fun getPendingIntent(): PendingIntent {
176 val intent = Intent(Intent.ACTION_MAIN)
177 .setClass(this, NekoLand::class.java)
178 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
179 return PendingIntent.getActivity(this, 0, intent,
180 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
181 }
182
183 private fun getAppIntent(): PendingIntent {
184 return getPendingIntent()
185 }
186
187
188 override fun performControlAction(
189 controlId: String,
190 action: ControlAction,
191 consumer: Consumer<Int>
192 ) {
193 when (controlId) {
194 CONTROL_ID_FOOD -> {
195 // refill bowl
196 controls[CONTROL_ID_FOOD] = makeFoodBowlControl(true)
197 Log.v(TAG, "Bowl refilled. (Registering job.)")
198 NekoService.registerJob(this, FOOD_SPAWN_CAT_DELAY_MINS)
199 MetricsLogger.histogram(this, "egg_neko_offered_food", 11)
200 prefs.foodState = 11
201 }
202 CONTROL_ID_TOY -> {
203 Log.v(TAG, "Toy tossed.")
204 controls[CONTROL_ID_TOY] =
205 makeToyControl(currentToyIcon(), true)
206 // TODO: re-enable toy
207 Thread() {
208 Thread.sleep((1 + Random().nextInt(4)) * 1000L)
209 NekoService.getExistingCat(prefs)?.let {
210 NekoService.notifyCat(this, it)
211 }
212 controls[CONTROL_ID_TOY] = makeToyControl(randomToyIcon(), false)
213 pushControlChanges()
214 }.start()
215 }
216 CONTROL_ID_WATER -> {
217 if (action is FloatAction) {
218 controls[CONTROL_ID_WATER] = makeWaterBowlControl(action.newValue)
219 Log.v(TAG, "Water level set to " + action.newValue)
220 prefs.waterState = action.newValue
221 }
222 }
223 else -> {
224 return
225 }
226 }
227 consumer.accept(ControlAction.RESPONSE_OK)
228 pushControlChanges()
229 }
230
231 private fun pushControlChanges() {
232 Thread() {
233 publishers.forEach { it.refresh() }
234 }.start()
235 }
236
237 private fun makeStateless(c: Control?): Control? {
238 if (c == null) return null
239 return Control.StatelessBuilder(c.controlId, c.appIntent)
240 .setTitle(c.title)
241 .setSubtitle(c.subtitle)
242 .setStructure(c.structure)
243 .setDeviceType(c.deviceType)
244 .setCustomIcon(c.customIcon)
245 .setCustomColor(c.customColor)
246 .build()
247 }
248
249 override fun createPublisherFor(list: MutableList<String>): Flow.Publisher<Control> {
250 createDefaultControls()
251
252 val publisher = UglyPublisher(list, true)
253 publishers.add(publisher)
254 return publisher
255 }
256
257 override fun createPublisherForAllAvailable(): Flow.Publisher<Control> {
258 createDefaultControls()
259
260 val publisher = UglyPublisher(controls.keys, false)
261 publishers.add(publisher)
262 return publisher
263 }
264
265 private inner class UglyPublisher(
266 val controlKeys: Iterable<String>,
267 val indefinite: Boolean
268 ) : Flow.Publisher<Control> {
269 val subscriptions = ArrayList<UglySubscription>()
270
271 private inner class UglySubscription(
272 val initialControls: Iterator<Control>,
273 var subscriber: Flow.Subscriber<in Control>?
274 ) : Flow.Subscription {
275 override fun cancel() {
276 Log.v(TAG, "cancel subscription: $this for subscriber: $subscriber " +
277 "to publisher: $this@UglyPublisher")
278 subscriber = null
279 unsubscribe(this)
280 }
281
282 override fun request(p0: Long) {
283 (0 until p0).forEach { _ ->
284 if (initialControls.hasNext()) {
285 send(initialControls.next())
286 } else {
287 if (!indefinite) subscriber?.onComplete()
288 }
289 }
290 }
291
292 fun send(c: Control) {
293 Log.v(TAG, "sending update: " + Control_toString(c) + " => " + subscriber)
294 subscriber?.onNext(c)
295 }
296 }
297
298 override fun subscribe(subscriber: Flow.Subscriber<in Control>) {
299 Log.v(TAG, "subscribe to publisher: $this by subscriber: $subscriber")
300 val sub = UglySubscription(controlKeys.mapNotNull { controls[it] }.iterator(),
301 subscriber)
302 subscriptions.add(sub)
303 subscriber.onSubscribe(sub)
304 }
305
306 fun unsubscribe(sub: UglySubscription) {
307 Log.v(TAG, "no more subscriptions, removing subscriber: $sub")
308 subscriptions.remove(sub)
309 if (subscriptions.size == 0) {
310 Log.v(TAG, "no more subscribers, removing publisher: $this")
311 publishers.remove(this)
312 }
313 }
314
315 fun refresh() {
316 controlKeys.mapNotNull { controls[it] }.forEach { control ->
317 subscriptions.forEach { sub ->
318 sub.send(control)
319 }
320 }
321 }
322 }
323}